Files
thethreemagi 8c8dab12e3 P1: gate members documents per unit, session auth on the admin API
documents.visible() now takes the viewer's unit set. A members document is
served and listed only to a signed-in member of the matching unit; 'both'
reaches any member; owner and admin reach everything including unit types
added later. Everyone else gets 404, never 403.

The gate moved INSIDE find(), so there is one path from a slug to a file and
no route can forget to check. listed() and visible() stay separate functions.

admin_api takes a session first and falls back to X-Admin-Token as break
glass. Still fails closed: no session and no ADMIN_TOKEN is 503. Capability,
not role, decides per route. announcements.created_by now comes from the
session and ignores any value in the request body.

tests/smoke_documents.py, 24 checks, including the invariant that the index
can never list something serving would refuse.
2026-09-04 12:09:19 -04:00

277 lines
9.7 KiB
Python

"""
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, and listed on /documents.
"unlisted" served at its slug to anyone holding the link, but kept off the
index and sent with X-Robots-Tag: noindex. For internal papers
that need a durable link before member login exists. This is
obscurity, not access control: treat an unlisted link as
forwardable, because it is.
"members" served only to a signed-in member of the right unit, and listed
only for them. Everyone else gets 404, never 403: a 403
advertises the existence of a document to someone who cannot
have it, and the slug is the only thing they would need.
Unit scoping. A document's `unit` is "pack", "troop" or "both". A viewer is
resolved to the set of unit types they belong to, and "both" is readable by any
member of any unit. An owner or admin reads everything, including units created
after their role was granted.
This module deliberately does NOT import identity. It reads a person as a plain
dict, so the coupling is a data shape rather than a module dependency and the
gate stays testable on its own.
"""
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", "unlisted", "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"]
# Every unit type that can appear on a document. A membership in a unit type
# outside this set (a Venturing crew, say) grants no document access until the
# manifest vocabulary is widened to match - failing closed is correct here.
DOC_UNITS = ("pack", "troop")
def units_for(person):
"""The document unit tokens a viewer may read. Empty set for anonymous.
An owner or admin gets everything, including a unit type added later. That
is the whole reason global_role is a column rather than a membership row.
"""
if not person or person.get("disabled_at"):
return frozenset()
if person.get("global_role") in ("owner", "admin"):
return frozenset(DOC_UNITS)
return frozenset(
m.get("unit_type") for m in person.get("memberships", [])
if m.get("unit_type") in DOC_UNITS)
def _member_ok(doc, units):
"""Does this viewer's unit set reach this members-only document?
"both" means the document concerns both units, so any member reaches it.
It does not mean "requires membership of both".
"""
if not units:
return False
return doc.get("unit") == "both" or doc.get("unit") in units
def visible(doc, units=None):
"""The single gate on SERVING. Login attaches here and nowhere else."""
if not doc["path"].is_file():
return False
vis = doc.get("visibility")
if vis in ("public", "unlisted"):
return True
if vis == "members":
return _member_ok(doc, units or frozenset())
return False
def listed(doc, units=None):
"""The separate, weaker question of whether it appears on the index.
Kept separate from visible() on purpose. Collapsing the two is how
"unlisted" quietly becomes public, or "members" quietly becomes servable.
"""
vis = doc.get("visibility")
if vis == "public":
return visible(doc, units)
if vis == "members":
return visible(doc, units)
return False
def noindex(doc):
"""Unlisted documents should not turn up in a search result."""
return doc.get("visibility") == "unlisted"
def listing(units=None):
"""Listed documents grouped into their categories, in manifest order."""
m = manifest()
docs = [d for d in m["documents"] if listed(d, units)]
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, units=None):
"""The document at this slug, or None if the viewer cannot have it.
The gate is applied HERE rather than by the caller, so there is exactly one
path from a slug to a file and no route can forget to check.
"""
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, units) 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")