feat(booth): view .md (rendered) and .txt/.log in-booth without downloading

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/<name>/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 <pre> 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.
This commit is contained in:
2026-08-05 01:13:38 -07:00
parent b5ae9365ff
commit 315faac4b5
5 changed files with 157 additions and 20 deletions
+47 -15
View File
@@ -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 <pre>."""
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):
@@ -32,6 +32,8 @@
<video controls preload="metadata" src="{{ it.url }}"></video>
{% elif it.kind == 'audio' %}
<audio controls preload="metadata" src="{{ it.url }}"></audio>
{% elif it.doc %}
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
{% else %}
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
{% endif %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block content %}
<div class="docview">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)"></a>
<span class="vname">{{ file }}</span>
<span class="vspacer"></span>
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}"></a>
</div>
{% if is_html %}
<article class="markdown-body">{{ body|safe }}</article>
{% else %}
<pre class="textview">{{ body }}</pre>
{% endif %}
</div>
<style>
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
.textview{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);
font-size:.86rem;line-height:1.5;color:var(--fg-1);background:var(--rk-well);
border:1px solid var(--rk-line,#252a35);border-radius:10px;padding:1rem 1.15rem;overflow-x:auto}
.markdown-body{color:var(--fg-1);line-height:1.62;font-size:.98rem;overflow-wrap:break-word}
.markdown-body h1,.markdown-body h2,.markdown-body h3{line-height:1.25;margin:1.6em 0 .5em}
.markdown-body h1{font-size:1.7em}.markdown-body h2{font-size:1.35em}.markdown-body h3{font-size:1.12em}
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--rk-line,#252a35);padding-bottom:.3em}
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
.markdown-body a{color:var(--aus-bright-cyan,#42dcd1)}
.markdown-body code{font-family:var(--font-mono);font-size:.86em;background:var(--rk-well);
padding:.12em .38em;border-radius:5px}
.markdown-body pre{background:var(--rk-well);border:1px solid var(--rk-line,#252a35);
border-radius:10px;padding:.9rem 1.05rem;overflow-x:auto}
.markdown-body pre code{background:none;padding:0}
.markdown-body blockquote{border-left:3px solid var(--aus-bright-cyan,#42dcd1);
padding-left:1em;color:var(--fg-2);margin-left:0}
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
.markdown-body img{max-width:100%}
</style>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
});
</script>
{% endblock %}
+2 -1
View File
@@ -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]
+62 -4
View File
@@ -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 "<h1>" in html and "Title" in html
assert "<li>" 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 "<h1>" in r.text and "Heading" in r.text # rendered, not raw markdown
assert "<strong>bold</strong>" 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 "<pre" in r.text and "line one" in r.text
assert "attachment" not in r.headers.get("content-disposition", "")
def test_gallery_links_docs_to_view(client):
c, data = client
d = data / "run1"; d.mkdir()
(d / "readme.md").write_text("# hi")
(d / "notes.txt").write_text("hello") # loose txt (no media partner) -> 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