From 37af433704992b99df36528ac7b820fb7a7e8fde Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 16 Aug 2026 16:25:26 +0000 Subject: [PATCH] Reserve slugs beginning with _ or . Enforced at three layers: the site editor validates slug inputs before committing (rename and new-page rows), the API rejects such paths in _check_reserved (page save/delete, structure moves, editor socket), and the content catch-all 404s them without a tree lookup. --- AGENTS.md | 6 ++++-- docs/design-principles.md | 2 ++ frontend/src/SiteEditor.vue | 15 +++++++++++++++ pagerite/app.py | 20 +++++++++++++------- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee348c7..9bedb41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,8 +161,10 @@ not for the public pages. See `docs/design-principles.md` for the design. - Keep dependencies minimal; add via `uv add` and mention it. - The public URL space belongs to content (pretty slugs at root). Reserve - only `/_/` for the machinery (files, API, built assets, admin); top-level - `_` is a reserved slug. + only `/_/` for the machinery (files, API, built assets, admin), plus + `/favicon.ico` from the build. Slugs may not begin with `_` or `.` — + validated in the site editor, rejected by the API, and such URLs are + never looked up as content when serving. - No auth in core code; trusted single author. Never add output sanitization "for safety" against the author — embedded HTML/scripts in Markdown are passed through deliberately. diff --git a/docs/design-principles.md b/docs/design-principles.md index 0992569..733003a 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -32,6 +32,8 @@ evolves. lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built assets at `/_/assets/`, and the admin shell at `/_/admin`). The only other reserved root path is `/favicon.ico`, served from the build. + Slugs may not begin with `_` or `.` anywhere in the path — such URLs + are never looked up as content. - **Single user, trusted author.** No auth concerns in the core design. Everything published is public; only editing tools will later sit behind access control (external SSO when that time comes). The author is trusted diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index b4c8f23..d69a6c6 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -200,6 +200,10 @@ async function commitPending() { if (!slug) { return } + if (invalidSlug(slug)) { + saveError.value = '⚠️ slugs cannot begin with _ or .' + return // keep the pending row so the slug can be fixed + } const loc = locatePending(tree.value, '') const parentPath = loc?.parentPath ?? '' const newPath = parentPath ? `${parentPath}/${slug}` : slug @@ -394,9 +398,20 @@ function onTitleInput(node, ev) { }) } +// Slugs may not begin with _ or . (reserved for the /_/ machinery and +// dot-paths); enforced on every commit, and again server-side. +function invalidSlug(slug) { + return slug.startsWith('_') || slug.startsWith('.') +} + async function commitSlug(node, ev) { const slug = ev.target.value.trim().replace(/\/+/g, '') if (slug === node.slug) return + if (invalidSlug(slug)) { + saveError.value = '⚠️ slugs cannot begin with _ or .' + ev.target.value = node.slug + return + } const parent = node.path.split('/').slice(0, -1).join('/') // Empty slug at top level = the front page (path ""). const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug diff --git a/pagerite/app.py b/pagerite/app.py index 9af1d94..6e35a13 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -342,14 +342,17 @@ async def delete_page(path: str) -> None: data.version += 1 -def _check_reserved(path: str) -> None: - """Reject slugs under the machinery prefix. +def _is_reserved(path: str) -> bool: + """Slug shape that content may never use: any segment beginning with + "_" or "." ("/_/" is the machinery prefix; dot-segments look like + hidden or filesystem paths).""" + return any(seg.startswith(("_", ".")) for seg in path.split("/")) - The public URL space belongs to content; only "/_/" is reserved for - the machinery (API, files, built assets, admin). - """ - if path.split("/", 1)[0] == "_": - raise HTTPException(400, "reserved path prefix") + +def _check_reserved(path: str) -> None: + """Reject configured slugs beginning with "_" or ".".""" + if _is_reserved(path): + raise HTTPException(400, 'slugs cannot begin with "_" or "."') @app.websocket("/_/api/ws/editor") @@ -494,6 +497,9 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response: first child page in menu order. """ path = path.strip("/") + if path and _is_reserved(path): + # Reserved slug shape: never content — don't even look it up. + return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404) chain = resolve(data.menu, path) node = chain[-1] if chain else None if node is not None and node.published and node.content is not None: