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:
2026-07-20 14:44:21 -07:00
parent eaece794d7
commit 775e9804cd
8 changed files with 347 additions and 22 deletions
+1 -1
View File
@@ -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
View File
@@ -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
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 %}
+3 -2
View File
@@ -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]
+104
View File
@@ -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"]