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**.
2. **Ask for Input** (Text): “Caption (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**.
- Header: `Authorization` = `Bearer YOUR_TOKEN`.
- 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.
- `highlight`: type **Text**, value the highlight-name response. Select each
prompt's specific output variable so the caption and highlight stay separate.
- 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”;
only `published` means the deployed site's receipt contains this album.
Multi-photo albums require repeated `photos` parts in one request. Verify that
file-list behavior on the target iPhone, starting with one photo and then two.
The ZIP route accepts 1–20 images in archive-entry order and keeps them as one
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
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**,
@@ -181,9 +188,10 @@ Create **Publish snippet**:
- Header `Authorization`: `Bearer YOUR_TOKEN` (same token as photos).
- `text`: **Text**, the combined shared text.
- `caption`: **Text**, the optional note.
- `photos`: **File**, **Snippet Images**, when sharing images. For text-only
requests, omit this field. Use an **If** on whether there are images to choose
between the form with `photos` and the text-only form.
- For images, **Make Archive** (ZIP) from **Snippet Images** first, and send its
output as `archive`, type **File**. For text-only requests, omit this field.
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`.
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;
@@ -191,10 +199,10 @@ Create **Publish snippet**:
same authorization header; `published` confirms deployment.
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
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.
It returns `id`, `count` (image count), `url`, `status_url`, and `status`.
`GET /api/snippets/<id>` checks publication, and
+19 -1
View File
@@ -12,6 +12,7 @@ import shutil
import tempfile
from datetime import datetime, timezone
import warnings
import zipfile
from flask import Flask, abort, jsonify, request
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
@@ -183,6 +184,23 @@ def create_app(data_dir=None, token=None):
def upload():
kind = "snippet" if request.path == "/api/snippets" else "photo"
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:
abort(400, "Send between 1 and 20 files in the photos form field")
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 / "images").mkdir()
for index, photo in enumerate(photos):
raw = photo.read()
raw = photo if isinstance(photo, bytes) else photo.read()
digest.update(hashlib.sha256(raw).digest())
(temporary / "originals" / str(index)).write_bytes(raw)
try:
+33
View File
@@ -6,6 +6,7 @@ import subprocess
import sys
import os
import random
import zipfile
import pytest
from PIL import Image
@@ -70,6 +71,38 @@ def test_multi_megabyte_multipart_file(service):
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):
_, client, data = service
caption = '<script>alert(1)</script> {% include secret %} {{ site.email }}\nsecond line'