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:
@@ -24,7 +24,7 @@
|
|||||||
href: http://10.100.10.50:8090/
|
href: http://10.100.10.50:8090/
|
||||||
icon: mdi-filmstrip
|
icon: mdi-filmstrip
|
||||||
siteMonitor: http://10.100.10.50:8090/healthz
|
siteMonitor: http://10.100.10.50:8090/healthz
|
||||||
description: Ephemeral media drop — CC-session A/B & smoke results (nh3-dev, 24h TTL)
|
description: Ephemeral media drop + upload-for-pickup (human-readable ids) — nh3-dev, 24h TTL
|
||||||
|
|
||||||
# The AI tab is fully Docker-auto-discovered. Each inference service carries
|
# The AI tab is fully Docker-auto-discovered. Each inference service carries
|
||||||
# a homepage.group=AI - <role> label on its compose file (AI - Inference,
|
# a homepage.group=AI - <role> label on its compose file (AI - Inference,
|
||||||
|
|||||||
+41
-12
@@ -1,10 +1,16 @@
|
|||||||
# The Booth
|
# The Booth
|
||||||
|
|
||||||
A dead-simple standing web server for surfacing **ephemeral media** to the
|
A dead-simple standing web server for shuttling **ephemeral files** between the
|
||||||
operator — A/B renders, smoke-test screenshots, audio/video samples. A Claude
|
operator and CC sessions — A/B renders, smoke-test screenshots, audio/video
|
||||||
Code session drops a folder of files somewhere on disk; the Booth renders it as
|
samples, or anything you want to hand off. It works both directions:
|
||||||
a browsable "booth" and **wipes it 24h after the last activity**. No database,
|
|
||||||
no upload API — the filesystem *is* the state.
|
- **Session → operator:** a session drops a folder of files on disk; the Booth
|
||||||
|
renders it as a browsable "booth".
|
||||||
|
- **Operator/anyone → pickup:** upload files through the browser (or `curl -F`)
|
||||||
|
and get a **human-readable pickup id** like `4-wombat` or `star-84`.
|
||||||
|
|
||||||
|
Either way it **wipes 24h after the last activity**. No database — the
|
||||||
|
filesystem *is* the state.
|
||||||
|
|
||||||
- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth*
|
- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth*
|
||||||
- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth)
|
- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth)
|
||||||
@@ -33,6 +39,27 @@ rsync -a ./out/ nh3-dev:booth-data/my-run/
|
|||||||
|
|
||||||
Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
|
Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
|
||||||
|
|
||||||
|
## Upload for pickup
|
||||||
|
|
||||||
|
The reverse direction — put files in through the web, pick them up by id:
|
||||||
|
|
||||||
|
- **Browser:** the index page has an *Upload files for pickup* panel
|
||||||
|
(drag-drop or click). Submit → you land on a booth with a **human-readable
|
||||||
|
id** (`4-wombat`, `star-84`) whose files each have a ⬇ download link.
|
||||||
|
- **curl (a remote session with no ssh to nh3-dev can use this too):**
|
||||||
|
```bash
|
||||||
|
curl -sS -i -F 'files=@out/a.png' -F 'files=@out/b.png' \
|
||||||
|
http://10.100.10.50:8090/upload | grep -i location
|
||||||
|
# Location: /b/star-84/ <- the pickup id
|
||||||
|
```
|
||||||
|
- **Pick up** at `http://10.100.10.50:8090/b/<id>/` (download links), or on
|
||||||
|
nh3-dev straight off disk at `~/booth-data/<id>/`.
|
||||||
|
|
||||||
|
Uploads are stamped as pickup booths (a `⬆ pickup` badge in the UI) and expire
|
||||||
|
on the same 24h TTL. Limits: `BOOTH_MAX_FILES` files (default 50) and
|
||||||
|
`BOOTH_MAX_UPLOAD_MB` total per submission (default 1024); filenames are reduced
|
||||||
|
to a safe basename (no path traversal).
|
||||||
|
|
||||||
## What a booth renders
|
## What a booth renders
|
||||||
|
|
||||||
- **Has its own `index.html`?** → served **verbatim** (its relative assets —
|
- **Has its own `index.html`?** → served **verbatim** (its relative assets —
|
||||||
@@ -57,6 +84,7 @@ Then hand the operator `http://10.100.10.50:8090/b/my-run/`.
|
|||||||
| `GET /` | Index — one card per booth (newest first), with expiry countdown |
|
| `GET /` | Index — one card per booth (newest first), with expiry countdown |
|
||||||
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
||||||
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
||||||
|
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
|
||||||
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
||||||
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
|
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
|
||||||
| `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target |
|
| `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target |
|
||||||
@@ -73,13 +101,14 @@ journalctl --user -u booth.service -f # sweeper logs "[booth] swept …
|
|||||||
```
|
```
|
||||||
|
|
||||||
Config is env in the unit (`booth.service`):
|
Config is env in the unit (`booth.service`):
|
||||||
`BOOTH_DATA_DIR`, `BOOTH_TTL_HOURS`, `BOOTH_HOST_LABEL`, `BOOTH_SWEEP_INTERVAL_MIN`.
|
`BOOTH_DATA_DIR`, `BOOTH_TTL_HOURS`, `BOOTH_HOST_LABEL`, `BOOTH_SWEEP_INTERVAL_MIN`,
|
||||||
|
`BOOTH_MAX_UPLOAD_MB` (default 1024), `BOOTH_MAX_FILES` (default 50).
|
||||||
|
|
||||||
### Install / update
|
### Install / update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd services/booth
|
cd services/booth
|
||||||
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 # runtime deps
|
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart # runtime deps
|
||||||
cp booth.service ~/.config/systemd/user/booth.service
|
cp booth.service ~/.config/systemd/user/booth.service
|
||||||
systemctl --user daemon-reload && systemctl --user enable --now booth.service
|
systemctl --user daemon-reload && systemctl --user enable --now booth.service
|
||||||
```
|
```
|
||||||
@@ -97,8 +126,8 @@ cd services/booth && uv pip install pytest httpx && .venv/bin/python -m pytest -
|
|||||||
## Notes / non-goals
|
## Notes / non-goals
|
||||||
|
|
||||||
- **No auth.** LAN/WG-internal only, ephemeral content — don't drop secrets in a
|
- **No auth.** LAN/WG-internal only, ephemeral content — don't drop secrets in a
|
||||||
booth. (The data dir is world-readable-on-LAN via the server.)
|
booth, and note anyone on the LAN can upload (bounded by the size/file limits).
|
||||||
- **No upload API** by design. Sessions have filesystem access to nh3-dev; a
|
Uploaded files are served back with their own content-type, so an uploaded
|
||||||
folder drop is simpler and more debuggable than an HTTP upload path.
|
`index.html` renders as a page (a feature for custom reports; keep it in mind).
|
||||||
- Booth names with `/`, `..`, or a leading `.` are rejected; file serving is
|
- Booth names with `/`, `..`, or a leading `.` are rejected; file serving and
|
||||||
guarded against path traversal and symlink escape.
|
uploaded filenames are guarded against path traversal and symlink escape.
|
||||||
|
|||||||
+107
-2
@@ -18,13 +18,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
||||||
from fastapi.responses import (
|
from fastapi.responses import (
|
||||||
FileResponse,
|
FileResponse,
|
||||||
HTMLResponse,
|
HTMLResponse,
|
||||||
@@ -141,6 +142,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
|||||||
"kinds": kinds,
|
"kinds": kinds,
|
||||||
"thumb_url": thumb_url,
|
"thumb_url": thumb_url,
|
||||||
"has_index": (child / "index.html").is_file(),
|
"has_index": (child / "index.html").is_file(),
|
||||||
|
"uploaded": (child / UPLOAD_MARKER).exists(),
|
||||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||||
"mtime": mtime,
|
"mtime": mtime,
|
||||||
}
|
}
|
||||||
@@ -204,16 +206,71 @@ def build_gallery(child: Path) -> list[dict]:
|
|||||||
return items
|
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(
|
def create_app(
|
||||||
data_dir,
|
data_dir,
|
||||||
ttl_hours: float = 24.0,
|
ttl_hours: float = 24.0,
|
||||||
host_label: str = "",
|
host_label: str = "",
|
||||||
start_sweeper: bool = True,
|
start_sweeper: bool = True,
|
||||||
sweep_interval_s: int = 900,
|
sweep_interval_s: int = 900,
|
||||||
|
max_upload_mb: float = 1024.0,
|
||||||
|
max_files: int = 50,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
data_dir = Path(data_dir).expanduser().resolve()
|
data_dir = Path(data_dir).expanduser().resolve()
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
ttl_seconds = ttl_hours * 3600.0
|
ttl_seconds = ttl_hours * 3600.0
|
||||||
|
max_upload_bytes = int(max_upload_mb * 1024 * 1024)
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
templates.env.filters["dur"] = human_dur
|
templates.env.filters["dur"] = human_dur
|
||||||
@@ -287,6 +344,7 @@ def create_app(
|
|||||||
"name": name,
|
"name": name,
|
||||||
"name_url": quote(name, safe=""),
|
"name_url": quote(name, safe=""),
|
||||||
"items": build_gallery(booth),
|
"items": build_gallery(booth),
|
||||||
|
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
"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")
|
raise HTTPException(status_code=404, detail="no such file")
|
||||||
return FileResponse(str(target))
|
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")
|
@app.post("/b/{name}/delete")
|
||||||
def booth_delete_form(name: str):
|
def booth_delete_form(name: str):
|
||||||
shutil.rmtree(resolve_booth(name))
|
shutil.rmtree(resolve_booth(name))
|
||||||
@@ -320,7 +416,16 @@ def _from_env() -> FastAPI:
|
|||||||
ttl = float(os.environ.get("BOOTH_TTL_HOURS", "24"))
|
ttl = float(os.environ.get("BOOTH_TTL_HOURS", "24"))
|
||||||
host = os.environ.get("BOOTH_HOST_LABEL", "")
|
host = os.environ.get("BOOTH_HOST_LABEL", "")
|
||||||
interval = int(float(os.environ.get("BOOTH_SWEEP_INTERVAL_MIN", "15")) * 60)
|
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()
|
app = _from_env()
|
||||||
|
|||||||
@@ -83,6 +83,38 @@
|
|||||||
|
|
||||||
main{flex:1;width:100%;max-width:1240px;margin:0 auto;padding:1.7rem 1.5rem 3rem}
|
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);
|
.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}
|
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">
|
<div class="boothhead">
|
||||||
<a class="back" href="/">‹ all booths</a>
|
<a class="back" href="/">‹ all booths</a>
|
||||||
<h1>{{ name }}</h1>
|
<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"
|
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
|
||||||
onsubmit="return confirm('Wipe this booth now?')">
|
onsubmit="return confirm('Wipe this booth now?')">
|
||||||
<button>Wipe now</button>
|
<button>Wipe now</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% if not items %}
|
||||||
<div class="empty">This booth is empty.</div>
|
<div class="empty">This booth is empty.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -24,9 +30,16 @@
|
|||||||
{% elif it.kind == 'audio' %}
|
{% elif it.kind == 'audio' %}
|
||||||
<audio controls preload="metadata" src="{{ it.url }}"></audio>
|
<audio controls preload="metadata" src="{{ it.url }}"></audio>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
<figcaption>{{ it.caption or it.name }}</figcaption>
|
|
||||||
</figure>
|
</figure>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% 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 %}
|
{% if not booths %}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
No booths yet.<br>
|
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
|
||||||
Drop a folder of media into <code>{{ data_dir }}</code> and it lands here.
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -21,6 +30,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="ph">◆ files</div>
|
<div class="ph">◆ files</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||||
@@ -34,4 +44,35 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
name = "booth"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
"uvicorn[standard]>=0.34",
|
"uvicorn[standard]>=0.34",
|
||||||
"jinja2>=3.1",
|
"jinja2>=3.1",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -8,11 +9,15 @@ from booth.app import (
|
|||||||
build_gallery,
|
build_gallery,
|
||||||
classify,
|
classify,
|
||||||
create_app,
|
create_app,
|
||||||
|
generate_pickup_id,
|
||||||
human_dur,
|
human_dur,
|
||||||
is_expired,
|
is_expired,
|
||||||
|
safe_upload_name,
|
||||||
sweep_once,
|
sweep_once,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
||||||
|
|
||||||
|
|
||||||
# ---- pure helpers -----------------------------------------------------------
|
# ---- pure helpers -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -182,3 +187,102 @@ def test_healthz(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["ok"] is True and body["booths"] == 1
|
assert body["ok"] is True and body["booths"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---- uploads / pickup -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pickup_id_format():
|
||||||
|
for _ in range(100):
|
||||||
|
pid = generate_pickup_id(lambda n: False)
|
||||||
|
assert PICKUP_RE.match(pid), pid
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_pickup_id_avoids_collision():
|
||||||
|
taken = {"4-wombat", "star-84"}
|
||||||
|
for _ in range(50):
|
||||||
|
pid = generate_pickup_id(lambda n: n in taken)
|
||||||
|
assert pid not in taken
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_upload_name():
|
||||||
|
assert safe_upload_name("../../etc/passwd", "fb") == "passwd"
|
||||||
|
assert safe_upload_name("C:\\Users\\x\\shot.png", "fb") == "shot.png"
|
||||||
|
assert safe_upload_name("", "fb") == "fb"
|
||||||
|
assert safe_upload_name(" ", "fb") == "fb"
|
||||||
|
assert safe_upload_name(".hidden", "fb") == "hidden"
|
||||||
|
assert safe_upload_name("...", "fb") == "fb"
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(client, files):
|
||||||
|
return client.post("/upload", files=files, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_creates_pickup_booth(client):
|
||||||
|
c, data = client
|
||||||
|
r = _upload(c, [
|
||||||
|
("files", ("a.png", b"\x89PNG\r\n\x1a\n" + b"0" * 20, "image/png")),
|
||||||
|
("files", ("notes.txt", b"pick me up", "text/plain")),
|
||||||
|
])
|
||||||
|
assert r.status_code == 303
|
||||||
|
loc = r.headers["location"]
|
||||||
|
pid = loc.split("/b/")[1].rstrip("/")
|
||||||
|
assert PICKUP_RE.match(pid), pid
|
||||||
|
booth = data / pid
|
||||||
|
assert (booth / "a.png").is_file()
|
||||||
|
assert (booth / "notes.txt").read_bytes() == b"pick me up"
|
||||||
|
assert (booth / ".uploaded").is_file() # marker present
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_booth_renders_pickup_ui(client):
|
||||||
|
c, data = client
|
||||||
|
r = _upload(c, [("files", ("shot.png", b"\x89PNG" + b"0" * 30, "image/png"))])
|
||||||
|
pid = r.headers["location"].split("/b/")[1].rstrip("/")
|
||||||
|
page = c.get(f"/b/{pid}/")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "pickup" in page.text.lower() # badge / note
|
||||||
|
assert 'download' in page.text # per-item download link
|
||||||
|
# and it shows up flagged as an upload on the index
|
||||||
|
assert pid in c.get("/").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_sanitizes_traversal(client):
|
||||||
|
c, data = client
|
||||||
|
r = _upload(c, [("files", ("../../../etc/passwd", b"x", "text/plain"))])
|
||||||
|
pid = r.headers["location"].split("/b/")[1].rstrip("/")
|
||||||
|
booth = data / pid
|
||||||
|
assert (booth / "passwd").is_file() # basename only
|
||||||
|
assert not (data.parent / "passwd").exists() # nothing escaped upward
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_too_many_files(tmp_path):
|
||||||
|
app = create_app(tmp_path, start_sweeper=False, max_files=2)
|
||||||
|
c = TestClient(app)
|
||||||
|
files = [("files", (f"f{i}.txt", b"x", "text/plain")) for i in range(3)]
|
||||||
|
r = c.post("/upload", files=files, follow_redirects=False)
|
||||||
|
assert r.status_code == 413
|
||||||
|
# no partial booth left behind
|
||||||
|
assert list(tmp_path.iterdir()) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_too_large(tmp_path):
|
||||||
|
app = create_app(tmp_path, start_sweeper=False, max_upload_mb=0.0001) # ~104 bytes
|
||||||
|
c = TestClient(app)
|
||||||
|
r = c.post(
|
||||||
|
"/upload",
|
||||||
|
files=[("files", ("big.bin", b"0" * 500, "application/octet-stream"))],
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 413
|
||||||
|
assert list(tmp_path.iterdir()) == [] # partial write cleaned up
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_dedupes_repeated_names(client):
|
||||||
|
c, data = client
|
||||||
|
r = _upload(c, [
|
||||||
|
("files", ("shot.png", b"a" * 10, "image/png")),
|
||||||
|
("files", ("shot.png", b"b" * 10, "image/png")),
|
||||||
|
])
|
||||||
|
pid = r.headers["location"].split("/b/")[1].rstrip("/")
|
||||||
|
names = sorted(p.name for p in (data / pid).iterdir() if not p.name.startswith("."))
|
||||||
|
assert names == ["shot-1.png", "shot.png"]
|
||||||
|
|||||||
Reference in New Issue
Block a user