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 %}
+
+
+ {% 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 "
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