Add a document shelf at /documents, served through the app

Files live outside the repo at /srv/scout-website-assets/docs (bind-mounted
read-only at /docs), the same arrangement as the photos, published by a
manifest.json beside them. Adding a document is a file drop plus a manifest
entry - the app re-reads the manifest on mtime change, so no rebuild and no
redeploy.

The manifest maps a stable slug to a filename, so next year's permission slip
can replace this year's without breaking a link already printed on a flyer.

Documents are served through /documents/{slug} rather than from a static
mount, and NOT placed under /app/static, which is public forever. That is the
point: when member login exists it plugs into documents.visible() and no
public URL moves. The visibility field already carries 'members', which today
is hidden from the index and 404s rather than 403s, since a 403 would
advertise a document we cannot yet gate.
This commit is contained in:
Mike Wichers
2026-08-30 09:02:29 -04:00
parent 27f1a2104b
commit 1ecdb32139
4 changed files with 296 additions and 4 deletions
+198
View File
@@ -0,0 +1,198 @@
"""
documents.py - the document shelf behind /documents.
Files live OUTSIDE the repo at /srv/scout-website-assets/docs, bind-mounted
read-only at /docs, the same arrangement as the photos. PDFs never bloat a
clone, and publishing one is a file drop plus a manifest line - no rebuild,
no redeploy.
manifest.json sits beside the files and maps a STABLE SLUG to a filename. That
indirection is the point: next year's permission slip can replace this year's
without breaking a link already printed on a flyer or sitting in somebody's
inbox.
Documents are served THROUGH the app, never from a static mount. Anything
under /app/static is public forever. Serving through a route means that when
member login exists, gating a document is one change in visible() and not a
single URL moves.
visibility:
"public" anyone, which is everything today
"members" reserved for the login that does not exist yet. Until it does,
these are hidden from the index and return 404 rather than 403,
because a 403 advertises a document we cannot actually gate yet.
"""
import datetime
import json
import os
import re
from pathlib import Path
DOCS_DIR = Path(os.environ.get("DOCS_DIR", "/docs"))
MANIFEST = DOCS_DIR / "manifest.json"
# Slugs are the public contract. Keep them boring and permanent.
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
EMPTY = {"categories": [], "documents": []}
# extension -> (badge label, media type). Anything unlisted downloads as a blob.
KINDS = {
".pdf": ("PDF", "application/pdf"),
".doc": ("DOC", "application/msword"),
".docx": ("DOC", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
".xls": ("XLS", "application/vnd.ms-excel"),
".xlsx": ("XLS", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
".ppt": ("PPT", "application/vnd.ms-powerpoint"),
".pptx": ("PPT", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
".csv": ("CSV", "text/csv"),
".txt": ("TXT", "text/plain; charset=utf-8"),
".md": ("TXT", "text/plain; charset=utf-8"),
".ics": ("ICS", "text/calendar; charset=utf-8"),
".jpg": ("IMG", "image/jpeg"),
".jpeg": ("IMG", "image/jpeg"),
".png": ("IMG", "image/png"),
".zip": ("ZIP", "application/zip"),
}
# Browsers should open these in a tab; the rest download.
INLINE = {".pdf", ".txt", ".md", ".jpg", ".jpeg", ".png"}
_cache = {"key": None, "value": EMPTY}
def _stat_key():
try:
st = MANIFEST.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
def _clean(raw):
"""Drop anything malformed rather than serving a half-valid shelf."""
cats, seen_cat = [], set()
for c in raw.get("categories") or []:
cid = str(c.get("id", "")).strip()
if not cid or cid in seen_cat:
continue
seen_cat.add(cid)
cats.append({
"id": cid,
"title": str(c.get("title") or cid).strip(),
"blurb": str(c.get("blurb") or "").strip(),
})
docs, seen_slug = [], set()
for d in raw.get("documents") or []:
slug = str(d.get("slug", "")).strip().lower()
name = str(d.get("file", "")).strip()
if not SLUG_RE.match(slug) or slug in seen_slug or not name:
continue
# Path traversal guard: the resolved file must sit inside DOCS_DIR.
path = (DOCS_DIR / name).resolve()
try:
inside = path.is_relative_to(DOCS_DIR.resolve())
except AttributeError: # pragma: no cover - Python < 3.9
inside = str(path).startswith(str(DOCS_DIR.resolve()) + os.sep)
if not inside:
continue
seen_slug.add(slug)
vis = str(d.get("visibility") or "public").strip().lower()
unit = str(d.get("unit") or "both").strip().lower()
docs.append({
"slug": slug,
"file": name,
"path": path,
"title": str(d.get("title") or slug).strip(),
"description": str(d.get("description") or "").strip(),
"category": str(d.get("category") or "").strip(),
"unit": unit if unit in ("pack", "troop", "both") else "both",
"visibility": vis if vis in ("public", "members") else "members",
"updated": str(d.get("updated") or "").strip(),
})
return {"categories": cats, "documents": docs}
def manifest():
"""Reload only when manifest.json actually changed on disk."""
key = _stat_key()
if key != _cache["key"]:
if key is None:
_cache["value"] = EMPTY
else:
try:
with MANIFEST.open(encoding="utf-8") as fh:
_cache["value"] = _clean(json.load(fh))
except Exception as e:
print("DOCUMENTS: manifest unreadable: %s" % e, flush=True)
_cache["value"] = EMPTY
_cache["key"] = key
return _cache["value"]
def visible(doc):
"""The single gate. Member login plugs in here and nowhere else."""
return doc.get("visibility") == "public" and doc["path"].is_file()
def listing():
"""Visible documents grouped into their categories, in manifest order."""
m = manifest()
docs = [d for d in m["documents"] if visible(d)]
known = {c["id"] for c in m["categories"]}
groups = []
for cat in m["categories"]:
rows = [d for d in docs if d["category"] == cat["id"]]
if rows:
groups.append((cat, rows))
loose = [d for d in docs if d["category"] not in known]
if loose:
groups.append(({"id": "", "title": "Everything else", "blurb": ""}, loose))
return groups
def find(slug):
slug = (slug or "").strip().lower()
if not SLUG_RE.match(slug):
return None
for d in manifest()["documents"]:
if d["slug"] == slug:
return d if visible(d) else None
return None
def kind(doc):
return KINDS.get(doc["path"].suffix.lower(), ("FILE", "application/octet-stream"))
def disposition(doc):
return "inline" if doc["path"].suffix.lower() in INLINE else "attachment"
def size_text(doc):
try:
n = doc["path"].stat().st_size
except OSError:
return ""
if n < 1024:
return "%d B" % n
if n < 1024 * 1024:
return "%d KB" % round(n / 1024)
return "%.1f MB" % (n / 1024 / 1024)
def updated_text(doc):
"""Manifest date wins; otherwise the file's own mtime, which cannot lie."""
stamp = doc.get("updated")
if stamp:
try:
return datetime.date.fromisoformat(stamp).strftime("%b %-d, %Y")
except ValueError:
return stamp
try:
ts = doc["path"].stat().st_mtime
except OSError:
return ""
return datetime.date.fromtimestamp(ts).strftime("%b %-d, %Y")