Files
theread.me/photo-upload/app.py
T

186 lines
8.0 KiB
Python

"""Authenticated photo intake. Only completed directories are visible to the builder."""
import hashlib
import hmac
import html
import io
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
from datetime import datetime, timezone
import warnings
from flask import Flask, abort, jsonify, request
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
from pillow_heif import register_heif_opener
from werkzeug.exceptions import HTTPException
register_heif_opener()
Image.MAX_IMAGE_PIXELS = 60_000_000
warnings.simplefilter("error", Image.DecompressionBombWarning)
def atomic_json(path, value):
fd, temporary = tempfile.mkstemp(dir=path.parent)
try:
with os.fdopen(fd, "w") as output:
json.dump(value, output)
output.flush()
os.fsync(output.fileno())
os.replace(temporary, path)
finally:
Path(temporary).unlink(missing_ok=True)
def public_text(text):
# Escape both HTML and Liquid: Jekyll evaluates Liquid before Markdown.
return html.escape(text, quote=True).replace("{", "{").replace("}", "}")
def web_image(raw, destination):
with Image.open(io.BytesIO(raw)) as original:
if original.format not in {"JPEG", "PNG", "WEBP", "HEIF"}:
raise ValueError("Use JPEG, PNG, WebP, or HEIC photos")
image = ImageOps.exif_transpose(original)
image.thumbnail((2560, 2560), Image.Resampling.LANCZOS)
if image.info.get("icc_profile"):
image = ImageCms.profileToProfile(
image, ImageCms.ImageCmsProfile(io.BytesIO(image.info["icc_profile"])),
ImageCms.createProfile("sRGB"), outputMode="RGB",
)
if image.mode in {"RGBA", "LA"} or "transparency" in image.info:
rgba = image.convert("RGBA")
background = Image.new("RGB", image.size, "white")
background.paste(rgba, mask=rgba.getchannel("A"))
image = background
else:
image = image.convert("RGB")
# Copy pixels only; no EXIF, GPS, XMP, comments, or original profile.
clean = Image.new("RGB", image.size)
clean.paste(image)
clean.save(destination, "JPEG", quality=88, optimize=True)
def create_app(data_dir=None, token=None):
app = Flask(__name__)
app.config.update(MAX_CONTENT_LENGTH=100 * 1024 * 1024,
MAX_FORM_MEMORY_SIZE=64 * 1024, MAX_FORM_PARTS=40)
data = Path(data_dir or os.environ.get("PHOTO_DATA", "/data"))
if token is None:
token = Path(os.environ.get("PHOTO_TOKEN_FILE", "/run/secrets/photo_token")).read_text().strip()
if len(token) < 32 or not token.isascii():
raise ValueError("Photo token must be at least 32 ASCII characters")
expected_auth = ("Bearer " + token).encode()
for directory in ("submissions", "status", "temporary"):
(data / directory).mkdir(parents=True, exist_ok=True)
@app.before_request
def authenticate():
if request.path != "/healthz":
auth = request.headers.get("Authorization", "").encode()
if not hmac.compare_digest(auth, expected_auth):
abort(401, "Invalid upload token")
@app.errorhandler(HTTPException)
def error_response(error):
return jsonify(error=error.description), error.code
@app.get("/healthz")
def health():
return {"status": "ok"}
def response_for(job):
manifest_path = data / "submissions" / job / "manifest.json"
if not re.fullmatch(r"[a-f0-9]{64}", job) or not manifest_path.is_file():
abort(404)
manifest = json.loads(manifest_path.read_text())
status_path = data / "status" / (job + ".json")
state = json.loads(status_path.read_text()) if status_path.exists() else {"status": "queued"}
if state["status"] == "pushed":
deployed = Path(os.environ.get("PHOTO_DEPLOYMENT_FILE", "/published/current/photo-publication.json"))
try:
if job in json.loads(deployed.read_text())["photos"]:
state["status"] = "published"
except (OSError, ValueError, KeyError):
pass # A successful push is not yet a successful deployment.
return dict(id=job, count=manifest["count"], **state,
status_url=f"/api/photos/{job}", url=f"/art#photo-{job}")
@app.get("/api/photos/<job>")
def status(job):
return response_for(job)
@app.post("/api/photos/<job>/retry")
def retry(job):
current = response_for(job)
if current["status"] == "failed":
atomic_json(data / "status" / (job + ".json"), {"status": "queued"})
return response_for(job), 202
@app.post("/api/photos")
def upload():
photos = request.files.getlist("photos")
if not 1 <= len(photos) <= 20:
abort(400, "Send between 1 and 20 files in the photos form field")
caption = request.form.get("caption", "").strip()
if len(caption) > 4000:
abort(400, "Caption must be at most 4000 characters")
highlight = request.form.get("highlight", "").strip()
if len(highlight) > 200:
abort(400, "Highlight name must be at most 200 characters")
# Ordered byte hashes + caption make retries idempotent, even across restarts.
digest = hashlib.sha256(caption.encode())
temporary = Path(tempfile.mkdtemp(dir=data / "temporary"))
try:
(temporary / "originals").mkdir()
(temporary / "images").mkdir()
for index, photo in enumerate(photos):
raw = photo.read()
digest.update(hashlib.sha256(raw).digest())
(temporary / "originals" / str(index)).write_bytes(raw)
try:
web_image(raw, temporary / "images" / f"{index:02}.jpg")
except (UnidentifiedImageError, OSError, ValueError, SyntaxError,
Image.DecompressionBombError, Image.DecompressionBombWarning,
ImageCms.PyCMSError):
abort(400, f"Photo {index + 1} is unsupported, damaged, or exceeds 60 megapixels")
# Preserve existing IDs when omitted; named highlights participate in deduplication.
if highlight:
digest.update(b"\0highlight\0" + highlight.encode())
job = digest.hexdigest()
date = datetime.now(timezone.utc).isoformat()
manifest = {"id": job, "date": date, "caption": caption,
"highlight": highlight, "count": len(photos)}
atomic_json(temporary / "manifest.json", manifest)
content = ["---", "layout: post", f'title: "photo-{job}"',
f'date: "{date}"', "categories: art"]
if highlight:
content.append(f"anchor: {json.dumps(highlight)}")
content.extend(["---", ""])
# The gallery already renders an anchor before the first photo for highlights.
if not highlight:
content.extend([f'<div id="photo-{job}"></div>', ""])
for index in range(len(photos)):
content.append(f'<p><img src="/img/arts/uploads/{job}/{index:02}.jpg" '
f'alt="{public_text(caption)}" loading="lazy"></p>')
if caption:
content.append('<span class="image-details">' +
public_text(caption).replace("\n", "<br>") + "</span>")
(temporary / "entry.md").write_text("\n".join(content) + "\n")
# Atomic rename handles simultaneous identical submissions without replacing one.
try:
temporary.rename(data / "submissions" / job)
created = True
except OSError:
if not (data / "submissions" / job / "manifest.json").is_file():
raise
created = False
return response_for(job), 202 if created else 200
finally:
shutil.rmtree(temporary, ignore_errors=True)
return app