Accept ZIP batches to preserve every photo shared by Shortcuts
This commit is contained in:
+18
-10
@@ -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
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user