Media and Page Management

Media management

Browsing

GET /_admin/media lists media in a thumbnail grid. It supports case-insensitive filename substring search, a media-type filter, and a folder filter, with pagination defaulting to 60 items per page. The grid shows a used-by count per asset, derived from a reverse-reference index that maps each asset to the pages referencing it (empty for unreferenced assets). A no-JS picker fallback is controlled by return_to and insert_field query parameters; return_to must start with /_admin/pages/ and may not contain .. or backslashes, which guards against open redirects.

Upload

Upload via the upload modal (GET /_admin/_modals/media-upload); files post to POST /_admin/media/upload as multipart, with per-file validation:

  • Size limit — admin.media.max_upload_mb (default 20 MB), enforced as a streaming byte count; a file exceeding the limit yields a per-file error.
  • MIME prefix allow-list — the claimed Content-Type must start with one of admin.media.allowed_mime_prefixes (default image/, application/pdf, video/mp4, audio/mpeg), otherwise the upload is rejected.
  • Magic-byte check — the file’s leading bytes are sniffed (JPEG, PNG, GIF, WebP, PDF, MP4, MP3) and must match the claimed type at the major-type level (for example, image/* must carry image magic bytes).
  • Duplicate detection — uploads are Blake3-hashed against the content index; if a duplicate already exists, the upload returns the existing URL instead of writing a new file.

Uploaded files land under the pattern in admin.media.upload_folder_pattern (default uploads/{YYYY}-{MM}/), where {YYYY} and {MM} are replaced with the current UTC year and month. A successful upload is recorded in the audit log with the asset URL.

Rename with reference rewriting

POST /_admin/media-rename/{path} renames an asset and rewrites every markdown reference to it across all pages. The rename renames the file first, scans all pages for references to the old URL, and rewrites each one, returning a count of references updated and pages modified. Rewriting is boundary-aware: a URL is only replaced when it is not a prefix of a longer URL (the character following the match must not be a URL-continuation character such as a letter, digit, dot, dash, underscore, or slash). The audit log records the rename with the reference count and both the old and new URLs.

Delete with used-by check

POST /_admin/media-delete/{path} first checks whether any pages reference the asset. If references exist and force is not set, the delete is refused and the confirmation flow lists the referencing pages. The operator can confirm with force=true to delete anyway, which leaves broken references behind. The audit log records the delete with the asset URL.

The media detail drawer (GET /_admin/media/{path}) surfaces the same used-by list so you can see what depends on an asset before acting.

Page management

Create, move, and delete all enforce a session check and return 403 Forbidden under the Publisher server role. After any successful modification the content index cache is invalidated so the change is reflected immediately.

Create

The create form (GET /_admin/pages/new) accepts a model, a title, an optional path, and an initial status (Draft or Published, defaulting to Draft). The title is required; if the path is left blank it is auto-derived from the title slug. The chosen status overrides the model’s defaults.status, so a new page never silently inherits published from its model — the normal authoring flow is to create a draft, fill in the fields in the editor, then publish through the workflow. Submitting posts to POST /_admin/pages, which validates the title is non-empty and checks for a path collision, re-rendering the form with an inline error if either fails. Creating directly as published runs the same required-field publish gate the Approve transition enforces: if any required field is missing the create is refused with an inline error and nothing is written, so the create path can never land an invalid published page. On success it redirects (303) into the editor at /_admin/pages{new_path}. The audit log records a create_page entry with the page path and model name. Create is forbidden for the Publisher role.

Move

Move is posted to POST /_admin/pages/move/{path} with a new_path form field; the same modal is available from the list row menu and from the editor’s Move button (GET /_admin/_modals/page-move). The handler checks that the source and destination differ, that both paths are valid (no .. or . segments), and that the destination does not already exist — a collision returns 409 Conflict with the modal still open. The move rewrites markdown references to the old URL during the operation, and the audit log records a move_page entry with the references-updated count and the from/to paths. From the editor you land back in the editor at the new URL; from the list you stay on the list.

Delete

Delete is posted to POST /_admin/pages/delete/{path}; the confirm modal (GET /_admin/_modals/page-delete) shows the reference count, child count, and page-local-media count via cheap read-only lookups. The handler validates the URL and that the page exists, then enforces two blocks:

  • Children block — a page with sub-pages is refused (409 Conflict) with a modal listing the child URLs; move or delete them first.
  • Local-media block — a page that owns page-local media is refused (409 Conflict); remove the assets through the media library first.

Otherwise, references to the deleted page from other pages are rewritten to a missing-link placeholder so the original URL is recoverable from the next index reload. On success the handler redirects to the page list with a flash message showing the deletion status and the references-rewritten count, and the audit log records a delete_page entry.