From 91a031fdb35dfb2a2ff3519198e1a51e84e26432 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sat, 25 Jul 2026 17:49:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(booth):=20downloadable=20booths=20?= =?UTF-8?q?=E2=80=94=20whole-booth=20zip=20+=20=3Fdl=20force-download=20(v?= =?UTF-8?q?0.1.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verbatim index.html booth (e.g. edict-design-brief: a rendered brief + its .md) had no download affordance — the page is served raw with no gallery/per-file chrome. Adds: - GET /b//?download=1 -> streams the whole booth as .zip (attachment) - GET /b//?dl=1 -> forces Content-Disposition: attachment (html/md/ text otherwise render inline with no easy save) - a download link on the index card (the accessible spot for verbatim booths) and the gallery header Tests for both; verified live against edict-design-brief on nh3-dev :8090. --- services/booth/booth/app.py | 41 +++++++++++++++++++++-- services/booth/booth/templates/booth.html | 1 + services/booth/booth/templates/index.html | 2 +- services/booth/pyproject.toml | 2 +- services/booth/tests/test_booth.py | 31 +++++++++++++++++ 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index 066f709..d807d71 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -17,10 +17,12 @@ State is the filesystem — `ls ~/booth-data` tells you everything. That is the from __future__ import annotations import asyncio +import io import os import secrets import shutil import time +import zipfile from contextlib import asynccontextmanager from pathlib import Path from urllib.parse import quote @@ -31,6 +33,7 @@ from fastapi.responses import ( HTMLResponse, JSONResponse, RedirectResponse, + Response, ) from fastapi.templating import Jinja2Templates @@ -206,6 +209,27 @@ def build_gallery(child: Path) -> list[dict]: return items +def zip_booth(booth: Path) -> bytes: + """Zip a booth's whole tree (dotfiles excluded) into an in-memory archive. + + Lets a booth be downloaded as one artifact regardless of shape — the case a + verbatim `index.html` booth (e.g. a rendered brief + its assets) has no + per-file download affordance for, since the page is served raw. + """ + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for p in sorted(booth.rglob("*")): + if p.is_file() and not p.name.startswith("."): + zf.write(p, p.relative_to(booth).as_posix()) + return buf.getvalue() + + +def _zip_filename(name: str) -> str: + """A Content-Disposition-safe `.zip` (strip quotes/control chars).""" + safe = "".join(c for c in name if c.isprintable() and c != '"') + return f"{safe or 'booth'}.zip" + + # ---- uploads (browser drop-off for pickup) --------------------------------- UPLOAD_MARKER = ".uploaded" # dotfile stamped into upload booths (excluded from listings) @@ -331,8 +355,16 @@ def create_app( return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307) @app.get("/b/{name}/", response_class=HTMLResponse) - def booth_view(request: Request, name: str): + def booth_view(request: Request, name: str, download: int = 0): booth = resolve_booth(name) + if download: + # whole-booth zip — the download path for a verbatim index.html booth + # (which has no gallery/per-file chrome), and a "download all" for any. + return Response( + content=zip_booth(booth), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{_zip_filename(name)}"'}, + ) own_index = booth / "index.html" if own_index.is_file(): return FileResponse(str(own_index), media_type="text/html") @@ -375,7 +407,7 @@ def create_app( ) @app.get("/b/{name}/{filepath:path}") - def booth_file(name: str, filepath: str): + def booth_file(name: str, filepath: str, dl: int = 0): booth = resolve_booth(name) try: target = (booth / filepath).resolve() @@ -383,6 +415,11 @@ def create_app( raise HTTPException(status_code=404, detail="no such file") if not str(target).startswith(str(booth) + os.sep) or not target.is_file(): raise HTTPException(status_code=404, detail="no such file") + # ?dl=1 forces a download (Content-Disposition: attachment) instead of the + # browser rendering inline — the fix for html/md/text that otherwise opens + # in-page with no easy "save". + if dl: + return FileResponse(str(target), filename=target.name) return FileResponse(str(target)) @app.post("/upload") diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html index e409202..b2ee0f5 100644 --- a/services/booth/booth/templates/booth.html +++ b/services/booth/booth/templates/booth.html @@ -5,6 +5,7 @@ ‹ all booths

{{ name }}

{% if uploaded %}⬆ pickup {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }} + {% if items %}⬇ zip{% endif %}
diff --git a/services/booth/booth/templates/index.html b/services/booth/booth/templates/index.html index 754707e..2c77c4c 100644 --- a/services/booth/booth/templates/index.html +++ b/services/booth/booth/templates/index.html @@ -34,7 +34,7 @@
{{ b.name }} -
{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }}
+
{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · ⬇ zip
diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index b7791a4..f5f0b1e 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "booth" -version = "0.1.3" +version = "0.1.4" 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 = [ diff --git a/services/booth/tests/test_booth.py b/services/booth/tests/test_booth.py index 0aa9e6a..3319f37 100644 --- a/services/booth/tests/test_booth.py +++ b/services/booth/tests/test_booth.py @@ -150,6 +150,37 @@ def test_booth_serves_file(client): assert r.content == b"\x00\x01payload" +def test_booth_zip_download(client): + # a verbatim index.html booth (no per-file chrome) is still downloadable as a zip + c, data = client + d = data / "brief" + d.mkdir() + (d / "index.html").write_text("

BRIEF

") + (d / "notes.md").write_text("# notes") + r = c.get("/b/brief/?download=1") + assert r.status_code == 200 + assert r.headers["content-type"] == "application/zip" + assert "attachment" in r.headers["content-disposition"] + assert "brief.zip" in r.headers["content-disposition"] + import io as _io + import zipfile as _zip + assert set(_zip.ZipFile(_io.BytesIO(r.content)).namelist()) == {"index.html", "notes.md"} + + +def test_booth_file_force_download(client): + # ?dl=1 forces attachment so html/md/text saves instead of rendering inline + c, data = client + d = data / "brief" + d.mkdir() + (d / "index.html").write_text("

BRIEF

") + r = c.get("/b/brief/index.html") + assert "attachment" not in r.headers.get("content-disposition", "") + r2 = c.get("/b/brief/index.html?dl=1") + assert r2.status_code == 200 + assert "attachment" in r2.headers["content-disposition"] + assert "index.html" in r2.headers["content-disposition"] + + def test_missing_booth_404(client): c, _ = client assert c.get("/b/nope/").status_code == 404