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).
|
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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
@@ -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,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(
|
||||||
|
|||||||
Reference in New Issue
Block a user