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:
@@ -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")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<a class="back" href="/">‹ all booths</a>
|
||||
<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>
|
||||
{% 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"
|
||||
onsubmit="return confirm('Wipe this booth now?')">
|
||||
<button>Wipe now</button>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</a>
|
||||
<div class="meta">
|
||||
<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>
|
||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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("<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):
|
||||
c, _ = client
|
||||
assert c.get("/b/nope/").status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user