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**.
|
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
@@ -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:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user