From dcd1e5afc9ffd55626bc7d228c62e4a43b6260ea Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 17 Aug 2026 03:58:59 +0000 Subject: [PATCH] Prepare for forward-auth; edit buttons and functions conditional on server 401, login button if needed. --- AGENTS.md | 18 ++++++-- frontend/src/assets/pagerite.css | 15 +++++++ frontend/src/pagerite.js | 70 +++++++++++++++++++++++++++++++- pagerite/app.py | 6 --- pagerite/views.py | 37 ++++------------- scripts/devserver.py | 3 +- 6 files changed, 106 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 91b6ace..4f9538f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,14 @@ not for the public pages. See `docs/design-principles.md` for the design. database (never overwrites existing pages). - `frontend/src/` — the Vue editor and public-page entries. - `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor. - - `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal and - code copy buttons. The backend links the shared CSS as two separate + - `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal, + code copy buttons, and the auth check: it fetches + `/auth/api/validate?perm=pagerite:admin` and only then injects the 🖊️ + edit pens (asset URLs from the `pagerite:editor-src`/`-css` meta tags); + a 401 adds a "log in" link to `/auth/` in the banner corner, a 403 + nothing, and any other result (no auth server, e.g. dev) leaves + editing open. Pages themselves render identically for everyone; the + real gate is the auth proxy in front of all of `/_api`. The backend links the shared CSS as two separate stylesheets (base and theme) so they can be swapped or augmented. - `assets/` — shared styles and data files built by Vite and served hashed under `/_assets/`: `pagerite.css` (base layout + conservative variables), @@ -136,7 +142,8 @@ not for the public pages. See `docs/design-principles.md` for the design. dragged row previews its whole subtree at the target list's depth. The two pens swap the docked panel for the other editor; clicking the open editor's own pen closes it. Normally dynamic-imported onto the content page by - pagerite.js when a 🖊️ edit link is clicked (the link carries + pagerite.js when a 🖊️ edit pen is clicked (the pens are injected by + pagerite.js after the session validates; they carry `data-editor-src`/`data-editor-css`/`data-editor-mode`). In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), in prod from the hashed build assets resolved via @@ -209,7 +216,10 @@ not for the public pages. See `docs/design-principles.md` for the design. folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content. -- No auth in core code; trusted single author. Never add output +- No auth in core code; the SSO/reverse proxy gates all of `/_api` + (forward-auth) and owns `/auth/` (login/logout, session validation). + Pages render identically for everyone; pagerite.js adds the editing UI + only after the auth server validates the session. Never add output sanitization "for safety" against the author — embedded HTML/scripts in Markdown are passed through deliberately. - Update this file and `docs/design-principles.md` when architecture, diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 7d859a3..d9043dc 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -378,6 +378,21 @@ article h1 .edit-link { opacity: 1; } +/* Login link injected by pagerite.js for anonymous visitors when an auth + server gates /_api (validate answered 401). Same corner as the site pen. */ +a.login-link { + position: absolute; + top: 0.6rem; + right: 1.25rem; + z-index: 10; + font-size: 0.85rem; + opacity: 0.7; +} + +a.login-link:hover { + opacity: 1; +} + article p, article li, article dd { diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index 21c9c35..cee20af 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -24,6 +24,70 @@ const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)"); let editorModule = null; + // --- Auth-gated edit pens --------------------------------------------- + // Pages render identically for everyone; the 🖊️ pens are injected by JS + // only after the auth server validates the session (perm pagerite:admin). + // 401 = anonymous: show a small login link in the banner corner instead. + // 403 = logged in without the permission: no pens. Any other outcome + // (404, network error — i.e. no auth server deployed, as in dev) leaves + // editing open as before: the real gate is the proxy in front of /_api. + let authorized = false; + let editorMeta = null; + + function makePen(mode) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = mode === "page" ? "edit-link" : "edit-link banner-edit-link"; + btn.title = "edit"; + btn.textContent = "🖊️"; + btn.dataset.editorSrc = editorMeta.src; + btn.dataset.editorCss = editorMeta.css || ""; + btn.dataset.editorMode = mode; + return btn; + } + + function injectPens() { + const banner = document.getElementById("page-banner"); + if (banner && !banner.parentElement.querySelector(".banner-edit-link")) { + banner.after(makePen("site")); + } + const article = document.querySelector("#main article"); + if (article && !article.querySelector("button.edit-link")) { + article.prepend(makePen("page")); + } + } + + function addLoginLink() { + const banner = document.getElementById("page-banner"); + if (!banner || banner.parentElement.querySelector(".login-link")) return; + const a = document.createElement("a"); + a.className = "login-link"; + a.href = "/auth/"; + a.textContent = "log in"; + banner.after(a); + } + + async function setupAuth() { + const src = document.querySelector('meta[name="pagerite:editor-src"]')?.content; + if (!src) return; + editorMeta = { + src, + css: document.querySelector('meta[name="pagerite:editor-css"]')?.content, + }; + let status = 0; + try { + status = (await fetch("/auth/api/validate?perm=pagerite:admin")).status; + } catch { + // Auth server unreachable: treat as not deployed. + } + if (status === 401) addLoginLink(); + else if (status !== 403) { + authorized = true; + injectPens(); + placeEditPen(); + } + } + function runScripts(root) { // Scripts inserted via DOM swapping do not execute; re-create them. for (const old of root.querySelectorAll("script")) { @@ -86,6 +150,8 @@ (window.requestIdleCallback || setTimeout)(preload); const main = document.getElementById("main"); addCopyButtons(main); + // Fetch-navigation swaps #main, discarding the article pen; re-add it. + if (authorized) injectPens(); placeEditPen(); // Multi-column layout only when there is enough text to justify it. // Split the body into columned segments: h2s and wide figures are @@ -276,7 +342,8 @@ if (url.origin !== location.origin) return; // Same-page anchor links (footnotes etc.): let the browser handle them if (url.pathname === location.pathname && url.hash) return; - if (url.pathname.startsWith("/_")) return; + // Machinery and auth endpoints are never fetch-navigated. + if (url.pathname.startsWith("/_") || url.pathname.startsWith("/auth")) return; ev.preventDefault(); load(url); }); @@ -319,5 +386,6 @@ toggleTask(checkbox, index); }); + setupAuth(); applyEffects(); })(); diff --git a/pagerite/app.py b/pagerite/app.py index f459e6e..d1306b4 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -163,12 +163,6 @@ class PageIn(BaseModel): banner: str | None = None # None keeps the existing banner -@app.get("/_api/health") -async def health_check() -> dict[str, str]: - """Return backend status for health monitoring.""" - return {"status": "ok"} - - @app.get("/_api/pages") async def list_pages() -> list[dict]: """The site tree for the structure editor (all nodes, drafts included). diff --git a/pagerite/views.py b/pagerite/views.py index d9e2ed3..beb8cd6 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -96,6 +96,13 @@ def _layout( doc = Document(E.Title, lang="en") if theme: doc.meta(name="pagerite:theme", content=theme) + # Editor asset URLs for pagerite.js, which injects the 🖊️ edit pens + # itself once it has validated the session (pages render identically + # for everyone; editing is gated by the auth proxy in front of /_api). + script, editor_css = _editor_assets(theme) + doc.meta(name="pagerite:editor-src", content=script[-1]) + if editor_css: + doc.meta(name="pagerite:editor-css", content=editor_css) for url in urls: doc.link(rel="stylesheet", href=url, blocking="render") for src in modules: @@ -106,7 +113,6 @@ def _layout( doc .header( E.div(E.Banner, id="page-banner"), - E.BannerEdit, E.Brand, E.nav(E.Nav, id="nav"), id="banner", @@ -231,26 +237,6 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None: return None -def _edit_attrs(path: str, mode: str = "page", theme: str = "") -> dict: - """Attributes for a 🖊️ edit button. - - pagerite.js wires these buttons to dynamic-import the editor app - (data-editor-src, plus any extra styles it needs) and open the docked - editor without leaving the page. mode="page" edits content; mode="site" - (the pen on the banner) edits the banner and site structure. They are - buttons, not links: editing is an action, not a navigation. - """ - script, editor_css = _editor_assets(theme) - return { - "type": "button", - "class": "edit-link" if mode == "page" else "edit-link banner-edit-link", - "title": "edit", - "data-editor-src": script[-1], - "data-editor-css": editor_css or "", - "data-editor-mode": mode, - } - - def page_content(menu: dict[str, Node], path: str) -> HTML: """Render the contents of the #main element for a page.""" node = resolve(menu, path)[-1] @@ -260,8 +246,6 @@ def page_content(menu: dict[str, Node], path: str) -> HTML: # only rendered as h1 when the markdown has none of its own. if not has_h1(node.content or ""): doc.h1(node.title) - # All users are trusted authors for now, so the edit button is public. - doc.button("🖊️", **_edit_attrs(path)) doc.div(HTML(render(node.content or "", path)), class_="body") return HTML(str(doc)) @@ -284,7 +268,6 @@ def render_page( Nav=nav_html(menu, path), Sidebar=sidebar_html(menu, path), Banner=banner_html(menu, path), - BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))), Main=page_content(menu, path), ), ) @@ -308,8 +291,6 @@ def render_category( doc = E.article with doc: doc.h1(title) - # Editing works here too: the pen creates this category's page. - doc.button("🖊️", **_edit_attrs(path, "page", theme)) doc.p( "Pages in this section are listed in the menu on the left." ) @@ -321,7 +302,6 @@ def render_category( Nav=nav_html(menu, path), Sidebar=sidebar_html(menu, path), Banner=banner_html(menu, path), - BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))), Main=HTML(str(doc)), ), ) @@ -338,8 +318,6 @@ def render_not_found( doc = E.article with doc: doc.h1("Not Found") - # Editing works here too: this is how brand new pages get created. - doc.button("🖊️", **_edit_attrs(path, "page", theme)) doc.p(f"No page at /{path}.") scripts, styles = _page_assets(theme) return str( @@ -349,7 +327,6 @@ def render_not_found( Nav=nav_html(menu, path), Sidebar=sidebar_html(menu, path), Banner=banner_html(menu, path), - BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))), Main=HTML(str(doc)), ), ) diff --git a/scripts/devserver.py b/scripts/devserver.py index cef6b49..01426e0 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -1,5 +1,4 @@ #!/usr/bin/env -S uv run -# auto-upgrade@fastapi-vue-setup - remove this if you modify this file """Run Vite development server for Vue app and FastAPI backend with auto-reload.""" import argparse @@ -22,7 +21,7 @@ from devutil import ( DEFAULT_VITE_PORT = 3100 DEFAULT_DEV_PORT = 3200 -HEALTH = "/_api/health?from=devserver.py" +HEALTH = "/?from=devserver.py" async def run_devserver(