Publish snippets and Markdown posts through authenticated uploads

This commit is contained in:
2026-09-19 15:34:17 +03:30
parent 17f1a8ef39
commit e42646e09e
14 changed files with 553 additions and 15 deletions
+90 -7
View File
@@ -17,6 +17,7 @@ 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
from posts import parse_upload
register_heif_opener()
Image.MAX_IMAGE_PIXELS = 60_000_000
@@ -67,7 +68,8 @@ def web_image(raw, destination):
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)
# Must exceed Werkzeug's 64 KiB multipart read buffer plus headers.
MAX_FORM_MEMORY_SIZE=512 * 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()
@@ -92,22 +94,78 @@ def create_app(data_dir=None, token=None):
def health():
return {"status": "ok"}
def response_for(job):
def response_for(job, kind="photo"):
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())
if manifest.get("kind", "photo") != kind:
abort(404)
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"]:
if job in json.loads(deployed.read_text()).get(kind + "s", []):
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}")
page = "art" if kind == "photo" else "snippets/"
result = dict(id=job, count=manifest["count"],
status_url=f"/api/{kind}s/{job}",
url=None if kind == "post" else f"/{page}#{kind}-{job}")
result.update(state)
return result
@app.get("/api/posts/<job>")
def post_status(job):
return response_for(job, "post")
@app.post("/api/posts/<job>/retry")
def post_retry(job):
current = response_for(job, "post")
if current["status"] == "failed":
atomic_json(data / "status" / (job + ".json"), {"status": "queued"})
return response_for(job, "post"), 202
@app.post("/api/posts")
def upload_post():
files = request.files.getlist("file")
if len(files) != 1:
abort(400, "Send one Markdown file in the file form field")
raw = files[0].read(1024 * 1024 + 1)
try:
incoming = parse_upload(files[0].filename, raw)
except ValueError as error:
abort(400, str(error))
job = hashlib.sha256(b"post\0" + incoming["key"].encode() + b"\0" + raw).hexdigest()
temporary = Path(tempfile.mkdtemp(dir=data / "temporary"))
try:
atomic_json(temporary / "post.json", incoming)
(temporary / "original.md").write_bytes(raw)
atomic_json(temporary / "manifest.json", dict(id=job, kind="post", count=1,
date=datetime.now(timezone.utc).isoformat()))
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, "post"), 202 if created else 200
finally:
shutil.rmtree(temporary, ignore_errors=True)
@app.get("/api/snippets/<job>")
def snippet_status(job):
return response_for(job, "snippet")
@app.post("/api/snippets/<job>/retry")
def snippet_retry(job):
current = response_for(job, "snippet")
if current["status"] == "failed":
atomic_json(data / "status" / (job + ".json"), {"status": "queued"})
return response_for(job, "snippet"), 202
@app.get("/api/photos/<job>")
def status(job):
@@ -121,10 +179,17 @@ def create_app(data_dir=None, token=None):
return response_for(job), 202
@app.post("/api/photos")
@app.post("/api/snippets")
def upload():
kind = "snippet" if request.path == "/api/snippets" else "photo"
photos = request.files.getlist("photos")
if not 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")
text = request.form.get("text", "").strip() if kind == "snippet" else ""
if len(text) > 20000:
abort(400, "Snippet text must be at most 20000 characters")
if kind == "snippet" and not text and not photos:
abort(400, "Send text or images for the snippet")
caption = request.form.get("caption", "").strip()
if len(caption) > 4000:
abort(400, "Caption must be at most 4000 characters")
@@ -133,6 +198,8 @@ def create_app(data_dir=None, token=None):
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())
if kind == "snippet":
digest.update(b"\0snippet\0" + text.encode())
temporary = Path(tempfile.mkdtemp(dir=data / "temporary"))
try:
(temporary / "originals").mkdir()
@@ -154,6 +221,8 @@ def create_app(data_dir=None, token=None):
date = datetime.now(timezone.utc).isoformat()
manifest = {"id": job, "date": date, "caption": caption,
"highlight": highlight, "count": len(photos)}
if kind == "snippet":
manifest.update(kind=kind, text=text)
atomic_json(temporary / "manifest.json", manifest)
content = ["---", "layout: post", f'title: "photo-{job}"',
f'date: "{date}"', "categories: art"]
@@ -169,6 +238,20 @@ def create_app(data_dir=None, token=None):
if caption:
content.append('<span class="image-details">' +
public_text(caption).replace("\n", "<br>") + "</span>")
if kind == "snippet":
content = [f'<div id="snippet-{job}"></div>', ""]
for paragraph in (text, caption):
if paragraph:
safe = public_text(paragraph)
# Link standalone URL lines; all other shared text stays literal.
lines = [f'<a href="{line}">{line}</a>'
if re.fullmatch(r"https?://[^\s<>]+", line) else line
for line in safe.splitlines()]
content.append('<p dir="auto">' + '<br>\n'.join(lines) + '</p>')
for index in range(len(photos)):
content.append(f'<p><img src="/img/snippets/uploads/{job}/{index:02}.jpg" '
'alt="" loading="lazy"></p>')
content.extend(["", "----", ""])
(temporary / "entry.md").write_text("\n".join(content) + "\n")
# Atomic rename handles simultaneous identical submissions without replacing one.
try:
@@ -178,7 +261,7 @@ def create_app(data_dir=None, token=None):
if not (data / "submissions" / job / "manifest.json").is_file():
raise
created = False
return response_for(job), 202 if created else 200
return response_for(job, kind), 202 if created else 200
finally:
shutil.rmtree(temporary, ignore_errors=True)