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
|
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>
|
||||||
|
|||||||
@@ -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,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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user