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:
@@ -218,6 +218,45 @@ caching/memoization to `Session` that assumes static state survives
|
||||
between requests, since none of it does. See `/admin/docs/session` for a
|
||||
worked flash example.
|
||||
|
||||
**Standing rule: any mechanism that conditionally hides page content from
|
||||
the public must also be threaded into `Renderer::render()`'s
|
||||
`$excludeFromCache` decision, not just a pre-render auth gate.** This
|
||||
bit twice already — once as a designed-around gotcha (draft pages), once
|
||||
as a real pre-existing bug found while testing that feature (`/admin/*`
|
||||
itself). The reason: `Renderer::render()` writes a sidecar-less page's
|
||||
output to the static HTML cache (`novaconium/src/Cache.php`), and
|
||||
`.htaccess` serves a cached file *before PHP, and therefore any auth
|
||||
check, ever runs again* (see `/admin/docs/caching`). A route can be
|
||||
gated by `AdminAuth::requireLogin()`/`::isAuthenticated()` and still leak
|
||||
completely to the public the moment it's viewed once by someone
|
||||
authorized, if the page has no sidecar and nothing tells `Renderer` to
|
||||
skip the cache write for that route. `draft_routes` (see
|
||||
`/admin/docs/drafts`, `novaconium/config.php`) and every `/admin/*` route
|
||||
both pass `true` for `Renderer::render()`'s `$excludeFromCache` param
|
||||
from `novaconium/bootstrap.php` for exactly this reason — most pages
|
||||
under `novaconium/pages/admin/` (e.g. `admin/index.twig`) have no
|
||||
sidecar, so before this was wired up, visiting `/admin` once as an
|
||||
authenticated admin would cache the admin panel and serve it to every
|
||||
subsequent visitor, unauthenticated, straight from `public/cache/admin/`.
|
||||
Any future feature that gates a route by anything other than a sidecar
|
||||
check (paywall content is the next one on the roadmap likely to hit this)
|
||||
needs to make the same check here, not just at the point where the
|
||||
request is first authorized.
|
||||
|
||||
`AdminAuth::isAuthenticated(string $username, string $passwordHash): bool`
|
||||
(`novaconium/src/AdminAuth.php`) is the credential check on its own, with
|
||||
no response side effects, extracted out of `requireLogin()` (which still
|
||||
does the same check, then issues the `401` challenge on failure) so a
|
||||
different caller can react to failure differently. The draft-page gate in
|
||||
`bootstrap.php` is the first such caller: on failure it renders a plain
|
||||
404 via the same path an unmatched route takes, not a login prompt —
|
||||
prompting for credentials at a draft URL would itself reveal that
|
||||
something is gated there, which defeats the point of hiding it. Returns
|
||||
`true` (open access) when `$passwordHash` is empty, mirroring
|
||||
`requireLogin()`'s existing no-op-when-unset posture, so a draft behaves
|
||||
consistently with the rest of `/admin/*`: wide open until a password is
|
||||
configured, gated once one is.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user