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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user