174 lines
7.3 KiB
Python
174 lines
7.3 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")
|
|
# 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")
|
|
job = digest.hexdigest()
|
|
date = datetime.now(timezone.utc).isoformat()
|
|
manifest = {"id": job, "date": date, "caption": caption, "count": len(photos)}
|
|
atomic_json(temporary / "manifest.json", manifest)
|
|
content = ["---", "layout: post", f'title: "photo-{job}"',
|
|
f'date: "{date}"', "categories: art", "---", "",
|
|
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
|