diff --git a/README.md b/README.md index c4c5dbc..bbe2bc3 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,33 @@ Event fields the feed emits (one object per event): - Past events are hidden from "Next up" automatically; the Calendar page shows the whole program year. - TeamSnap (team 8198615) stays the source of truth for registered families; Radicale `mike/site73` is the source for this public site. +## Documents + +Files at `/srv/scout-website-assets/docs` (bind-mounted read-only at `/docs`), published by +`manifest.json` in that same folder. **Adding a document is a file drop plus a manifest entry: no +rebuild, no redeploy.** The app re-reads the manifest whenever its mtime changes. + +```json +{"slug": "permission-slip", "file": "2026-permission-slip.pdf", + "title": "Activity permission slip", "description": "One per scout, per outing.", + "category": "forms", "unit": "both", "visibility": "public", "updated": "2026-08-30"} +``` + +- `slug` is the permanent URL: `greenlanescouts73.org/documents/`. **Never change one that has + been printed or emailed.** To publish a new version, point the same slug at the new filename. +- `category` matches an id in the manifest's `categories`; anything else lands under "Everything else". +- `visibility`: `public`, or `members` for later. `members` documents are hidden from the index and + return 404 - **this is not a working gate yet**, it is the seam login will attach to. +- `updated` optional; without it the file's own mtime is shown. + +Documents are served **through the app** (`/documents/{slug}`), never from a static mount. Anything +under `/app/static` is public forever, so nothing that will ever need gating goes there. When member +login exists it plugs into `documents.visible()` and no public URL moves. + ## Layout -- `app/app.py` — FastAPI app, all five pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join). `/contact` 301s to `/join`. +- `app/app.py` — FastAPI app, all pages server-rendered (Home, /cubs, /troop, /calendar?unit=, /join, /documents). `/contact` 301s to `/join`. +- `app/documents.py` — the document shelf: manifest loading, the slug-to-file map, and the one visibility gate. - `docker-compose.yml` — Portainer Repository stack definition (port 8131). - Images: **not in the repo.** Bind-mounted read-only from `/srv/scout-website-assets/img`. Source photos live on the NAS (`Scouting-Recruitment-Images`, `scouting-comms/pack-73/assets/social`); web-sized copies in `Scouting-Recruitment-Images/_web`. diff --git a/app/app.py b/app/app.py index 5e22762..9145aa7 100644 --- a/app/app.py +++ b/app/app.py @@ -1,13 +1,14 @@ import json, os, time, datetime, urllib.request, urllib.parse from pathlib import Path from fastapi import FastAPI, Form -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles # Internal record store + admin API. The store is the source of truth for # anything a family submits; see store.py for why. import store import admin_api +import documents # --------------------------------------------------------------------------- # Pack & Troop 73 - greenlanescouts73.org @@ -259,6 +260,14 @@ footer a{color:#D8DFEA;text-decoration:none;font-size:14px} footer a:hover{color:#fff} footer a.gold{color:#F7C556;font-weight:700} footer a.fb{display:inline-flex;align-items:center;gap:8px} +.docrow{display:flex;gap:16px;align-items:center;background:#fff;border:1px solid #EBE4D4;border-radius:14px;padding:16px 18px;text-decoration:none;color:inherit;transition:transform .12s,border-color .12s} +.docrow:hover{transform:translateY(-2px);border-color:#CBD2DC;color:inherit} +.docicon{font-family:'Archivo';font-weight:800;font-size:12px;letter-spacing:.06em;color:#fff;background:#1E2F52;border-radius:10px;padding:13px 0;width:58px;min-width:58px;text-align:center} +.docmain{flex:1;min-width:0} +.doct{font-family:'Archivo';font-weight:700;font-size:16px;color:#1E2F52;display:flex;gap:10px;align-items:center;flex-wrap:wrap} +.docd{display:block;margin-top:3px;color:#6A7280;font-size:14px} +.docmeta{font-size:12.5px;font-weight:600;color:#8B93A3;white-space:nowrap} +@media(max-width:560px){.docrow{flex-wrap:wrap}.docmeta{width:100%;padding-left:74px}} """ def page(title, body, active=""): @@ -296,7 +305,7 @@ def page(title, body, active=""):
Chartered by St. Luke's Lutheran Church · Zieglerville, PA
Cradle of Liberty Council · A family program. Every kid welcome.
VISIT
Tuesdays · Pack 6:00 · Troop 7:30
St. Luke's Lutheran Church
Zieglerville, PA
EXPLORE
-Cub Scouts · Pack 73Troop 73 · Ages 11-172026-2027 CalendarJoin us
+Cub Scouts · Pack 73Troop 73 · Ages 11-172026-2027 CalendarForms & documentsJoin us
FOLLOW
Pack 73Troop 73
@@ -531,6 +540,64 @@ Dates occasionally shift. Registered families get the live TeamSnap calendar wit """ return page("2026-2027 Calendar · Pack & Troop 73", body, "cal") +# --------------------------------------------------------------------------- +# Documents. Files come from the manifest in /docs (see documents.py); they are +# deliberately served through this route rather than a static mount, so member +# login can gate them later without a single public URL changing. +# --------------------------------------------------------------------------- + +def doc_row(d): + label, _ = documents.kind(d) + u = d["unit"] + chip = f' {unit_label(u)}' if u != "both" else "" + desc = f'{d["description"]}' if d["description"] else "" + meta = " · ".join(x for x in [documents.size_text(d), documents.updated_text(d)] if x) + return (f'' + f'{label}' + f'{d["title"]}{chip}{desc}' + f'{meta}') + +@app.get("/documents", response_class=HTMLResponse) +def documents_index(): + groups = documents.listing() + if groups: + cards = [] + for cat, rows in groups: + blurb = f'

{cat["blurb"]}

' if cat["blurb"] else "" + cards.append(f'

{cat["title"]}

{blurb}' + f'
{"".join(doc_row(d) for d in rows)}
') + inner = "".join(cards) + else: + inner = '
Nothing posted here yet. Forms and handouts will land on this page as the program year gets going.
' + body = f""" +
+
Pack & Troop 73
+

Forms and documents.

+

Everything worth handing out, in one place. These links are permanent, so a QR code or a printed handout keeps working even after the file behind it is replaced.

+
+
{inner}
+
+Can't find something, or need a form in another format? Message us on Facebook or catch a leader on a Tuesday night. +
""" + return page("Forms & Documents · Pack & Troop 73", body, "docs") + +@app.get("/documents/{slug}") +def document_file(slug: str): + d = documents.find(slug) + if d is None: + body = """ +
+
Pack & Troop 73
+

That document isn't here.

+

The link may be out of date, or the file may have been taken down. Everything currently posted is on the documents page.

+See all documents +
""" + return HTMLResponse(page("Not found · Pack & Troop 73", body, "docs"), status_code=404) + _, mime = documents.kind(d) + return FileResponse(d["path"], media_type=mime, filename=d["path"].name, + content_disposition_type=documents.disposition(d)) + + FAQ = [ ("What does it cost?", "Annual registration plus modest pack or troop dues. Fundraisers like our chocolate booth keep costs down, and quiet help is available. Cost never keeps a kid out of Pack 73; just talk to us."), ("Do parents stay at meetings?", "Yes: a parent or guardian stays at every Cub Scout meeting and event. Lions and Tigers pair up with their grown-up for the hands-on parts; for older dens, being in the room is enough. Troop scouts (11 to 17) can be dropped off."), diff --git a/app/documents.py b/app/documents.py new file mode 100644 index 0000000..10186d1 --- /dev/null +++ b/app/documents.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 10daafc..59f930f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,9 @@ # - no relative bind mounts: `.` resolves to Portainer's clone, not /srv # - no env_file: NTFY_URL comes from the Portainer stack env # Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted -# read-only) so photos never bloat clones. +# read-only) so photos never bloat clones. Documents work the same way, from +# /srv/scout-website-assets/docs mounted at /docs - NOT under /app/static, +# because they are served through the app so login can gate them later. # The calendar comes from the scout-calendar feed (EVENTS_FEED_URL, set in # the Portainer stack env); /data/events-cache.json is the stale fallback. @@ -30,6 +32,7 @@ services: - /srv/scout-website/data:/data - /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro - /srv/scout-website-assets/img:/app/static/img:ro + - /srv/scout-website-assets/docs:/docs:ro networks: - default - npm