feat(booth): wrap verbatim index.html booths with a back-to-booths chip + inherited favicon
Verbatim-index.html booths were served raw (FileResponse) with no base
template, so they had no favicon and no way back to the booth index — the
gap the app-rendered gallery/zoom pages already covered via base.html.
booth_view now reads a small verbatim index.html and, via a pure
wrap_verbatim_html(), injects:
- a fixed-position 'all booths' chip (scoped class, max z-index, hidden
in print), pinned top-right (empty on left-aligned report layouts; a
top-left chip clips the page title) and appended at the END of the
document so it never reorders the page;
- the Booth favicon at the first head-ish seam, only if the page declares
no icon of its own.
Injection is doctype/charset-safe for the compact HTML real booths use
(<!doctype html><meta charset><title><style>…content, no explicit head/
body): nothing is ever placed ahead of a leading <!doctype> (which would
force quirks mode), and the ~250B favicon link keeps the charset <meta>
inside the first-1024-byte detection window. The raw file route
(/b/<name>/index.html) stays byte-for-byte, so assets and ?dl=1 downloads
are unchanged; files over 8 MB serve raw, unwrapped.
Verified live on nh3-dev :8090 across the real booth shapes (compact-HTML
crow-*/jackdaw-*/mimir-favicon, well-formed dcc-summarizer-ab, own-icon
edict-favicon). 10 new tests; suite 38 passing.
This commit is contained in:
@@ -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'<link rel="icon" href="{FAVICON_HREF}">'
|
||||
|
||||
# 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 = (
|
||||
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
|
||||
# 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.
|
||||
"<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
||||
"font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
||||
"color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
|
||||
"background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
|
||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
|
||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
|
||||
"@media print{.booth-nav-home{display:none}}</style>"
|
||||
)
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
|
||||
|
||||
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
|
||||
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
|
||||
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
|
||||
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
|
||||
_HTML_CLOSE_RE = re.compile(r"</html\s*>", 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 (`<!doctype html><meta charset><title>
|
||||
<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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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</title></head><body><h1>REPORT</h1></body></html>"
|
||||
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 "<h1>REPORT</h1>" in out # original content preserved
|
||||
# favicon lands in the head, chip lands in the body
|
||||
assert out.index(FAVICON_LINK) < out.index("</head>")
|
||||
assert out.index("booth-nav-home") > out.index("<body>")
|
||||
|
||||
|
||||
def test_wrap_respects_existing_favicon():
|
||||
html = '<html><head><link rel="icon" href="data:image/png;base64,AAAA"></head><body>x</body></html>'
|
||||
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("<h1>bare fragment</h1>") # no doctype/head/body
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert out.rstrip().endswith("</style>") # 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("<body><h1>no head</h1></body>")
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert FAVICON_LINK in out # injected even without an explicit <head>
|
||||
|
||||
|
||||
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 = "<!doctype html><meta charset=utf-8><title>T</title><style>body{margin:0}</style><h1>REPORT</h1>"
|
||||
out = wrap_verbatim_html(html)
|
||||
assert out.lstrip().lower().startswith("<!doctype") # doctype still first -> standards mode
|
||||
assert FAVICON_LINK in out
|
||||
assert out.index(FAVICON_LINK) < out.index("<h1>") # 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("<h1>REPORT</h1>") # 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("<html><head></head><body><h1>BRIEF</h1></body></html>")
|
||||
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/<name>/index.html) still serves the raw bytes — the chip
|
||||
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
|
||||
c, data = client
|
||||
d = data / "brief"
|
||||
d.mkdir()
|
||||
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
|
||||
r = c.get("/b/brief/index.html")
|
||||
assert r.status_code == 200
|
||||
assert "booth-nav-home" not in r.text
|
||||
|
||||
Reference in New Issue
Block a user