diff --git a/booth/app.py b/booth/app.py index b7af84f..526548e 100644 --- a/booth/app.py +++ b/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/booth/templates/booth.html b/booth/templates/booth.html
index b2ee0f5..4ac250f 100644
--- a/booth/templates/booth.html
+++ b/booth/templates/booth.html
@@ -32,6 +32,8 @@
         
       {% elif it.kind == 'audio' %}
         
+      {% elif it.doc %}
+        📄 {{ it.name }}
       {% else %}
         ⬇ {{ it.name }}
       {% endif %}
diff --git a/booth/templates/doc.html b/booth/templates/doc.html
new file mode 100644
index 0000000..66f19ce
--- /dev/null
+++ b/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/pyproject.toml b/pyproject.toml index 41c5d0d..d87e35d 100644 --- a/pyproject.toml +++ b/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/tests/test_booth.py b/tests/test_booth.py index e25fe3c..6fd15b6 100644 --- a/tests/test_booth.py +++ b/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