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).
- `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,
+15
View File
@@ -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 {
+69 -1
View File
@@ -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();
})();
-6
View File
@@ -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).
+7 -30
View File
@@ -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)),
),
)
+1 -2
View File
@@ -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(