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 c2d6b3290e
commit a8bfe592b4
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
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 `<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) ---------------------------------
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")