diff --git a/README.md b/README.md index bbe2bc3..aa301e1 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,13 @@ rebuild, no redeploy.** The app re-reads the manifest whenever its mtime changes - `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. +- `visibility`: + - `public` - listed on `/documents`, open to anyone. + - `unlisted` - served at its slug to anyone holding the link, kept off the index, sent with + `X-Robots-Tag: noindex`. For internal papers that need a durable link before login exists. + **This is obscurity, not access control.** An unlisted link is forwardable; assume it will be. + - `members` - hidden and unservable, returns 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 diff --git a/app/app.py b/app/app.py index 9145aa7..904e9bc 100644 --- a/app/app.py +++ b/app/app.py @@ -594,8 +594,10 @@ def document_file(slug: str): """ return HTMLResponse(page("Not found ยท Pack & Troop 73", body, "docs"), status_code=404) _, mime = documents.kind(d) + headers = {"X-Robots-Tag": "noindex, nofollow"} if documents.noindex(d) else None return FileResponse(d["path"], media_type=mime, filename=d["path"].name, - content_disposition_type=documents.disposition(d)) + content_disposition_type=documents.disposition(d), + headers=headers) FAQ = [ diff --git a/app/documents.py b/app/documents.py index 10186d1..1d4f4b3 100644 --- a/app/documents.py +++ b/app/documents.py @@ -17,10 +17,15 @@ 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. + "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" reserved for the login that does not exist yet. Until it does, + these are hidden AND unservable, returning 404 rather than 403, + because a 403 advertises a document we cannot actually gate yet. """ import datetime @@ -109,7 +114,7 @@ def _clean(raw): "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", + "visibility": vis if vis in ("public", "unlisted", "members") else "members", "updated": str(d.get("updated") or "").strip(), }) return {"categories": cats, "documents": docs} @@ -133,14 +138,24 @@ def manifest(): def visible(doc): - """The single gate. Member login plugs in here and nowhere else.""" - return doc.get("visibility") == "public" and doc["path"].is_file() + """The single gate on SERVING. Member login plugs in here and nowhere else.""" + return doc.get("visibility") in ("public", "unlisted") and doc["path"].is_file() + + +def listed(doc): + """The separate, weaker question of whether it appears on the index.""" + return doc.get("visibility") == "public" and visible(doc) + + +def noindex(doc): + """Unlisted documents should not turn up in a search result.""" + return doc.get("visibility") == "unlisted" def listing(): - """Visible documents grouped into their categories, in manifest order.""" + """Listed documents grouped into their categories, in manifest order.""" m = manifest() - docs = [d for d in m["documents"] if visible(d)] + docs = [d for d in m["documents"] if listed(d)] known = {c["id"] for c in m["categories"]} groups = [] for cat in m["categories"]: