feat(booth): downloadable booths — whole-booth zip + ?dl force-download (v0.1.4)

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/<name>/?download=1 -> streams the whole booth as <name>.zip (attachment)
  - GET /b/<name>/<file>?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.
This commit is contained in:
vh
2026-07-25 17:49:11 -07:00
parent df1d87935d
commit 91a031fdb3
5 changed files with 73 additions and 4 deletions
+39 -2
View File
@@ -17,10 +17,12 @@ State is the filesystem — `ls ~/booth-data` tells you everything. That is the
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import io
import os import os
import secrets import secrets
import shutil import shutil
import time import time
import zipfile
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
@@ -31,6 +33,7 @@ from fastapi.responses import (
HTMLResponse, HTMLResponse,
JSONResponse, JSONResponse,
RedirectResponse, RedirectResponse,
Response,
) )
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -206,6 +209,27 @@ def build_gallery(child: Path) -> list[dict]:
return items 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 `<booth>.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) --------------------------------- # ---- uploads (browser drop-off for pickup) ---------------------------------
UPLOAD_MARKER = ".uploaded" # dotfile stamped into upload booths (excluded from listings) 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) return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
@app.get("/b/{name}/", response_class=HTMLResponse) @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) 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" own_index = booth / "index.html"
if own_index.is_file(): if own_index.is_file():
return FileResponse(str(own_index), media_type="text/html") return FileResponse(str(own_index), media_type="text/html")
@@ -375,7 +407,7 @@ def create_app(
) )
@app.get("/b/{name}/{filepath:path}") @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) booth = resolve_booth(name)
try: try:
target = (booth / filepath).resolve() target = (booth / filepath).resolve()
@@ -383,6 +415,11 @@ def create_app(
raise HTTPException(status_code=404, detail="no such file") raise HTTPException(status_code=404, detail="no such file")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file(): if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such 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)) return FileResponse(str(target))
@app.post("/upload") @app.post("/upload")
@@ -5,6 +5,7 @@
<a class="back" href="/">‹ all booths</a> <a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
<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> <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>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
<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>
+1 -1
View File
@@ -34,7 +34,7 @@
</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>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }}</div> <div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
</div> </div>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete" <form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')"> onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "booth" 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." 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 = [
+31
View File
@@ -150,6 +150,37 @@ def test_booth_serves_file(client):
assert r.content == b"\x00\x01payload" 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("<h1>BRIEF</h1>")
(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("<h1>BRIEF</h1>")
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): def test_missing_booth_404(client):
c, _ = client c, _ = client
assert c.get("/b/nope/").status_code == 404 assert c.get("/b/nope/").status_code == 404