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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user