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:
vh
2026-07-20 14:44:21 -07:00
parent ae968adf44
commit f328848bd7
7 changed files with 346 additions and 21 deletions
+107 -2
View File
@@ -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()
+32
View File
@@ -83,6 +83,38 @@
main{flex:1;width:100%;max-width:1240px;margin:0 auto;padding:1.7rem 1.5rem 3rem}
/* ---- upload / pickup ---- */
.uploader{display:flex;gap:.9rem;align-items:stretch;margin-bottom:1.7rem;flex-wrap:wrap}
.drop{flex:1 1 300px;position:relative;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:.15rem;text-align:center;cursor:pointer;padding:1.1rem 1rem;
border:1.5px dashed var(--border-default);border-radius:var(--radius-lg);background:var(--rk-well);
transition:border-color .16s var(--ease-out),background .16s var(--ease-out),box-shadow .16s var(--ease-out)}
.drop:hover{border-color:var(--aus-cyan)}
.drop.over{border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.06);box-shadow:var(--glow-cyan)}
.drop.has{border-style:solid;border-color:var(--aus-cyan)}
.drop input[type=file]{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
.drop-icon{font-size:1.2rem;color:var(--aus-bright-cyan)}
.drop-main{font-family:var(--font-display);font-weight:600;color:var(--fg-0);font-size:.98rem}
.drop-sub{font-family:var(--font-mono);font-size:.7rem;letter-spacing:.03em;color:var(--fg-3)}
.up-go{flex:0 0 auto;align-self:stretch;cursor:pointer;font-family:var(--font-mono);font-size:.74rem;
letter-spacing:.1em;text-transform:uppercase;padding:0 1.15rem;border-radius:var(--radius-lg);
background:transparent;border:1px solid var(--aus-cyan);color:var(--aus-bright-cyan);
transition:.14s var(--ease-out)}
.up-go:hover{background:var(--aus-bright-cyan);color:var(--fg-on-accent);border-color:var(--aus-bright-cyan)}
.badge{display:inline-block;font-family:var(--font-mono);font-size:.6rem;letter-spacing:.1em;
text-transform:uppercase;font-weight:600;color:var(--fg-on-accent);background:var(--aus-bright-cyan);
padding:.08rem .42rem;border-radius:var(--radius-pill);vertical-align:middle}
.card .thumb{position:relative}
.thumb .badge{position:absolute;top:.5rem;left:.5rem;box-shadow:var(--shadow-2)}
.pickup-note{margin:-.5rem 0 1.5rem;padding:.6rem .85rem;border:1px solid var(--border-subtle);
border-left:3px solid var(--aus-bright-cyan);border-radius:var(--radius-md);background:var(--rk-well);
font-family:var(--font-mono);font-size:.78rem;color:var(--fg-2)}
.dl-link{color:var(--aus-bright-cyan);text-decoration:none;margin-right:.4rem;font-size:.95em}
.dl-link:hover{color:var(--aus-cyan)}
.cap-text{color:var(--fg-2)}
.foot{border-top:1px solid var(--border-subtle);color:var(--fg-muted);
font-size:.72rem;font-family:var(--font-mono);letter-spacing:.04em;padding:1rem 1.5rem;text-align:center}
+16 -3
View File
@@ -4,13 +4,19 @@
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1>
<span class="sub">{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}</span>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}</span>
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')">
<button>Wipe now</button>
</form>
</div>
{% if uploaded %}
<div class="pickup-note">
📦 Pickup <code>{{ name }}</code> — download files below, or on nh3-dev grab <code>~/booth-data/{{ name }}/</code>
</div>
{% endif %}
{% if not items %}
<div class="empty">This booth is empty.</div>
{% else %}
@@ -24,9 +30,16 @@
{% elif it.kind == 'audio' %}
<audio controls preload="metadata" src="{{ it.url }}"></audio>
{% else %}
<a class="dl" href="{{ it.url }}">⬇ {{ it.name }}</a>
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
{% endif %}
{% if it.kind == 'other' %}
{% if it.caption %}<figcaption><span class="cap-text">{{ it.caption }}</span></figcaption>{% endif %}
{% else %}
<figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span>
</figcaption>
{% endif %}
<figcaption>{{ it.caption or it.name }}</figcaption>
</figure>
{% endfor %}
</div>
+43 -2
View File
@@ -1,9 +1,18 @@
{% extends "base.html" %}
{% block content %}
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files">
<span class="drop-icon">⬆</span>
<span class="drop-main">Upload files for pickup</span>
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
<input id="booth-files" name="files" type="file" multiple>
</label>
<button class="up-go" type="submit">Get pickup id →</button>
</form>
{% if not booths %}
<div class="empty">
No booths yet.<br>
Drop a folder of media into <code>{{ data_dir }}</code> and it lands here.
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
</div>
{% else %}
<div class="grid">
@@ -21,6 +30,7 @@
{% else %}
<div class="ph">◆ files</div>
{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
@@ -34,4 +44,35 @@
{% endfor %}
</div>
{% endif %}
<script>
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
With JS off, the native file input + submit still works. */
(function () {
var input = document.getElementById('booth-files');
var sub = document.getElementById('drop-sub');
var drop = document.querySelector('.drop');
if (!input) return;
function show() {
var n = input.files ? input.files.length : 0;
if (n) {
sub.textContent = n + ' file' + (n > 1 ? 's' : '') + ' ready — hit “Get pickup id”';
drop.classList.add('has');
}
}
input.addEventListener('change', show);
['dragover', 'dragenter'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.add('over'); });
});
['dragleave', 'drop'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (ev) {
if (ev.dataTransfer && ev.dataTransfer.files.length) {
try { input.files = ev.dataTransfer.files; } catch (_) {}
show();
}
});
})();
</script>
{% endblock %}