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:
@@ -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.
|
- 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.
|
- 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/<slug>`. **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
|
## 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).
|
- `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`.
|
- 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`.
|
||||||
|
|
||||||
|
|||||||
+69
-2
@@ -1,13 +1,14 @@
|
|||||||
import json, os, time, datetime, urllib.request, urllib.parse
|
import json, os, time, datetime, urllib.request, urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI, Form
|
from fastapi import FastAPI, Form
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
# Internal record store + admin API. The store is the source of truth for
|
# Internal record store + admin API. The store is the source of truth for
|
||||||
# anything a family submits; see store.py for why.
|
# anything a family submits; see store.py for why.
|
||||||
import store
|
import store
|
||||||
import admin_api
|
import admin_api
|
||||||
|
import documents
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pack & Troop 73 - greenlanescouts73.org
|
# 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:hover{color:#fff}
|
||||||
footer a.gold{color:#F7C556;font-weight:700}
|
footer a.gold{color:#F7C556;font-weight:700}
|
||||||
footer a.fb{display:inline-flex;align-items:center;gap:8px}
|
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=""):
|
def page(title, body, active=""):
|
||||||
@@ -296,7 +305,7 @@ def page(title, body, active=""):
|
|||||||
<div style="font-size:14px;line-height:1.7">Chartered by St. Luke's Lutheran Church · Zieglerville, PA<br>Cradle of Liberty Council · A family program. Every kid welcome.</div></div>
|
<div style="font-size:14px;line-height:1.7">Chartered by St. Luke's Lutheran Church · Zieglerville, PA<br>Cradle of Liberty Council · A family program. Every kid welcome.</div></div>
|
||||||
<div><div class="fh">VISIT</div><div style="font-size:14px;line-height:1.9">Tuesdays · Pack 6:00 · Troop 7:30<br>St. Luke's Lutheran Church<br>Zieglerville, PA</div></div>
|
<div><div class="fh">VISIT</div><div style="font-size:14px;line-height:1.9">Tuesdays · Pack 6:00 · Troop 7:30<br>St. Luke's Lutheran Church<br>Zieglerville, PA</div></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">EXPLORE</div>
|
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">EXPLORE</div>
|
||||||
<a href="/cubs">Cub Scouts · Pack 73</a><a href="/troop">Troop 73 · Ages 11-17</a><a href="/calendar">2026-2027 Calendar</a><a class="gold" href="/join">Join us</a></div>
|
<a href="/cubs">Cub Scouts · Pack 73</a><a href="/troop">Troop 73 · Ages 11-17</a><a href="/calendar">2026-2027 Calendar</a><a href="/documents">Forms & documents</a><a class="gold" href="/join">Join us</a></div>
|
||||||
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">FOLLOW</div>
|
<div style="display:flex;flex-direction:column;gap:8px"><div class="fh" style="margin-bottom:4px">FOLLOW</div>
|
||||||
<a class="fb" href="https://www.facebook.com/Pack73GreenLane" target="_blank" rel="noopener" aria-label="Pack 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Pack 73</a><a class="fb" href="https://www.facebook.com/Troop73GreenLane" target="_blank" rel="noopener" aria-label="Troop 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Troop 73</a></div>
|
<a class="fb" href="https://www.facebook.com/Pack73GreenLane" target="_blank" rel="noopener" aria-label="Pack 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Pack 73</a><a class="fb" href="https://www.facebook.com/Troop73GreenLane" target="_blank" rel="noopener" aria-label="Troop 73 on Facebook"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true" style="flex:none"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>Troop 73</a></div>
|
||||||
</div></footer>
|
</div></footer>
|
||||||
@@ -531,6 +540,64 @@ Dates occasionally shift. Registered families get the live TeamSnap calendar wit
|
|||||||
</div></div></section>"""
|
</div></div></section>"""
|
||||||
return page("2026-2027 Calendar · Pack & Troop 73", body, "cal")
|
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' <span class="uchip uchip-{u}">{unit_label(u)}</span>' if u != "both" else ""
|
||||||
|
desc = f'<span class="docd">{d["description"]}</span>' if d["description"] else ""
|
||||||
|
meta = " · ".join(x for x in [documents.size_text(d), documents.updated_text(d)] if x)
|
||||||
|
return (f'<a class="docrow" href="/documents/{d["slug"]}">'
|
||||||
|
f'<span class="docicon">{label}</span>'
|
||||||
|
f'<span class="docmain"><span class="doct">{d["title"]}{chip}</span>{desc}</span>'
|
||||||
|
f'<span class="docmeta">{meta}</span></a>')
|
||||||
|
|
||||||
|
@app.get("/documents", response_class=HTMLResponse)
|
||||||
|
def documents_index():
|
||||||
|
groups = documents.listing()
|
||||||
|
if groups:
|
||||||
|
cards = []
|
||||||
|
for cat, rows in groups:
|
||||||
|
blurb = f'<p class="lead" style="margin:0 0 18px;font-size:15px">{cat["blurb"]}</p>' if cat["blurb"] else ""
|
||||||
|
cards.append(f'<div style="margin-bottom:44px"><h2 class="sec" style="font-size:1.5rem;margin-bottom:6px">{cat["title"]}</h2>{blurb}'
|
||||||
|
f'<div style="display:flex;flex-direction:column;gap:12px">{"".join(doc_row(d) for d in rows)}</div></div>')
|
||||||
|
inner = "".join(cards)
|
||||||
|
else:
|
||||||
|
inner = '<div class="noev">Nothing posted here yet. Forms and handouts will land on this page as the program year gets going.</div>'
|
||||||
|
body = f"""
|
||||||
|
<div class="calhead"><div class="wrap">
|
||||||
|
<div class="eyebrow gold">Pack & Troop 73</div>
|
||||||
|
<h1>Forms and documents.</h1>
|
||||||
|
<p class="sub">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.</p>
|
||||||
|
</div></div>
|
||||||
|
<section style="padding:52px 0 70px"><div class="wrap">{inner}</div></section>
|
||||||
|
<section style="padding:0 0 80px"><div class="wrap"><div class="note">
|
||||||
|
Can't find something, or need a form in another format? Message us on Facebook or catch a leader on a Tuesday night.
|
||||||
|
</div></div></section>"""
|
||||||
|
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 = """
|
||||||
|
<div class="calhead"><div class="wrap">
|
||||||
|
<div class="eyebrow gold">Pack & Troop 73</div>
|
||||||
|
<h1>That document isn't here.</h1>
|
||||||
|
<p class="sub">The link may be out of date, or the file may have been taken down. Everything currently posted is on the documents page.</p>
|
||||||
|
<a class="cta" href="/documents">See all documents</a>
|
||||||
|
</div></div>"""
|
||||||
|
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 = [
|
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."),
|
("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."),
|
("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."),
|
||||||
|
|||||||
@@ -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")
|
||||||
+4
-1
@@ -5,7 +5,9 @@
|
|||||||
# - no relative bind mounts: `.` resolves to Portainer's clone, not /srv
|
# - no relative bind mounts: `.` resolves to Portainer's clone, not /srv
|
||||||
# - no env_file: NTFY_URL comes from the Portainer stack env
|
# - no env_file: NTFY_URL comes from the Portainer stack env
|
||||||
# Images live OUTSIDE the repo at /srv/scout-website-assets/img (bind-mounted
|
# 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 calendar comes from the scout-calendar feed (EVENTS_FEED_URL, set in
|
||||||
# the Portainer stack env); /data/events-cache.json is the stale fallback.
|
# 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/data:/data
|
||||||
- /srv/scout-website/secrets/service_account.json:/app/service_account.json:ro
|
- /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/img:/app/static/img:ro
|
||||||
|
- /srv/scout-website-assets/docs:/docs:ro
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- npm
|
- npm
|
||||||
|
|||||||
Reference in New Issue
Block a user