diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index d807d71..b7af84f 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio import io import os +import re import secrets import shutil import time @@ -230,6 +231,96 @@ def _zip_filename(name: str) -> str: return f"{safe or 'booth'}.zip" +# ---- verbatim-index.html wrapper ------------------------------------------- + +# Mirror of base.html's favicon (the app templates set it there; this is the copy +# injected into a booth's *verbatim* index.html so a raw page inherits the same +# icon). Keep the two in sync if the Booth's icon ever changes. +FAVICON_HREF = ( + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'" + "%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' " + "cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle " + "cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E" +) +FAVICON_LINK = f'' + +# A self-contained floating "back to all booths" chip injected into verbatim +# booths. Scoped class + fixed positioning + max z-index so it overlays the raw +# page without touching its layout; hidden in print so downloaded reports stay clean. +_BACK_CHIP = ( + '‹ all booths' + # top-right: empty on left-aligned report layouts (a top-left chip clips the + # page title), and consistent with the zoom view's top-right back affordance. + "" +) + +WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped) + +_ICON_RE = re.compile(r"]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE) +_HEAD_CLOSE_RE = re.compile(r"", re.IGNORECASE) +_HTML_OPEN_RE = re.compile(r"]*>", re.IGNORECASE) +_DOCTYPE_RE = re.compile(r"]*>", re.IGNORECASE) +_BODY_CLOSE_RE = re.compile(r"", re.IGNORECASE) +_HTML_CLOSE_RE = re.compile(r"", re.IGNORECASE) + + +def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]: + m = pattern.search(html) + if m: + return html[: m.start()] + snippet + html[m.start() :], True + return html, False + + +def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]: + m = pattern.search(html) + if m: + return html[: m.end()] + snippet + html[m.end() :], True + return html, False + + +def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK) -> str: + """Inject a floating 'all booths' back-chip — and the Booth favicon, if the page + declares none — into a booth's verbatim index.html, without altering the page's + rendered content. + + Robust to the compact HTML real booths use (` + <style>…content`, no explicit head/body). The two hard constraints: + * NEVER put anything ahead of a leading <!doctype> — that forces quirks mode. + * Keep the charset <meta> within the first 1024 bytes so it's still honoured. + So the favicon lands at the first head-ish seam (before </head>, else after + <html>, else right after the doctype — a ~250B link keeps charset in range), and + the fixed-position chip is appended at the END of the document (before </body> / + </html> or appended), which renders top-left regardless and disturbs nothing. + """ + if favicon_link and not _ICON_RE.search(html): + for inserter, pat in ( + (_insert_before, _HEAD_CLOSE_RE), # inside an explicit <head> + (_insert_after, _HTML_OPEN_RE), # top of an explicit <html> + (_insert_after, _DOCTYPE_RE), # right after the doctype (compact HTML) + ): + html, done = inserter(html, pat, favicon_link) + if done: + break + else: + html = favicon_link + html # bare fragment, no doctype: safe to prepend + + for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE): + html, done = _insert_before(html, pat, _BACK_CHIP) + if done: + break + else: + html = html + _BACK_CHIP # no </body>/</html>: append to the end + return html + + # ---- uploads (browser drop-off for pickup) --------------------------------- UPLOAD_MARKER = ".uploaded" # dotfile stamped into upload booths (excluded from listings) @@ -367,6 +458,17 @@ def create_app( ) own_index = booth / "index.html" if own_index.is_file(): + # Serve the operator's verbatim report, but inject a floating + # back-to-booths chip + the Booth favicon (if it declares none) so a + # raw page still has a way home. Small HTML -> read + wrap in memory; + # a pathological large file falls back to serving raw, unwrapped. + try: + if own_index.stat().st_size <= WRAP_MAX_BYTES: + return HTMLResponse( + wrap_verbatim_html(own_index.read_text(encoding="utf-8", errors="replace")) + ) + except OSError: + pass return FileResponse(str(own_index), media_type="text/html") return templates.TemplateResponse( request, diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index f5f0b1e..41c5d0d 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "booth" -version = "0.1.4" +version = "0.1.5" 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 3319f37..e25fe3c 100644 --- a/services/booth/tests/test_booth.py +++ b/services/booth/tests/test_booth.py @@ -6,6 +6,7 @@ import pytest from fastapi.testclient import TestClient from booth.app import ( + FAVICON_LINK, build_gallery, classify, create_app, @@ -14,6 +15,7 @@ from booth.app import ( is_expired, safe_upload_name, sweep_once, + wrap_verbatim_html, ) PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$") @@ -357,3 +359,79 @@ def test_image_view_nonimage_redirects_to_raw(client): r = c.get("/b/run1/view", params={"f": "notes.txt"}, follow_redirects=False) assert r.status_code == 307 assert r.headers["location"] == "/b/run1/notes.txt" + + +# ---- verbatim-index.html wrapper -------------------------------------------- + + +def test_wrap_injects_chip_and_favicon(): + html = "<html><head><title>Brief

REPORT

" + out = wrap_verbatim_html(html) + assert 'class="booth-nav-home"' in out # floating back chip + assert 'href="/"' in out # points at the main booth index + assert "all booths" in out + assert FAVICON_LINK in out # favicon inherited + assert "

REPORT

" in out # original content preserved + # favicon lands in the head, chip lands in the body + assert out.index(FAVICON_LINK) < out.index("") + assert out.index("booth-nav-home") > out.index("") + + +def test_wrap_respects_existing_favicon(): + html = 'x' + out = wrap_verbatim_html(html) + assert FAVICON_LINK not in out # the page's own icon wins + assert out.count('rel="icon"') == 1 + assert 'class="booth-nav-home"' in out # chip is still added + + +def test_wrap_bare_fragment_appends_chip(): + out = wrap_verbatim_html("

bare fragment

") # no doctype/head/body + assert 'class="booth-nav-home"' in out + assert out.rstrip().endswith("") # chip appended at the end + assert FAVICON_LINK in out # no doctype -> safe to prepend the icon + assert out.index(FAVICON_LINK) < out.index("bare") # icon ahead of content (implied head) + + +def test_wrap_no_head_injects_favicon(): + out = wrap_verbatim_html("

no head

") + assert 'class="booth-nav-home"' in out + assert FAVICON_LINK in out # injected even without an explicit + + +def test_wrap_compact_doctype_stays_first(): + # the real-booth shape: compact HTML, no explicit head/body. The injection must + # not push anything ahead of the doctype (quirks mode) or past the charset window. + html = "T

REPORT

" + out = wrap_verbatim_html(html) + assert out.lstrip().lower().startswith(" standards mode + assert FAVICON_LINK in out + assert out.index(FAVICON_LINK) < out.index("

") # icon in the implied head, before content + assert out.index("charset") < 1024 # charset meta stays in the detection window + assert 'class="booth-nav-home"' in out + assert out.index("booth-nav-home") > out.index("

REPORT

") # chip appended after content + + +def test_verbatim_booth_wrapped_with_back_chip(client): + c, data = client + d = data / "brief" + d.mkdir() + (d / "index.html").write_text("

BRIEF

") + r = c.get("/b/brief/") + assert r.status_code == 200 + assert "BRIEF" in r.text # content preserved + assert 'class="booth-nav-home"' in r.text # back chip injected + assert 'href="/"' in r.text + assert 'rel="icon"' in r.text # favicon inherited + + +def test_verbatim_index_raw_file_route_unwrapped(client): + # the file route (/b//index.html) still serves the raw bytes — the chip + # only rides on the booth view (/b//), so downloads/assets stay verbatim + c, data = client + d = data / "brief" + d.mkdir() + (d / "index.html").write_text("

BRIEF

") + r = c.get("/b/brief/index.html") + assert r.status_code == 200 + assert "booth-nav-home" not in r.text