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.
This commit is contained in:
2026-08-16 16:25:26 +00:00
parent 508795437f
commit 37af433704
4 changed files with 34 additions and 9 deletions
+4 -2
View File
@@ -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.
+2
View File
@@ -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
+15
View File
@@ -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
+13 -7
View File
@@ -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: