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.
277 lines
9.7 KiB
Python
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")
|