Add draft pages (admin-only preview); fix admin panel cache leak

Lets a page under App/pages/ be previewed by an admin before the public
can see it: list its route in draft_routes (App/config.php), checked in
bootstrap.php alongside the existing /admin/* gate. Not authenticated ->
same plain 404 an unmatched route gets, not a login prompt, so a draft's
existence isn't revealed. Authenticated -> renders normally. No separate
login flow needed - Basic Auth credentials are scoped to the whole
origin/realm, so authenticating once at /admin covers draft URLs too.

AdminAuth::isAuthenticated() is extracted out of requireLogin() so the
draft gate can reuse the same credential check with a different failure
response (404 vs. a 401 challenge).

Renderer::render() gains an $excludeFromCache param so a draft without
its own sidecar can't get written to the static HTML cache - .htaccess
serves a cached file before PHP, and therefore any auth check, ever runs
again, so an uncached exception is required, not just the auth gate.

While testing this, found the same bug already existed for /admin itself:
novaconium/pages/admin/index.twig has no sidecar, so it was already being
cached - meaning any admin visiting /admin once caused the panel to be
served to everyone, unauthenticated, straight from
public/cache/admin/. Fixed in this change by excluding every /admin/*
route from the cache the same way, and documented as a standing rule in
AGENTS.md: any future mechanism that conditionally hides page content
from the public has to make the same check, not just gate the initial
request.

Closes the "Draft pages (admin-only preview)" backlog item in
novaconium/ISSUES.md.
This commit is contained in:
code
2026-07-14 16:35:31 +00:00
parent 5deb298b91
commit 4862526fa1
14 changed files with 226 additions and 63 deletions
+12
View File
@@ -61,4 +61,16 @@ return [
'migrations_dir' => __DIR__ . '/../App/migrations',
],
],
// Routes an admin can preview before the public can see them (see
// /admin/docs/drafts) — a list of Route::$dir-format paths, no leading
// slash, e.g. 'blog/upcoming-post'. Not authenticated as admin (per
// AdminAuth::isAuthenticated()) → 404, same as a route that doesn't
// exist at all, so a draft's existence isn't revealed to anyone
// poking at the URL. Authenticated → renders normally, and — critically
// — is never written to the static HTML cache regardless of whether
// the page has a sidecar (see Renderer::render()'s $isDraft param),
// since a world-readable cached copy would otherwise permanently leak
// the draft the first time an admin previewed it.
'draft_routes' => [],
];