From 315faac4b5c55f7ec9f1373375a66af6c278541b Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 5 Aug 2026 01:13:38 -0700 Subject: [PATCH] feat(booth): view .md (rendered) and .txt/.log in-booth without downloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loose .md/.txt/.log files rendered as forced-download links in the gallery and downloaded (or showed raw) when opened. Now they open in a readable in-booth page via the existing /b//view route: - .md -> rendered HTML (Python-Markdown: fenced code, tables, sane lists), styled in an Australis .markdown-body with the viewer chrome; - .txt/.log -> preformatted
 text view.
The gallery links docs to the viewer (๐Ÿ“„) instead of a download; the view
page keeps a โฌ‡ (?dl=1) for saving. Files over 2 MB hand back raw. New
markdown dep (optional-import: degrades .md to text view if absent).
booth_image_view -> booth_view_file (now handles image + doc + raw-fallback).
9 new tests, suite 44 passing; deployed + verified live on nh3-dev :8090.
---
 services/booth/booth/app.py               | 62 +++++++++++++++------
 services/booth/booth/templates/booth.html |  2 +
 services/booth/booth/templates/doc.html   | 44 +++++++++++++++
 services/booth/pyproject.toml             |  3 +-
 services/booth/tests/test_booth.py        | 66 +++++++++++++++++++++--
 5 files changed, 157 insertions(+), 20 deletions(-)
 create mode 100644 services/booth/booth/templates/doc.html

diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py
index b7af84f..526548e 100644
--- a/services/booth/booth/app.py
+++ b/services/booth/booth/app.py
@@ -38,6 +38,11 @@ from fastapi.responses import (
 )
 from fastapi.templating import Jinja2Templates
 
+try:
+    import markdown as _markdown
+except ImportError:  # optional dep โ€” .md then degrades to a plain-text view
+    _markdown = None
+
 TEMPLATES_DIR = Path(__file__).parent / "templates"
 
 # Browser-playable media buckets. Anything else renders as a download link.
@@ -47,6 +52,30 @@ AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus", ".aac"}
 
 CAPTION_MAX = 800  # chars of a sidecar .txt caption we render
 
+# Loose text docs that render as a readable in-booth page (not a download).
+MARKDOWN_EXTS = {".md", ".markdown", ".mdown"}
+TEXT_EXTS = {".txt", ".text", ".log"}
+DOC_MAX_BYTES = 2 * 1024 * 1024  # above this, a doc is handed back raw, not rendered
+
+
+def doc_kind(name: str) -> str | None:
+    """'markdown' | 'text' | None โ€” a booth file viewable as a readable page."""
+    ext = Path(name).suffix.lower()
+    if ext in MARKDOWN_EXTS:
+        return "markdown"
+    if ext in TEXT_EXTS:
+        return "text"
+    return None
+
+
+def render_doc(text: str, kind: str) -> tuple[str, bool]:
+    """(rendered, is_html). Markdown โ†’ HTML (fenced code, tables, sane lists);
+    plain text โ€” or markdown when the lib is unavailable โ€” โ†’ raw text for 
."""
+    if kind == "markdown" and _markdown is not None:
+        html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
+        return html, True
+    return text, False
+
 
 def classify(name: str) -> str:
     """image | video | audio | other, by extension."""
@@ -203,6 +232,7 @@ def build_gallery(child: Path) -> list[dict]:
             {
                 "name": rel,
                 "kind": classify(p.name),
+                "doc": doc_kind(p.name),
                 "url": quote(rel, safe="/"),
                 "caption": caption.get(rel),
             }
@@ -484,7 +514,7 @@ def create_app(
         )
 
     @app.get("/b/{name}/view", response_class=HTMLResponse)
-    def booth_image_view(request: Request, name: str, f: str):
+    def booth_view_file(request: Request, name: str, f: str):
         booth = resolve_booth(name)
         try:
             target = (booth / f).resolve()
@@ -493,20 +523,22 @@ def create_app(
         if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
             raise HTTPException(status_code=404, detail="no such file")
         file_url = quote(f, safe="/")
-        if classify(target.name) != "image":
-            # nothing to zoom on a non-image โ€” hand back the raw file
-            return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
-        return templates.TemplateResponse(
-            request,
-            "view.html",
-            {
-                **base_ctx,
-                "name": name,
-                "name_url": quote(name, safe=""),
-                "file": f,
-                "file_url": file_url,
-            },
-        )
+        common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
+        if classify(target.name) == "image":
+            return templates.TemplateResponse(request, "view.html", common)
+        # .md renders, .txt/.log show as text โ€” viewable in-booth, no download
+        dk = doc_kind(target.name)
+        if dk:
+            try:
+                if target.stat().st_size <= DOC_MAX_BYTES:
+                    body, is_html = render_doc(target.read_text(encoding="utf-8", errors="replace"), dk)
+                    return templates.TemplateResponse(
+                        request, "doc.html", {**common, "kind": dk, "body": body, "is_html": is_html}
+                    )
+            except OSError:
+                raise HTTPException(status_code=404, detail="no such file")
+        # nothing to render โ€” hand back the raw file
+        return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
 
     @app.get("/b/{name}/{filepath:path}")
     def booth_file(name: str, filepath: str, dl: int = 0):
diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html
index b2ee0f5..4ac250f 100644
--- a/services/booth/booth/templates/booth.html
+++ b/services/booth/booth/templates/booth.html
@@ -32,6 +32,8 @@
         
       {% elif it.kind == 'audio' %}
         
+      {% elif it.doc %}
+        ๐Ÿ“„ {{ it.name }}
       {% else %}
         โฌ‡ {{ it.name }}
       {% endif %}
diff --git a/services/booth/booth/templates/doc.html b/services/booth/booth/templates/doc.html
new file mode 100644
index 0000000..66f19ce
--- /dev/null
+++ b/services/booth/booth/templates/doc.html
@@ -0,0 +1,44 @@
+{% extends "base.html" %}
+{% block title %}{{ file }} ยท {{ name }} ยท The Booth{% endblock %}
+{% block content %}
+
+
+ โœ• + {{ file }} + + โฌ‡ +
+ {% if is_html %} +
{{ body|safe }}
+ {% else %} +
{{ body }}
+ {% endif %} +
+ + +{% endblock %} diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index 41c5d0d..d87e35d 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "booth" -version = "0.1.5" +version = "0.1.6" 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 = [ @@ -8,6 +8,7 @@ dependencies = [ "uvicorn[standard]>=0.34", "jinja2>=3.1", "python-multipart>=0.0.9", + "markdown>=3.5", ] [project.optional-dependencies] diff --git a/services/booth/tests/test_booth.py b/services/booth/tests/test_booth.py index e25fe3c..6fd15b6 100644 --- a/services/booth/tests/test_booth.py +++ b/services/booth/tests/test_booth.py @@ -10,9 +10,11 @@ from booth.app import ( build_gallery, classify, create_app, + doc_kind, generate_pickup_id, human_dur, is_expired, + render_doc, safe_upload_name, sweep_once, wrap_verbatim_html, @@ -353,12 +355,12 @@ def test_image_view_traversal_404(client): assert c.get("/b/run1/view", params={"f": "../../etc/passwd"}).status_code == 404 -def test_image_view_nonimage_redirects_to_raw(client): +def test_view_nonviewable_redirects_to_raw(client): c, data = client - _touch(data / "run1" / "notes.txt") - r = c.get("/b/run1/view", params={"f": "notes.txt"}, follow_redirects=False) + _touch(data / "run1" / "data.bin") # not image/md/txt -> nothing to render, hand back raw + r = c.get("/b/run1/view", params={"f": "data.bin"}, follow_redirects=False) assert r.status_code == 307 - assert r.headers["location"] == "/b/run1/notes.txt" + assert r.headers["location"] == "/b/run1/data.bin" # ---- verbatim-index.html wrapper -------------------------------------------- @@ -435,3 +437,59 @@ def test_verbatim_index_raw_file_route_unwrapped(client): r = c.get("/b/brief/index.html") assert r.status_code == 200 assert "booth-nav-home" not in r.text + + +# ---- .md / .txt in-booth doc viewer ----------------------------------------- + + +def test_doc_kind(): + assert doc_kind("notes.md") == "markdown" + assert doc_kind("a.markdown") == "markdown" + assert doc_kind("log.txt") == "text" + assert doc_kind("run.log") == "text" + assert doc_kind("shot.png") is None + assert doc_kind("data.bin") is None + + +def test_render_doc_markdown(): + html, is_html = render_doc("# Title\n\n- a\n- b\n", "markdown") + assert is_html is True + assert "

" in html and "Title" in html + assert "
  • " in html + + +def test_render_doc_text_is_verbatim(): + body, is_html = render_doc("plain\ntext", "text") + assert is_html is False and body == "plain\ntext" + + +def test_view_markdown_renders(client): + c, data = client + d = data / "run1"; d.mkdir() + (d / "notes.md").write_text("# Heading\n\nsome **bold** text\n") + r = c.get("/b/run1/view", params={"f": "notes.md"}) + assert r.status_code == 200 + assert "

    " in r.text and "Heading" in r.text # rendered, not raw markdown + assert "bold" in r.text + assert "attachment" not in r.headers.get("content-disposition", "") # viewed, not downloaded + + +def test_view_text_shows_preformatted(client): + c, data = client + d = data / "run1"; d.mkdir() + (d / "out.txt").write_text("line one\nline two") + r = c.get("/b/run1/view", params={"f": "out.txt"}) + assert r.status_code == 200 + assert " own item + page = c.get("/b/run1/") + assert "view?f=readme.md" in page.text # md -> viewer + assert "view?f=notes.txt" in page.text # txt -> viewer + assert 'href="readme.md" download' not in page.text # not a forced download