Publish snippets and Markdown posts through authenticated uploads
This commit is contained in:
+90
-7
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user