feat(booth): upload-for-pickup with human-readable ids (v0.1.1)
Add a reverse direction to the Booth: the operator (or any client via `curl -F`) can upload files through the browser and pick them up by a human-readable id. - POST /upload — streams files to a new booth named with a human-readable id (e.g. 4-wombat / star-84), 303-redirects to /b/<id>/ (id in the Location header so curl clients can read it). Uploads reuse the whole booth machinery (render, per-file download links, 24h TTL sweep, delete). - Human-readable ids: word+number in either order, collision-checked, from a curated 140-word friendly list; secrets-based selection. - Safety: filenames reduced to a safe basename (no traversal), streaming size cap (BOOTH_MAX_UPLOAD_MB, default 1024) + file-count cap (BOOTH_MAX_FILES, default 50), partial-write cleanup on any failure. - UI: Australis-themed upload/drop panel (drag-drop, progressive-enhancement JS, degrades to a native file input), a "⬆ pickup" badge on upload booths, a pickup banner, and a ⬇ download link on every gallery item. - python-multipart dependency; homepage tile description updated; 9 new tests (24 total, all green).
This commit is contained in:
+107
-2
@@ -18,13 +18,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
HTMLResponse,
|
||||
@@ -141,6 +142,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
"kinds": kinds,
|
||||
"thumb_url": thumb_url,
|
||||
"has_index": (child / "index.html").is_file(),
|
||||
"uploaded": (child / UPLOAD_MARKER).exists(),
|
||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||
"mtime": mtime,
|
||||
}
|
||||
@@ -204,16 +206,71 @@ def build_gallery(child: Path) -> list[dict]:
|
||||
return items
|
||||
|
||||
|
||||
# ---- uploads (browser drop-off for pickup) ---------------------------------
|
||||
|
||||
UPLOAD_MARKER = ".uploaded" # dotfile stamped into upload booths (excluded from listings)
|
||||
|
||||
# Friendly, unambiguous words for human-readable pickup ids (4-wombat / star-84).
|
||||
PICKUP_WORDS = (
|
||||
"wombat otter panda koala tiger walrus gecko heron badger beaver falcon marmot "
|
||||
"lemur narwhal ocelot puffin quokka raccoon tapir urchin vulture weasel yak zebra "
|
||||
"alpaca bison cobra dingo egret ferret gibbon hare ibis jaguar llama moose newt "
|
||||
"osprey possum quail robin seal toad viper wren lynx mole swan crane finch sloth "
|
||||
"shrew stoat skunk heronry orca walnut sparrow "
|
||||
"star comet moon cloud river maple cedar birch fern moss reef dune mesa cove glade "
|
||||
"brook pine cedarwood kelp coral amber opal jade onyx slate flint ember spark frost "
|
||||
"storm tide wave ridge peak vale marsh delta atoll canyon fjord geyser lagoon prairie "
|
||||
"anchor beacon lantern kettle copper brass velvet cobalt indigo crimson violet olive "
|
||||
"hazel cocoa mango guava papaya plum kiwi lime pear quince radish turnip acorn clover "
|
||||
"thistle poppy aster dahlia iris lily sage thyme basil clove nutmeg ginger honey"
|
||||
).split()
|
||||
|
||||
|
||||
def safe_upload_name(name: str, fallback: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
|
||||
base = (name or "").replace("\\", "/").split("/")[-1].strip()
|
||||
base = base.lstrip(".") # a leading dot would hide the file from every listing
|
||||
return base[:200] or fallback
|
||||
|
||||
|
||||
def _dedupe_name(name: str, used: set) -> str:
|
||||
if name not in used:
|
||||
return name
|
||||
stem, dot, ext = name.partition(".")
|
||||
i = 1
|
||||
while f"{stem}-{i}{dot}{ext}" in used:
|
||||
i += 1
|
||||
return f"{stem}-{i}{dot}{ext}"
|
||||
|
||||
|
||||
def generate_pickup_id(exists) -> str:
|
||||
"""A human-readable id like '4-wombat' or 'star-84'. `exists(name)->bool` gates collisions."""
|
||||
for _ in range(400):
|
||||
word = secrets.choice(PICKUP_WORDS)
|
||||
num = secrets.randbelow(99) + 1
|
||||
name = f"{num}-{word}" if secrets.randbelow(2) else f"{word}-{num}"
|
||||
if not exists(name):
|
||||
return name
|
||||
# astronomically unlikely fallback: two words keep it human-readable
|
||||
while True:
|
||||
name = f"{secrets.choice(PICKUP_WORDS)}-{secrets.choice(PICKUP_WORDS)}-{secrets.randbelow(999) + 1}"
|
||||
if not exists(name):
|
||||
return name
|
||||
|
||||
|
||||
def create_app(
|
||||
data_dir,
|
||||
ttl_hours: float = 24.0,
|
||||
host_label: str = "",
|
||||
start_sweeper: bool = True,
|
||||
sweep_interval_s: int = 900,
|
||||
max_upload_mb: float = 1024.0,
|
||||
max_files: int = 50,
|
||||
) -> FastAPI:
|
||||
data_dir = Path(data_dir).expanduser().resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
ttl_seconds = ttl_hours * 3600.0
|
||||
max_upload_bytes = int(max_upload_mb * 1024 * 1024)
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
templates.env.filters["dur"] = human_dur
|
||||
@@ -287,6 +344,7 @@ def create_app(
|
||||
"name": name,
|
||||
"name_url": quote(name, safe=""),
|
||||
"items": build_gallery(booth),
|
||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
||||
},
|
||||
)
|
||||
@@ -302,6 +360,44 @@ def create_app(
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
return FileResponse(str(target))
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload(files: list[UploadFile] = File(...)):
|
||||
"""Browser/curl drop-off: files land in a new booth with a human-readable
|
||||
pickup id (e.g. 4-wombat), sweep-expiring in the usual TTL. Redirects (303)
|
||||
to the pickup page; curl clients read the Location header for the id."""
|
||||
files = [f for f in files if f and f.filename]
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="no files uploaded")
|
||||
if len(files) > max_files:
|
||||
raise HTTPException(status_code=413, detail=f"too many files (max {max_files})")
|
||||
|
||||
booth_id = generate_pickup_id(lambda n: (data_dir / n).exists())
|
||||
dest = data_dir / booth_id
|
||||
dest.mkdir(parents=True)
|
||||
(dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed)
|
||||
|
||||
total = 0
|
||||
used: set = {UPLOAD_MARKER}
|
||||
try:
|
||||
for i, f in enumerate(files):
|
||||
name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
|
||||
used.add(name)
|
||||
with (dest / name).open("wb") as out:
|
||||
while chunk := await f.read(1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > max_upload_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"upload too large (max {max_upload_mb:g} MB)",
|
||||
)
|
||||
out.write(chunk)
|
||||
await f.close()
|
||||
except Exception:
|
||||
shutil.rmtree(dest, ignore_errors=True) # never leave a half-written booth
|
||||
raise
|
||||
|
||||
return RedirectResponse(url=f"/b/{quote(booth_id, safe='')}/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/delete")
|
||||
def booth_delete_form(name: str):
|
||||
shutil.rmtree(resolve_booth(name))
|
||||
@@ -320,7 +416,16 @@ def _from_env() -> FastAPI:
|
||||
ttl = float(os.environ.get("BOOTH_TTL_HOURS", "24"))
|
||||
host = os.environ.get("BOOTH_HOST_LABEL", "")
|
||||
interval = int(float(os.environ.get("BOOTH_SWEEP_INTERVAL_MIN", "15")) * 60)
|
||||
return create_app(data, ttl_hours=ttl, host_label=host, sweep_interval_s=interval)
|
||||
max_mb = float(os.environ.get("BOOTH_MAX_UPLOAD_MB", "1024"))
|
||||
max_n = int(os.environ.get("BOOTH_MAX_FILES", "50"))
|
||||
return create_app(
|
||||
data,
|
||||
ttl_hours=ttl,
|
||||
host_label=host,
|
||||
sweep_interval_s=interval,
|
||||
max_upload_mb=max_mb,
|
||||
max_files=max_n,
|
||||
)
|
||||
|
||||
|
||||
app = _from_env()
|
||||
|
||||
Reference in New Issue
Block a user