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
+39
View File
@@ -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 between requests, since none of it does. See `/admin/docs/session` for a
worked flash example. 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 ## Running it
``` ```
+5
View File
@@ -37,4 +37,9 @@ return [
// 'migrations_dir' => __DIR__ . '/migrations/legacy', // optional // 'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
// ], // ],
// ], // ],
// Docs: /admin/docs/drafts — requires admin_password_hash above to be
// set to actually gate anything; open access otherwise, same as the
// rest of /admin/*.
// 'draft_routes' => ['blog/upcoming-post'],
]; ];
+3 -1
View File
@@ -13,6 +13,7 @@ A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages
- **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks. - **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.
- **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default. - **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default.
- **Admin authentication** — gate every `/admin/*` route behind HTTP Basic Auth by setting `admin_username`/`admin_password_hash` in `App/config.php`; reusable for any admin page a project adds later, with a `/admin/logout` link to clear cached credentials and a built-in `/admin/password-hash` form so generating the hash doesn't require the CLI. Off by default. - **Admin authentication** — gate every `/admin/*` route behind HTTP Basic Auth by setting `admin_username`/`admin_password_hash` in `App/config.php`; reusable for any admin page a project adds later, with a `/admin/logout` link to clear cached credentials and a built-in `/admin/password-hash` form so generating the hash doesn't require the CLI. Off by default.
- **Draft pages** — list a route under `draft_routes` in `App/config.php` to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt. Reuses the admin auth check directly, and is excluded from static caching so a cached copy can't leak the draft to the public. See `/admin/docs/drafts`.
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else. - **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form. - **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`. - **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, and `/admin/password-hash`.
@@ -78,7 +79,7 @@ php novaconium/bin/create-static-page.php blog/my-new-post
## Documentation ## Documentation
The full framework documentation — routing, sidecars, libraries, database, session, layouts, static caching, SEO, Matomo analytics, admin authentication, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights: The full framework documentation — routing, sidecars, libraries, database, session, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started) - [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
- [Routing](http://127.0.0.1:8000/admin/docs/routing) - [Routing](http://127.0.0.1:8000/admin/docs/routing)
@@ -91,6 +92,7 @@ The full framework documentation — routing, sidecars, libraries, database, ses
- [SEO](http://127.0.0.1:8000/admin/docs/seo) - [SEO](http://127.0.0.1:8000/admin/docs/seo)
- [Matomo](http://127.0.0.1:8000/admin/docs/matomo) - [Matomo](http://127.0.0.1:8000/admin/docs/matomo)
- [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth) - [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth)
- [Draft pages](http://127.0.0.1:8000/admin/docs/drafts)
- [Styling](http://127.0.0.1:8000/admin/docs/styling) - [Styling](http://127.0.0.1:8000/admin/docs/styling)
- [Project layout](http://127.0.0.1:8000/admin/docs/project-layout) - [Project layout](http://127.0.0.1:8000/admin/docs/project-layout)
- [Third-party](http://127.0.0.1:8000/admin/docs/third-party) - [Third-party](http://127.0.0.1:8000/admin/docs/third-party)
+52 -41
View File
@@ -66,22 +66,24 @@ of the others):
Internal search, so easiest right after (or alongside) it. Internal search, so easiest right after (or alongside) it.
5. **Media/file manager** — no hard dependency; usable standalone, though 5. **Media/file manager** — no hard dependency; usable standalone, though
best gated behind admin login once that exists. best gated behind admin login once that exists.
6. **Draft pages (admin-only preview)** — no hard dependency; the admin 6. **Syntax highlighting on code blocks** — no hard dependency on the
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
7. **Syntax highlighting on code blocks** — no hard dependency on the
copy button (see Done — shipped 2026-07-14), but touches the same copy button (see Done — shipped 2026-07-14), but touches the same
`<pre><code>` markup it did. `<pre><code>` markup it did.
8. **Admin login & user management** — SQLite groundwork and session 7. **Admin login & user management** — SQLite groundwork and session
handling it needed are both done; ready to build. handling it needed are both done; ready to build.
9. **Ecommerce functionality** — needs admin login & user management 8. **Ecommerce functionality** — needs admin login & user management
(order/product admin, and customer accounts); SQLite groundwork and (order/product admin, and customer accounts); SQLite groundwork and
session handling (cart) it needed are both done. session handling (cart) it needed are both done.
10. **Paywall functionality** — needs everything Ecommerce needs, plus 9. **Paywall functionality** — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing; Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel. build after it rather than in parallel — also now has a concrete
precedent to follow for the "gated content must skip the static cache"
part of its design (see Draft pages (admin-only preview) in Done, and
the caching/auth standing rule in `AGENTS.md`), which was still an open
question when this entry was originally written.
MySQL support and Session handling (with flash sessions) shipped MySQL support, Session handling (with flash sessions), and Draft pages
2026-07-14 — see Done. (admin-only preview) all shipped 2026-07-14 — see Done.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo. See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
@@ -192,38 +194,6 @@ wanted later, in which case that part could ride on SQLite groundwork).
Needs basic safety handling: extension allowlist, filename sanitization, Needs basic safety handling: extension allowlist, filename sanitization,
and a max upload size, since this is a file-write surface. and a max upload size, since this is a file-write surface.
### Draft pages (admin-only preview)
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Added:** 2026-07-13
Let a page under `App/pages/` (or `novaconium/pages/`) be written and
previewed by an admin without being visible to the public — a draft blog
post, an in-progress redesign of a page, etc. Likely a config-driven list
(e.g. `'draft_routes' => ['blog/upcoming-post']` in `App/config.php`) or a
per-sidecar flag (`return ['draft' => true, ...]`), checked in
`novaconium/bootstrap.php` right alongside the existing `/admin/*` gate —
reusing `AdminAuth::requireLogin()` (see `/admin/docs/admin-auth`) rather
than inventing a second auth mechanism: not logged in → 404 (not a login
prompt, so a draft's existence isn't revealed to anyone poking at the
URL), logged in → renders normally.
**The gotcha to design around from day one:** sidecar-less pages get
written to the static HTML cache and served by `.htaccess` *before PHP
ever runs* (see `/admin/docs/caching`) — if a draft page took that path,
the cached file would be world-readable the moment an admin previewed it
once, completely bypassing the auth check for anyone hitting the same URL
afterward. So a draft page must either always go through a sidecar (never
cached, checked via the config/flag above) or `Renderer`/`Cache` need an
explicit "never write this route to the cache" exception. Whichever
approach ships, add a line to `/admin/docs/caching` and `AGENTS.md`
calling this out, the same way the `mb_substr`/`|slice` gotcha got a
standing-rule note — it's exactly the kind of interaction between two
independently-reasonable features that's easy to get wrong once and
should only need explaining once.
### Syntax highlighting on code blocks ### Syntax highlighting on code blocks
- **Type:** Feature - **Type:** Feature
@@ -331,6 +301,47 @@ _Nothing yet._
## Done ## Done
### Draft pages (admin-only preview)
- **Type:** Feature
- **Status:** Done
- **Priority:** Medium
- **Added:** 2026-07-13
- **Shipped:** 2026-07-14
Let a page under `App/pages/` be written and previewed by an admin without
being visible to the public — a config-driven list, `draft_routes` in
`App/config.php` (`Route::$dir`-format entries, e.g. `'blog/upcoming-post'`),
checked in `novaconium/bootstrap.php` right alongside the existing
`/admin/*` gate. Reuses `AdminAuth::isAuthenticated()` (a new method,
extracted out of `requireLogin()` so the credential check could be reused
with a different failure response) rather than a second auth mechanism:
not authenticated → the same plain 404 an unmatched route gets (not a
login prompt, so a draft's existence isn't revealed to anyone poking at
the URL), authenticated → renders normally. No separate login flow needed
— an admin authenticates once at `/admin`, and the browser then resends
those same Basic Auth credentials to draft URLs automatically, since
they're scoped to the whole origin/realm.
The gotcha flagged when this entry was written was designed around
correctly: sidecar-less pages get written to the static HTML cache and
served by `.htaccess` before PHP ever runs, so a draft without its own
sidecar needed an explicit exclusion, not just the auth gate —
`Renderer::render()` gained an `$excludeFromCache` param for this.
**A second, real instance of the same bug was found while testing this
feature, pre-dating it entirely:** `/admin` itself
(`novaconium/pages/admin/index.twig`) has no sidecar, so it was already
being written to the static cache — meaning once any admin visited
`/admin` once, the admin panel was served to every subsequent visitor,
unauthenticated, straight from `public/cache/admin/`, completely
bypassing `AdminAuth`. Fixed in the same change by passing
`$excludeFromCache = true` for every `/admin/*` route too, not just
drafts. Documented as a standing rule in `AGENTS.md` (next to the
`mb_substr`/`|slice` and `|escape('js')` notes) and at
`/admin/docs/drafts`/`/admin/docs/caching`: any future mechanism that
conditionally hides page content from the public has to make the same
check, not just gate the initial request.
### Session handling (with flash sessions) ### Session handling (with flash sessions)
- **Type:** Feature - **Type:** Feature
+23 -4
View File
@@ -64,7 +64,8 @@ $route = $router->resolve($requestUri);
// moment it exists; nothing to remember to wire up. No-op (open access) // moment it exists; nothing to remember to wire up. No-op (open access)
// when admin_password_hash is empty, which is the default. See // when admin_password_hash is empty, which is the default. See
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth. // novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
if ($route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'))) { $isAdminRoute = $route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'));
if ($isAdminRoute) {
AdminAuth::requireLogin($config['admin_username'], $config['admin_password_hash']); AdminAuth::requireLogin($config['admin_username'], $config['admin_password_hash']);
} }
@@ -79,14 +80,32 @@ $adminAuthEnabled = $config['admin_password_hash'] !== '';
$cache = new Cache($config['cache_dir']); $cache = new Cache($config['cache_dir']);
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']); $renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
// A route listed in draft_routes is only visible to an authenticated admin
// — anyone else gets treated exactly like a route that doesn't exist at
// all (a plain 404, not a login prompt), so a draft's existence isn't
// revealed to anyone poking at the URL. See /admin/docs/drafts. Reuses the
// same credential check /admin/* uses (AdminAuth::isAuthenticated()) —
// in practice an admin authenticates by visiting /admin once first; the
// browser then resends those same Basic Auth credentials to draft URLs
// too, since they share the same origin/realm.
$isDraftRoute = $route->found && in_array($route->dir, $config['draft_routes'], true);
// $route->found is false for anything Router couldn't match to a real page // $route->found is false for anything Router couldn't match to a real page
// (no index.twig or index.php at the resolved directory) — render the 404 // (no index.twig or index.php at the resolved directory) — render the 404
// page and stop. Otherwise render the matched page: runs its sidecar (if // page and stop. Otherwise render the matched page: runs its sidecar (if
// any), resolves the nearest layout, renders Twig, and writes the static // any), resolves the nearest layout, renders Twig, and writes the static
// cache for sidecar-less pages. See novaconium/src/Renderer.php. // cache for sidecar-less pages — except for drafts and $isAdminRoute (see
if (!$route->found) { // Renderer::render()'s $excludeFromCache param). Every /admin/* route is
// excluded from the cache for the same reason a draft is: a sidecar-less
// admin page (e.g. novaconium/pages/admin/index.twig) would otherwise get
// written to public/cache/ as plain HTML the first time an authenticated
// admin visited it, and .htaccess serves a cached file before PHP (and
// therefore AdminAuth::requireLogin()) ever runs again — permanently
// serving the admin panel to anyone, unauthenticated, straight from the
// static cache. See novaconium/src/Renderer.php.
if (!$route->found || ($isDraftRoute && !AdminAuth::isAuthenticated($config['admin_username'], $config['admin_password_hash']))) {
$renderer->renderNotFound($requestUri); $renderer->renderNotFound($requestUri);
return; return;
} }
$renderer->render($route, $requestUri); $renderer->render($route, $requestUri, $isDraftRoute || $isAdminRoute);
+12
View File
@@ -61,4 +61,16 @@ return [
'migrations_dir' => __DIR__ . '/../App/migrations', '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' => [],
]; ];
@@ -16,6 +16,7 @@
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a></li> <li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a></li>
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li> <li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li> <li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li> <li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li> <li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li> <li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
@@ -38,6 +38,8 @@ return [
<p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p> <p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p>
<p>The credential check itself is a separate method, <code>AdminAuth::isAuthenticated($username, $passwordHash)</code> — <code>requireLogin()</code> is just that check plus the <code>401</code>-challenge response on failure. <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> reuse <code>isAuthenticated()</code> directly with a different failure response (a plain <code>404</code>, not a login prompt), rather than duplicating the credential logic.</p>
<h2>Logging out</h2> <h2>Logging out</h2>
<p>HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p> <p>HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p>
@@ -1,5 +1,7 @@
{% extends 'admin/docs/_layout/layout.twig' %} {% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Static caching{% endblock %} {% block title %}Static caching{% endblock %}
{% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %} {% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %}
@@ -11,6 +13,8 @@
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/&lt;path&gt;/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p> <p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/&lt;path&gt;/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
<p>Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft page</a> and every <code>/admin/*</code> route. Both are gated by <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}HTTP Basic Auth</a>, and a cached copy would bypass that check entirely — <code>.htaccess</code> serves a cached file before PHP (and therefore any auth check) ever runs, so a cached admin or draft page would be served to anyone, unauthenticated, forever after the first authenticated view. See <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> for the full write-up.</p>
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p> <p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
<ul> <ul>
@@ -9,7 +9,7 @@
{% block docs_content %} {% block docs_content %}
<h1>Configuration</h1> <h1>Configuration</h1>
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p> <p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code>, <code>db_connections</code>, <code>draft_routes</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p> <p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
@@ -39,6 +39,10 @@ return [
<p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p> <p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p>
<h2>Draft pages</h2>
<p><code>draft_routes</code> (default <code>[]</code>) is a list of routes only an authenticated admin can see — everyone else gets a plain <code>404</code>. Requires <code>admin_password_hash</code> above to be set to actually gate anything. See <a href="/admin/docs/drafts">Draft pages</a> for the full write-up, including why a cached draft page would be a security problem and how that's avoided.</p>
<h2>For developers: using <code>Cache.php</code> directly</h2> <h2>For developers: using <code>Cache.php</code> directly</h2>
<p><code>novaconium/src/Cache.php</code> is the class behind the <code>cache_dir</code> config key above — a small, dependency-free wrapper around writing/deleting the static HTML files under <code>public/cache/</code> that <a href="/admin/docs/caching">Static caching</a> describes. Like <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it's plain and easy to reason about in isolation: no Twig, no request state, just a path convention and some filesystem calls.</p> <p><code>novaconium/src/Cache.php</code> is the class behind the <code>cache_dir</code> config key above — a small, dependency-free wrapper around writing/deleting the static HTML files under <code>public/cache/</code> that <a href="/admin/docs/caching">Static caching</a> describes. Like <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it's plain and easy to reason about in isolation: no Twig, no request state, just a path convention and some filesystem calls.</p>
@@ -0,0 +1,37 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Draft pages{% endblock %}
{% block description %}Let an admin preview a page before the public can see it, without a second login mechanism.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Draft pages</h1>
<p>List a page's route under <code>draft_routes</code> in <code>App/config.php</code> to make it visible only to an authenticated admin — anyone else gets a plain <code>404</code>, exactly as if the page didn't exist at all:</p>
<pre><code>&lt;?php
// App/config.php
return [
'admin_username' =&gt; 'admin',
'admin_password_hash' =&gt; '$2y$10$...',
'draft_routes' =&gt; ['blog/upcoming-post'],
];</code></pre>
<p>Each entry matches the same path format <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> resolves internally — no leading slash, directory segments joined with <code>/</code> (e.g. <code>App/pages/blog/upcoming-post/</code> is listed as <code>'blog/upcoming-post'</code>).</p>
<h2>Not a login prompt</h2>
<p>An unauthenticated visitor to a draft route gets the site's normal 404 page — not a <code>401</code> Basic Auth challenge like <code>/admin/*</code> gives. This is deliberate: prompting for a login would itself reveal that something is gated at that URL. A draft is indistinguishable from a URL that was never routable in the first place.</p>
<p>There's no separate login flow for drafts, and none is needed — <code>AdminAuth::isAuthenticated()</code> (the same credential check <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a>'s <code>requireLogin()</code> uses) is reused directly. In practice, an admin authenticates once by visiting <code>/admin</code> and entering credentials there; HTTP Basic Auth credentials are scoped to the whole origin/realm, not a single path, so the browser then resends those same credentials automatically on later requests to a draft URL too, without a second prompt.</p>
<h2>The caching interaction</h2>
<p>Sidecar-less pages normally get pre-rendered once and served as static HTML straight from <code>public/cache/</code> on every later request (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — <code>.htaccess</code> checks for that cached file <strong>before PHP, and therefore any auth check, ever runs</strong>. A draft page without its own sidecar would otherwise take that exact path: the moment an authenticated admin previewed it, the rendered HTML would be written to the cache as a plain file, and every subsequent visitor — authenticated or not — would be served it directly by Apache, permanently bypassing the draft gate.</p>
<p>Draft routes are therefore excluded from the static cache unconditionally, regardless of whether the page has a sidecar — <code>novaconium/bootstrap.php</code> passes this down to <code>Renderer::render()</code>'s <code>$excludeFromCache</code> parameter. Every <code>/admin/*</code> route gets the same exclusion, for the identical reason (most admin pages have no sidecar either).</p>
{% endblock %}
+2 -1
View File
@@ -4,7 +4,7 @@
{% block title %}Docs{% endblock %} {% block title %}Docs{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, layouts, caching, styling.{% endblock %} {% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %} {% block robots %}noindex, nofollow{% endblock %}
@@ -21,6 +21,7 @@
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li> <li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li>
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li> <li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li>
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li> <li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li> <li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li> <li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li> <li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
+26 -12
View File
@@ -23,18 +23,7 @@ final class AdminAuth
*/ */
public static function requireLogin(string $username, string $passwordHash): void public static function requireLogin(string $username, string $passwordHash): void
{ {
if ($passwordHash === '') { if (self::isAuthenticated($username, $passwordHash)) {
return;
}
$providedUser = $_SERVER['PHP_AUTH_USER'] ?? null;
$providedPass = $_SERVER['PHP_AUTH_PW'] ?? null;
if (
$providedUser === $username
&& $providedPass !== null
&& password_verify($providedPass, $passwordHash)
) {
return; return;
} }
@@ -45,6 +34,31 @@ final class AdminAuth
exit; exit;
} }
/**
* The credential check on its own, with no response side effects —
* reused by requireLogin() above (401 challenge on failure) and by
* novaconium/bootstrap.php's draft-page gate (see /admin/docs/drafts),
* which needs a *different* response on failure: a plain 404, not a
* login prompt, so an unauthenticated visitor can't tell a draft
* exists at all. Returns true (open access) when $passwordHash is
* empty, matching requireLogin()'s existing no-op-when-unset posture —
* a draft behaves like the rest of /admin/*: wide open until a
* password is configured, gated once one is.
*/
public static function isAuthenticated(string $username, string $passwordHash): bool
{
if ($passwordHash === '') {
return true;
}
$providedUser = $_SERVER['PHP_AUTH_USER'] ?? null;
$providedPass = $_SERVER['PHP_AUTH_PW'] ?? null;
return $providedUser === $username
&& $providedPass !== null
&& password_verify($providedPass, $passwordHash);
}
/** /**
* HTTP Basic Auth has no real server-side "log out" — the browser just * HTTP Basic Auth has no real server-side "log out" — the browser just
* keeps resending the cached credentials. The standard workaround: always * keeps resending the cached credentials. The standard workaround: always
+14 -2
View File
@@ -42,7 +42,19 @@ final class Renderer
$this->twig->addGlobal('is_404', false); $this->twig->addGlobal('is_404', false);
} }
public function render(Route $route, string $requestUri): void /**
* $excludeFromCache (see novaconium/bootstrap.php) skips the
* static-cache write below unconditionally, regardless of $hasSidecar
* — bootstrap.php passes true for a draft route (see
* /admin/docs/drafts) or any /admin/* route. Either would otherwise
* still take the normal sidecar-less caching path (most pages under
* novaconium/pages/admin/ have no sidecar) and get written to
* public/cache/ as plain, world-readable HTML the first time an
* authenticated admin viewed it — permanently bypassing the auth check
* for anyone hitting that URL afterward, since .htaccess serves a
* cached file before PHP (and therefore any auth check) runs again.
*/
public function render(Route $route, string $requestUri, bool $excludeFromCache = false): void
{ {
$sidecarRel = $this->withFile($route->dir, 'index.php'); $sidecarRel = $this->withFile($route->dir, 'index.php');
$sidecar = Overlay::findFile($this->pagesDirs, $sidecarRel); $sidecar = Overlay::findFile($this->pagesDirs, $sidecarRel);
@@ -66,7 +78,7 @@ final class Renderer
header('Content-Type: text/html; charset=utf-8'); header('Content-Type: text/html; charset=utf-8');
echo $html; echo $html;
if (!$hasSidecar) { if (!$hasSidecar && !$excludeFromCache) {
$this->cache->write($requestUri, $html); $this->cache->write($requestUri, $html);
} }
} }