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/
|
||||
icon: mdi-filmstrip
|
||||
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
|
||||
# a homepage.group=AI - <role> label on its compose file (AI - Inference,
|
||||
|
||||
+41
-12
@@ -1,10 +1,16 @@
|
||||
# The Booth
|
||||
|
||||
A dead-simple standing web server for surfacing **ephemeral media** to the
|
||||
operator — A/B renders, smoke-test screenshots, audio/video samples. A Claude
|
||||
Code session drops a folder of files somewhere on disk; the Booth renders it as
|
||||
a browsable "booth" and **wipes it 24h after the last activity**. No database,
|
||||
no upload API — the filesystem *is* the state.
|
||||
A dead-simple standing web server for shuttling **ephemeral files** between the
|
||||
operator and CC sessions — A/B renders, smoke-test screenshots, audio/video
|
||||
samples, or anything you want to hand off. It works both directions:
|
||||
|
||||
- **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*
|
||||
- **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/`.
|
||||
|
||||
## 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
|
||||
|
||||
- **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 /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
||||
| `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) |
|
||||
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
|
||||
| `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`):
|
||||
`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
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
|
||||
- **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.)
|
||||
- **No upload API** by design. Sessions have filesystem access to nh3-dev; a
|
||||
folder drop is simpler and more debuggable than an HTTP upload path.
|
||||
- Booth names with `/`, `..`, or a leading `.` are rejected; file serving is
|
||||
guarded against path traversal and symlink escape.
|
||||
booth, and note anyone on the LAN can upload (bounded by the size/file limits).
|
||||
Uploaded files are served back with their own content-type, so an uploaded
|
||||
`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 and
|
||||
uploaded filenames are guarded against path traversal and symlink escape.
|
||||
|
||||
+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 %}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.1.0"
|
||||
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."
|
||||
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). 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"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.34",
|
||||
"jinja2>=3.1",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -8,11 +9,15 @@ from booth.app import (
|
||||
build_gallery,
|
||||
classify,
|
||||
create_app,
|
||||
generate_pickup_id,
|
||||
human_dur,
|
||||
is_expired,
|
||||
safe_upload_name,
|
||||
sweep_once,
|
||||
)
|
||||
|
||||
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
||||
|
||||
|
||||
# ---- pure helpers -----------------------------------------------------------
|
||||
|
||||
@@ -182,3 +187,102 @@ def test_healthz(client):
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
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