Slug input filtering, title-derived slugs, reserved file names
Slug inputs in the site editor now filter as you type via slugify.js (built on the transliteration npm package: unicode incl. asian scripts folds to ASCII, spaces become hyphens, anything outside [a-z0-9-] is dropped) instead of erroring on commit. A new page left with an empty slug gets one derived from its title. Server-side, _check_reserved additionally rejects reserved root file names (robots.txt, ads.txt, sitemap.xml, openapi.json, favicon.ico, site.webmanifest) with a human-readable reason, which the editor surfaces in its error line so the row can be edited and committed again; those URLs are also never looked up as content when serving.
This commit is contained in:
@@ -162,9 +162,15 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
|||||||
- Keep dependencies minimal; add via `uv add` and mention it.
|
- Keep dependencies minimal; add via `uv add` and mention it.
|
||||||
- The public URL space belongs to content (pretty slugs at root). Reserve
|
- The public URL space belongs to content (pretty slugs at root). Reserve
|
||||||
only `/_/` for the machinery (files, API, built assets, admin), plus
|
only `/_/` for the machinery (files, API, built assets, admin), plus
|
||||||
`/favicon.ico` from the build. Slugs may not begin with `_` or `.` —
|
`/favicon.ico` from the build. Slugs are lowercase ASCII `[a-z0-9-]`
|
||||||
validated in the site editor, rejected by the API, and such URLs are
|
(the site editor filters input live via `slugify.js`, built on the
|
||||||
never looked up as content when serving.
|
`transliteration` npm package — unicode folds to ASCII, spaces become
|
||||||
|
hyphens; an empty slug on a new page is derived from its title), may
|
||||||
|
not begin with `_` or `.`, and may not be a reserved file name
|
||||||
|
(`robots.txt`, `ads.txt`, `sitemap.xml`, `openapi.json`, `favicon.ico`,
|
||||||
|
`site.webmanifest`). The API rejects such paths with a human-readable
|
||||||
|
reason shown in the editor, and such URLs are never looked up as
|
||||||
|
content when serving.
|
||||||
- No auth in core code; trusted single author. Never add output
|
- No auth in core code; trusted single author. 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.
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ evolves.
|
|||||||
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
|
lives under `/_/` (the API at `/_/api/`, uploaded files at `/_/f/`, built
|
||||||
assets at `/_/assets/`, and the admin shell at `/_/admin`). The only
|
assets at `/_/assets/`, and the admin shell at `/_/admin`). The only
|
||||||
other reserved root path is `/favicon.ico`, served from the build.
|
other reserved root path is `/favicon.ico`, served from the build.
|
||||||
Slugs may not begin with `_` or `.` anywhere in the path — such URLs
|
Slugs are lowercase ASCII (`[a-z0-9-]`; input is transliterated and
|
||||||
are never looked up as content.
|
filtered as you type, and a new page's empty slug is derived from its
|
||||||
|
title), may not begin with `_` or `.`, and may not occupy a reserved
|
||||||
|
root file name (`robots.txt`, `sitemap.xml`, `favicon.ico`, …) — such
|
||||||
|
URLs are never looked up as content.
|
||||||
- **Single user, trusted author.** No auth concerns in the core design.
|
- **Single user, trusted author.** No auth concerns in the core design.
|
||||||
Everything published is public; only editing tools will later sit behind
|
Everything published is public; only editing tools will later sit behind
|
||||||
access control (external SSO when that time comes). The author is trusted
|
access control (external SSO when that time comes). The author is trusted
|
||||||
|
|||||||
Generated
+14
@@ -14,6 +14,7 @@
|
|||||||
"@codemirror/view": "^6.43.8",
|
"@codemirror/view": "^6.43.8",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"transliteration": "^2.6.1",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
@@ -2528,6 +2529,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/transliteration": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-hJ9BhrQAOnNTbpOr1MxsNjZISkn7ppvF5TKUeFmTE1mG4ZPD/XVxF0L0LUoIUCWmQyxH0gJpVtfYLAWf298U9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"slugify": "dist/bin/slugify",
|
||||||
|
"transliterate": "dist/bin/transliterate"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unplugin-utils": {
|
"node_modules/unplugin-utils": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@codemirror/view": "^6.43.8",
|
"@codemirror/view": "^6.43.8",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
|
"transliteration": "^2.6.1",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-19
@@ -17,6 +17,7 @@ import { EditorState, Compartment } from '@codemirror/state'
|
|||||||
import { placeholder } from '@codemirror/view'
|
import { placeholder } from '@codemirror/view'
|
||||||
import { html } from '@codemirror/lang-html'
|
import { html } from '@codemirror/lang-html'
|
||||||
import { cmHighlight, cmTheme } from './cmtheme'
|
import { cmHighlight, cmTheme } from './cmtheme'
|
||||||
|
import { slugify } from './slugify'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pagePath: { type: String, default: '' },
|
pagePath: { type: String, default: '' },
|
||||||
@@ -196,14 +197,11 @@ function discardPending() {
|
|||||||
async function commitPending() {
|
async function commitPending() {
|
||||||
const node = pending.value
|
const node = pending.value
|
||||||
if (!node) return
|
if (!node) return
|
||||||
const slug = node.slug.trim().replace(/\/+/g, '')
|
// Empty slug: derive one from the title (transliterated to ASCII).
|
||||||
|
const slug = slugify(node.slug.trim()) || slugify(node.title)
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
return
|
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 loc = locatePending(tree.value, '')
|
||||||
const parentPath = loc?.parentPath ?? ''
|
const parentPath = loc?.parentPath ?? ''
|
||||||
const newPath = parentPath ? `${parentPath}/${slug}` : slug
|
const newPath = parentPath ? `${parentPath}/${slug}` : slug
|
||||||
@@ -217,7 +215,9 @@ async function commitPending() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
saveError.value = '⚠️ changes could not be saved'
|
// Show the server's reason (e.g. a reserved file name); the pending
|
||||||
|
// row stays so it can be edited and committed again.
|
||||||
|
saveError.value = `⚠️ ${await errorDetail(res)}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Place it exactly where the row was dropped: a fresh order key halfway
|
// Place it exactly where the row was dropped: a fresh order key halfway
|
||||||
@@ -348,6 +348,13 @@ async function refreshPages() {
|
|||||||
} catch { /* list stays stale; not fatal */ }
|
} catch { /* list stays stale; not fatal */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human-readable reason from a failed API call (FastAPI errors carry a
|
||||||
|
// JSON {detail}), falling back to a generic message.
|
||||||
|
async function errorDetail(res) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
return body?.detail || 'changes could not be saved'
|
||||||
|
}
|
||||||
|
|
||||||
async function postStructure(op) {
|
async function postStructure(op) {
|
||||||
const res = await fetch('/_/api/structure', {
|
const res = await fetch('/_/api/structure', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -358,7 +365,7 @@ async function postStructure(op) {
|
|||||||
saveError.value = ''
|
saveError.value = ''
|
||||||
loadPlain(path.value) // refresh menus and content from the server
|
loadPlain(path.value) // refresh menus and content from the server
|
||||||
} else {
|
} else {
|
||||||
saveError.value = '⚠️ changes could not be saved'
|
saveError.value = `⚠️ ${await errorDetail(res)}`
|
||||||
}
|
}
|
||||||
await refreshPages()
|
await refreshPages()
|
||||||
return res.ok
|
return res.ok
|
||||||
@@ -398,20 +405,11 @@ function onTitleInput(node, ev) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slugs may not begin with _ or . (reserved for the /_/ machinery and
|
// The slug inputs are filtered as you type (StructureTree onSlugInput,
|
||||||
// dot-paths); enforced on every commit, and again server-side.
|
// see slugify.js); the server re-validates and its reason is shown.
|
||||||
function invalidSlug(slug) {
|
|
||||||
return slug.startsWith('_') || slug.startsWith('.')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commitSlug(node, ev) {
|
async function commitSlug(node, ev) {
|
||||||
const slug = ev.target.value.trim().replace(/\/+/g, '')
|
const slug = ev.target.value.trim()
|
||||||
if (slug === node.slug) return
|
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('/')
|
const parent = node.path.split('/').slice(0, -1).join('/')
|
||||||
// Empty slug at top level = the front page (path "").
|
// Empty slug at top level = the front page (path "").
|
||||||
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
|
const moveTo = parent ? (slug ? `${parent}/${slug}` : parent) : slug
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
// forwarding.
|
// forwarding.
|
||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
|
import { slugify } from './slugify'
|
||||||
|
|
||||||
defineOptions({ name: 'StructureTree' })
|
defineOptions({ name: 'StructureTree' })
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -27,6 +28,18 @@ const props = defineProps({
|
|||||||
|
|
||||||
const handlers = inject('structureHandlers')
|
const handlers = inject('structureHandlers')
|
||||||
|
|
||||||
|
// Live-filter the slug inputs as they are typed (oninput): invalid
|
||||||
|
// characters are simply not accepted, spaces become hyphens and unicode
|
||||||
|
// folds to ASCII (see slugify.js). Existing rows commit on change, the
|
||||||
|
// pending row is v-modeled.
|
||||||
|
function onSlugInput(ev) {
|
||||||
|
ev.target.value = slugify(ev.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPendingSlugInput(element, ev) {
|
||||||
|
element.slug = slugify(ev.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Focus the title input of a fresh pending row.
|
// Focus the title input of a fresh pending row.
|
||||||
const vFocus = { mounted: (el) => el.focus() }
|
const vFocus = { mounted: (el) => el.focus() }
|
||||||
|
|
||||||
@@ -92,7 +105,8 @@ function onEnd() {
|
|||||||
<input
|
<input
|
||||||
v-model="element.slug"
|
v-model="element.slug"
|
||||||
class="edit slug-edit"
|
class="edit slug-edit"
|
||||||
placeholder="slug"
|
placeholder="slug — empty: derived from the title"
|
||||||
|
@input="onPendingSlugInput(element, $event)"
|
||||||
@keyup.enter="handlers.commitPending()"
|
@keyup.enter="handlers.commitPending()"
|
||||||
@keyup.esc="handlers.discardPending()"
|
@keyup.esc="handlers.discardPending()"
|
||||||
/>
|
/>
|
||||||
@@ -115,6 +129,7 @@ function onEnd() {
|
|||||||
:value="element.slug"
|
:value="element.slug"
|
||||||
placeholder="front page"
|
placeholder="front page"
|
||||||
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
|
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
|
||||||
|
@input="onSlugInput"
|
||||||
@change="handlers.commitSlug(element, $event)"
|
@change="handlers.commitSlug(element, $event)"
|
||||||
/>
|
/>
|
||||||
<span class="acts">
|
<span class="acts">
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Slugs are URL path segments: lowercase ASCII letters, digits, hyphens.
|
||||||
|
// The transliteration package folds unicode (incl. asian scripts) to
|
||||||
|
// ASCII and spaces to hyphens; we then drop everything outside the slug
|
||||||
|
// charset (including the reserved leading "_"/".", slashes, backslashes,
|
||||||
|
// dots).
|
||||||
|
import { slugify as transliterate } from 'transliteration'
|
||||||
|
|
||||||
|
export function slugify(s) {
|
||||||
|
return transliterate(s).replace(/[^a-z0-9-]/g, '').replace(/-{2,}/g, '-')
|
||||||
|
}
|
||||||
+18
-3
@@ -349,10 +349,25 @@ def _is_reserved(path: str) -> bool:
|
|||||||
return any(seg.startswith(("_", ".")) for seg in path.split("/"))
|
return any(seg.startswith(("_", ".")) for seg in path.split("/"))
|
||||||
|
|
||||||
|
|
||||||
|
# Well-known root file names that content may not occupy (they would
|
||||||
|
# shadow real files or carry special meaning to crawlers/clients).
|
||||||
|
RESERVED_FILES = {
|
||||||
|
"robots.txt",
|
||||||
|
"ads.txt",
|
||||||
|
"sitemap.xml",
|
||||||
|
"openapi.json",
|
||||||
|
"favicon.ico",
|
||||||
|
"site.webmanifest",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _check_reserved(path: str) -> None:
|
def _check_reserved(path: str) -> None:
|
||||||
"""Reject configured slugs beginning with "_" or "."."""
|
"""Reject configured slugs beginning with "_" or ".", and reserved
|
||||||
|
file names at the root."""
|
||||||
if _is_reserved(path):
|
if _is_reserved(path):
|
||||||
raise HTTPException(400, 'slugs cannot begin with "_" or "."')
|
raise HTTPException(400, 'slugs cannot begin with "_" or "."')
|
||||||
|
if path in RESERVED_FILES:
|
||||||
|
raise HTTPException(400, f'"{path}" is a reserved file name')
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/_/api/ws/editor")
|
@app.websocket("/_/api/ws/editor")
|
||||||
@@ -497,8 +512,8 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
|||||||
first child page in menu order.
|
first child page in menu order.
|
||||||
"""
|
"""
|
||||||
path = path.strip("/")
|
path = path.strip("/")
|
||||||
if path and _is_reserved(path):
|
if path and (_is_reserved(path) or path in RESERVED_FILES):
|
||||||
# Reserved slug shape: never content — don't even look it up.
|
# Reserved slug shape or file name: never content — no tree lookup.
|
||||||
return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
|
return HTMLResponse(views.render_not_found(data.menu, path, data.brand), 404)
|
||||||
chain = resolve(data.menu, path)
|
chain = resolve(data.menu, path)
|
||||||
node = chain[-1] if chain else None
|
node = chain[-1] if chain else None
|
||||||
|
|||||||
Reference in New Issue
Block a user