diff --git a/photo-upload/README.md b/photo-upload/README.md index de3d6ce..459b786 100644 --- a/photo-upload/README.md +++ b/photo-upload/README.md @@ -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/` checks publication, and diff --git a/photo-upload/app.py b/photo-upload/app.py index 6f19eb5..2649ec3 100644 --- a/photo-upload/app.py +++ b/photo-upload/app.py @@ -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: diff --git a/photo-upload/tests/test_upload.py b/photo-upload/tests/test_upload.py index 69f1c55..58c8abf 100644 --- a/photo-upload/tests/test_upload.py +++ b/photo-upload/tests/test_upload.py @@ -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 = ' {% include secret %} {{ site.email }}\nsecond line'