""" 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")