Accept ZIP batches to preserve every photo shared by Shortcuts

This commit is contained in:
2026-09-19 15:36:16 +03:30
parent e42646e09e
commit a9401b6251
3 changed files with 70 additions and 11 deletions
+18 -10
View File
@@ -131,21 +131,28 @@ Create **Publish to blog**:
1. Enable **Show in Share Sheet**, accepting **Images**. 1. Enable **Show in Share Sheet**, accepting **Images**.
2. **Ask for Input** (Text): “Caption (optional)”. 2. **Ask for Input** (Text): “Caption (optional)”.
3. Add another **Ask for Input** (Text): “Highlight name (optional)”. 3. Add another **Ask for Input** (Text): “Highlight name (optional)”.
4. **Get Contents of URL**: `https://theread.me/api/photos`. 4. **Make Archive** from **Shortcut Input**, format **ZIP**. Keep this output in
a variable named **Photo Archive**. This packages every selected image into
one upload; using an image list directly in a File form field can send only
the first image on some Shortcuts versions.
5. **Get Contents of URL**: `https://theread.me/api/photos`.
- Method: **POST**. - Method: **POST**.
- Header: `Authorization` = `Bearer YOUR_TOKEN`. - Header: `Authorization` = `Bearer YOUR_TOKEN`.
- Request Body: **Form**. - Request Body: **Form**.
- `photos`: type **File**, value **Shortcut Input** (the selected images). - `archive`: type **File**, value **Photo Archive**. Remove the old `photos` field.
- `caption`: type **Text**, value the caption response. - `caption`: type **Text**, value the caption response.
- `highlight`: type **Text**, value the highlight-name response. Select each - `highlight`: type **Text**, value the highlight-name response. Select each
prompt's specific output variable so the caption and highlight stay separate. prompt's specific output variable so the caption and highlight stay separate.
- Do not set Content-Type; Shortcuts supplies the multipart boundary. - Do not set Content-Type; Shortcuts supplies the multipart boundary.
5. Show “Uploaded; publishing” on success. Optionally poll the returned `status_url` 6. Show the actual response and check its `count` against the number of selected
images. Only show “Uploaded; publishing” when there is no `error`. Optionally poll the returned `status_url`
with the same header. `pushed` means “GitHub Actions is building/deploying”; with the same header. `pushed` means “GitHub Actions is building/deploying”;
only `published` means the deployed site's receipt contains this album. only `published` means the deployed site's receipt contains this album.
Multi-photo albums require repeated `photos` parts in one request. Verify that The ZIP route accepts 1–20 images in archive-entry order and keeps them as one
file-list behavior on the target iPhone, starting with one photo and then two. album. The first image in that order is the highlight target. Metadata sidecars
under `__MACOSX/` and `.DS_Store` are ignored. Ordinary API clients can also send
repeated `photos` parts; send either `archive` or `photos`, never both.
Select still photos; videos/Live Photo video components aren't supported. Dates Select still photos; videos/Live Photo video components aren't supported. Dates
are upload time in UTC; captions are plain text. A caption is optional. are upload time in UTC; captions are plain text. A caption is optional.
An optional highlight name (up to 200 characters) adds one link to **Highlights**, An optional highlight name (up to 200 characters) adds one link to **Highlights**,
@@ -181,9 +188,10 @@ Create **Publish snippet**:
- Header `Authorization`: `Bearer YOUR_TOKEN` (same token as photos). - Header `Authorization`: `Bearer YOUR_TOKEN` (same token as photos).
- `text`: **Text**, the combined shared text. - `text`: **Text**, the combined shared text.
- `caption`: **Text**, the optional note. - `caption`: **Text**, the optional note.
- `photos`: **File**, **Snippet Images**, when sharing images. For text-only - For images, **Make Archive** (ZIP) from **Snippet Images** first, and send its
requests, omit this field. Use an **If** on whether there are images to choose output as `archive`, type **File**. For text-only requests, omit this field.
between the form with `photos` and the text-only form. Use an **If** on whether there are images to choose between the form with
`archive` and the text-only form.
- Delete any blank header rows. Do not manually set `Content-Type`. - Delete any blank header rows. Do not manually set `Content-Type`.
6. **Show Result** using the actual response. A response with `error` means the 6. **Show Result** using the actual response. A response with `error` means the
upload failed; don't show a fixed success message. `queued` means accepted; upload failed; don't show a fixed success message. `queued` means accepted;
@@ -191,10 +199,10 @@ Create **Publish snippet**:
same authorization header; `published` confirms deployment. same authorization header; `published` confirms deployment.
For the simplest initial shortcut, accept just Text and URLs and omit the image For the simplest initial shortcut, accept just Text and URLs and omit the image
branch and `photos` field; add the image branch when that works on your phone. branch and `archive` field; add the image branch when that works on your phone.
`POST /api/snippets` accepts up to 20,000 characters of `text`, an optional `POST /api/snippets` accepts up to 20,000 characters of `text`, an optional
4,000-character `caption`, and up to 20 images in repeated `photos` parts, within 4,000-character `caption`, and up to 20 images in a ZIP `archive` (or repeated `photos` parts), within
the existing 100 MiB request limit. At least text or an image is required. the existing 100 MiB request limit. At least text or an image is required.
It returns `id`, `count` (image count), `url`, `status_url`, and `status`. It returns `id`, `count` (image count), `url`, `status_url`, and `status`.
`GET /api/snippets/<id>` checks publication, and `GET /api/snippets/<id>` checks publication, and
+19 -1
View File
@@ -12,6 +12,7 @@ import shutil
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
import warnings import warnings
import zipfile
from flask import Flask, abort, jsonify, request from flask import Flask, abort, jsonify, request
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
@@ -183,6 +184,23 @@ def create_app(data_dir=None, token=None):
def upload(): def upload():
kind = "snippet" if request.path == "/api/snippets" else "photo" kind = "snippet" if request.path == "/api/snippets" else "photo"
photos = request.files.getlist("photos") photos = request.files.getlist("photos")
archives = request.files.getlist("archive")
if archives:
if photos or len(archives) != 1:
abort(400, "Send either photos or one ZIP archive, not both")
try:
with zipfile.ZipFile(archives[0].stream) as archive:
members = [item for item in archive.infolist() if not item.is_dir()
and not item.filename.startswith("__MACOSX/")
and Path(item.filename).name != ".DS_Store"]
if not 1 <= len(members) <= 20:
abort(400, "The ZIP must contain between 1 and 20 images")
if sum(item.file_size for item in members) > 100 * 1024 * 1024:
abort(413, "Uncompressed ZIP images must be at most 100 MiB")
# Read members only; never extract archive paths onto the filesystem.
photos = [archive.read(item) for item in members]
except (zipfile.BadZipFile, RuntimeError, NotImplementedError, OSError):
abort(400, "Send a valid, unencrypted ZIP archive of images")
if not (0 if kind == "snippet" else 1) <= len(photos) <= 20: if not (0 if kind == "snippet" else 1) <= len(photos) <= 20:
abort(400, "Send between 1 and 20 files in the photos form field") abort(400, "Send between 1 and 20 files in the photos form field")
text = request.form.get("text", "").strip() if kind == "snippet" else "" text = request.form.get("text", "").strip() if kind == "snippet" else ""
@@ -205,7 +223,7 @@ def create_app(data_dir=None, token=None):
(temporary / "originals").mkdir() (temporary / "originals").mkdir()
(temporary / "images").mkdir() (temporary / "images").mkdir()
for index, photo in enumerate(photos): for index, photo in enumerate(photos):
raw = photo.read() raw = photo if isinstance(photo, bytes) else photo.read()
digest.update(hashlib.sha256(raw).digest()) digest.update(hashlib.sha256(raw).digest())
(temporary / "originals" / str(index)).write_bytes(raw) (temporary / "originals" / str(index)).write_bytes(raw)
try: try:
+33
View File
@@ -6,6 +6,7 @@ import subprocess
import sys import sys
import os import os
import random import random
import zipfile
import pytest import pytest
from PIL import Image from PIL import Image
@@ -70,6 +71,38 @@ def test_multi_megabyte_multipart_file(service):
assert response.status_code == 202, response.json assert response.status_code == 202, response.json
def test_zip_batch_preserves_all_images_order_and_deduplicates(service):
_, client, data = service
images = [photo("red"), photo("blue"), photo("green")]
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w") as output:
for i, raw in enumerate(images):
output.writestr(f"selection/{i}.jpg", raw)
output.writestr("__MACOSX/._0.jpg", b"metadata")
response = client.post("/api/photos", headers=AUTH, data={
"archive": (io.BytesIO(archive.getvalue()), "photos.zip"),
"caption": "Batch", "highlight": "Trip",
})
assert response.status_code == 202, response.json
assert response.json["count"] == 3
folder = data / "submissions" / response.json["id"]
for i, raw in enumerate(images):
assert (folder / "originals" / str(i)).read_bytes() == raw
assert upload(client, images, "Batch", highlight="Trip").json["id"] == response.json["id"]
def test_zip_invalid_or_oversized_batches_are_atomic(service):
_, client, data = service
for contents in ([b"bad"], [photo()] * 21):
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w") as output:
for i, raw in enumerate(contents):
output.writestr(f"{i}.jpg", raw)
response = client.post("/api/photos", headers=AUTH, data={"archive": (io.BytesIO(archive.getvalue()), "photos.zip")})
assert response.status_code == 400
assert not list((data / "submissions").iterdir())
def test_album_caption_order_and_retry_deduplication(service): def test_album_caption_order_and_retry_deduplication(service):
_, client, data = service _, client, data = service
caption = '<script>alert(1)</script> {% include secret %} {{ site.email }}\nsecond line' caption = '<script>alert(1)</script> {% include secret %} {{ site.email }}\nsecond line'