Prepare for forward-auth; edit buttons and functions conditional on server 401, login button if needed.

This commit is contained in:
2026-08-17 03:58:59 +00:00
parent 7db08ea821
commit dcd1e5afc9
6 changed files with 106 additions and 43 deletions
+14 -4
View File
@@ -87,8 +87,14 @@ not for the public pages. See `docs/design-principles.md` for the design.
database (never overwrites existing pages). database (never overwrites existing pages).
- `frontend/src/` — the Vue editor and public-page entries. - `frontend/src/` — the Vue editor and public-page entries.
- `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor. - `main.js` — Vue editor app entry, mounts PageEditor/SiteEditor.
- `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal and - `pagerite.js` — public page entry; runs fetch-navigation, scroll-reveal,
code copy buttons. The backend links the shared CSS as two separate 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. stylesheets (base and theme) so they can be swapped or augmented.
- `assets/` — shared styles and data files built by Vite and served hashed - `assets/` — shared styles and data files built by Vite and served hashed
under `/_assets/`: `pagerite.css` (base layout + conservative variables), 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 dragged row previews its whole subtree at the target list's depth. The
two pens swap the docked 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 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`). `data-editor-src`/`data-editor-css`/`data-editor-mode`).
In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`), In dev, modules load from the Vite dev server (`PAGERITE_VITE_URL`),
in prod from the hashed build assets resolved via 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 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 derived from its title), may not begin with `_` or `.`, and such URLs are
never looked up as content. 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 sanitization "for safety" against the author — embedded HTML/scripts in
Markdown are passed through deliberately. Markdown are passed through deliberately.
- Update this file and `docs/design-principles.md` when architecture, - Update this file and `docs/design-principles.md` when architecture,
+15
View File
@@ -378,6 +378,21 @@ article h1 .edit-link {
opacity: 1; 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 p,
article li, article li,
article dd { article dd {
+69 -1
View File
@@ -24,6 +24,70 @@
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)"); const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)");
let editorModule = null; 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) { function runScripts(root) {
// Scripts inserted via DOM swapping do not execute; re-create them. // Scripts inserted via DOM swapping do not execute; re-create them.
for (const old of root.querySelectorAll("script")) { for (const old of root.querySelectorAll("script")) {
@@ -86,6 +150,8 @@
(window.requestIdleCallback || setTimeout)(preload); (window.requestIdleCallback || setTimeout)(preload);
const main = document.getElementById("main"); const main = document.getElementById("main");
addCopyButtons(main); addCopyButtons(main);
// Fetch-navigation swaps #main, discarding the article pen; re-add it.
if (authorized) injectPens();
placeEditPen(); placeEditPen();
// Multi-column layout only when there is enough text to justify it. // Multi-column layout only when there is enough text to justify it.
// Split the body into columned segments: h2s and wide figures are // Split the body into columned segments: h2s and wide figures are
@@ -276,7 +342,8 @@
if (url.origin !== location.origin) return; if (url.origin !== location.origin) return;
// Same-page anchor links (footnotes etc.): let the browser handle them // Same-page anchor links (footnotes etc.): let the browser handle them
if (url.pathname === location.pathname && url.hash) return; 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(); ev.preventDefault();
load(url); load(url);
}); });
@@ -319,5 +386,6 @@
toggleTask(checkbox, index); toggleTask(checkbox, index);
}); });
setupAuth();
applyEffects(); applyEffects();
})(); })();
-6
View File
@@ -163,12 +163,6 @@ class PageIn(BaseModel):
banner: str | None = None # None keeps the existing banner 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") @app.get("/_api/pages")
async def list_pages() -> list[dict]: async def list_pages() -> list[dict]:
"""The site tree for the structure editor (all nodes, drafts included). """The site tree for the structure editor (all nodes, drafts included).
+7 -30
View File
@@ -96,6 +96,13 @@ def _layout(
doc = Document(E.Title, lang="en") doc = Document(E.Title, lang="en")
if theme: if theme:
doc.meta(name="pagerite:theme", content=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: for url in urls:
doc.link(rel="stylesheet", href=url, blocking="render") doc.link(rel="stylesheet", href=url, blocking="render")
for src in modules: for src in modules:
@@ -106,7 +113,6 @@ def _layout(
doc doc
.header( .header(
E.div(E.Banner, id="page-banner"), E.div(E.Banner, id="page-banner"),
E.BannerEdit,
E.Brand, E.Brand,
E.nav(E.Nav, id="nav"), E.nav(E.Nav, id="nav"),
id="banner", id="banner",
@@ -231,26 +237,6 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
return 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: def page_content(menu: dict[str, Node], path: str) -> HTML:
"""Render the contents of the #main element for a page.""" """Render the contents of the #main element for a page."""
node = resolve(menu, path)[-1] 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. # only rendered as h1 when the markdown has none of its own.
if not has_h1(node.content or ""): if not has_h1(node.content or ""):
doc.h1(node.title) 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") doc.div(HTML(render(node.content or "", path)), class_="body")
return HTML(str(doc)) return HTML(str(doc))
@@ -284,7 +268,6 @@ def render_page(
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path), Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path), Banner=banner_html(menu, path),
BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))),
Main=page_content(menu, path), Main=page_content(menu, path),
), ),
) )
@@ -308,8 +291,6 @@ def render_category(
doc = E.article doc = E.article
with doc: with doc:
doc.h1(title) doc.h1(title)
# Editing works here too: the pen creates this category's page.
doc.button("🖊️", **_edit_attrs(path, "page", theme))
doc.p( doc.p(
"Pages in this section are listed in the menu on the left." "Pages in this section are listed in the menu on the left."
) )
@@ -321,7 +302,6 @@ def render_category(
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path), Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path), Banner=banner_html(menu, path),
BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))),
Main=HTML(str(doc)), Main=HTML(str(doc)),
), ),
) )
@@ -338,8 +318,6 @@ def render_not_found(
doc = E.article doc = E.article
with doc: with doc:
doc.h1("Not Found") 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}.") doc.p(f"No page at /{path}.")
scripts, styles = _page_assets(theme) scripts, styles = _page_assets(theme)
return str( return str(
@@ -349,7 +327,6 @@ def render_not_found(
Nav=nav_html(menu, path), Nav=nav_html(menu, path),
Sidebar=sidebar_html(menu, path), Sidebar=sidebar_html(menu, path),
Banner=banner_html(menu, path), Banner=banner_html(menu, path),
BannerEdit=HTML(str(E.button("🖊️", **_edit_attrs(path, "site", theme)))),
Main=HTML(str(doc)), Main=HTML(str(doc)),
), ),
) )
+1 -2
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env -S uv run #!/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.""" """Run Vite development server for Vue app and FastAPI backend with auto-reload."""
import argparse import argparse
@@ -22,7 +21,7 @@ from devutil import (
DEFAULT_VITE_PORT = 3100 DEFAULT_VITE_PORT = 3100
DEFAULT_DEV_PORT = 3200 DEFAULT_DEV_PORT = 3200
HEALTH = "/_api/health?from=devserver.py" HEALTH = "/?from=devserver.py"
async def run_devserver( async def run_devserver(