Prepare for forward-auth; edit buttons and functions conditional on server 401, login button if needed.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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,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(
|
||||
|
||||
Reference in New Issue
Block a user