diff --git a/AGENTS.md b/AGENTS.md index 6f95fee..2fe5413 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``` diff --git a/App/config.php b/App/config.php index 65b8fa8..e314a18 100644 --- a/App/config.php +++ b/App/config.php @@ -37,4 +37,9 @@ return [ // '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'], ]; diff --git a/README.md b/README.md index d28ecbc..7d8829c 100644 --- a/README.md +++ b/README.md @@ -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. - **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. +- **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. - **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`. @@ -78,7 +79,7 @@ php novaconium/bin/create-static-page.php blog/my-new-post ## 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) - [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) - [Matomo](http://127.0.0.1:8000/admin/docs/matomo) - [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) - [Project layout](http://127.0.0.1:8000/admin/docs/project-layout) - [Third-party](http://127.0.0.1:8000/admin/docs/third-party) diff --git a/novaconium/ISSUES.md b/novaconium/ISSUES.md index a626710..e39da35 100644 --- a/novaconium/ISSUES.md +++ b/novaconium/ISSUES.md @@ -66,22 +66,24 @@ of the others): Internal search, so easiest right after (or alongside) it. 5. **Media/file manager** — no hard dependency; usable standalone, though best gated behind admin login once that exists. -6. **Draft pages (admin-only preview)** — no hard dependency; the admin - authentication it reuses already shipped (see `/admin/docs/admin-auth`). -7. **Syntax highlighting on code blocks** — no hard dependency on the +6. **Syntax highlighting on code blocks** — no hard dependency on the copy button (see Done — shipped 2026-07-14), but touches the same `
` 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.
-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
    session handling (cart) it needed are both done.
-10. **Paywall functionality** — needs everything Ecommerce needs, plus
-    Ecommerce itself for the recurring-billing/payment-gateway plumbing;
-    build after it rather than in parallel.
+9. **Paywall functionality** — needs everything Ecommerce needs, plus
+   Ecommerce itself for the recurring-billing/payment-gateway plumbing;
+   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
-2026-07-14 — see Done.
+MySQL support, Session handling (with flash sessions), and Draft pages
+(admin-only preview) all shipped 2026-07-14 — see Done.
 
 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,
 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
 
 - **Type:** Feature
@@ -331,6 +301,47 @@ _Nothing yet._
 
 ## 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)
 
 - **Type:** Feature
diff --git a/novaconium/bootstrap.php b/novaconium/bootstrap.php
index 2114359..f523edd 100644
--- a/novaconium/bootstrap.php
+++ b/novaconium/bootstrap.php
@@ -64,7 +64,8 @@ $route = $router->resolve($requestUri);
 // moment it exists; nothing to remember to wire up. No-op (open access)
 // when admin_password_hash is empty, which is the default. See
 // 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']);
 }
 
@@ -79,14 +80,32 @@ $adminAuthEnabled = $config['admin_password_hash'] !== '';
 $cache = new Cache($config['cache_dir']);
 $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
 // (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
 // any), resolves the nearest layout, renders Twig, and writes the static
-// cache for sidecar-less pages. See novaconium/src/Renderer.php.
-if (!$route->found) {
+// cache for sidecar-less pages — except for drafts and $isAdminRoute (see
+// 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);
     return;
 }
 
-$renderer->render($route, $requestUri);
+$renderer->render($route, $requestUri, $isDraftRoute || $isAdminRoute);
diff --git a/novaconium/config.php b/novaconium/config.php
index b9607f0..4268ee1 100644
--- a/novaconium/config.php
+++ b/novaconium/config.php
@@ -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' => [],
 ];
diff --git a/novaconium/pages/admin/docs/_layout/layout.twig b/novaconium/pages/admin/docs/_layout/layout.twig
index 8e1d59e..d47470f 100644
--- a/novaconium/pages/admin/docs/_layout/layout.twig
+++ b/novaconium/pages/admin/docs/_layout/layout.twig
@@ -16,6 +16,7 @@
                 
  • {{ icons.book() }}Database
  • {{ icons.book() }}Session
  • {{ icons.lock() }}Admin authentication
  • +
  • {{ icons.lock() }}Draft pages
  • {{ icons.book() }}Layouts
  • {{ icons.book() }}Static caching
  • {{ icons.book() }}SEO
  • diff --git a/novaconium/pages/admin/docs/admin-auth/index.twig b/novaconium/pages/admin/docs/admin-auth/index.twig index 9639303..7e15484 100644 --- a/novaconium/pages/admin/docs/admin-auth/index.twig +++ b/novaconium/pages/admin/docs/admin-auth/index.twig @@ -38,6 +38,8 @@ return [

    novaconium/src/AdminAuth.php is a single reusable check — AdminAuth::requireLogin($username, $passwordHash) — called once from novaconium/bootstrap.php for any resolved route whose path is admin or starts with admin/, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in bootstrap.php rather than on each page, a new admin page needs zero extra wiring to be protected — dropping a new directory under App/pages/admin/ or novaconium/pages/admin/ is automatically gated the moment it exists.

    +

    The credential check itself is a separate method, AdminAuth::isAuthenticated($username, $passwordHash)requireLogin() is just that check plus the 401-challenge response on failure. {{ icons.lock() }}Draft pages reuse isAuthenticated() directly with a different failure response (a plain 404, not a login prompt), rather than duplicating the credential logic.

    +

    Logging out

    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 /admin/logout works around this: AdminAuth::logout() always issues a fresh 401 challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time /admin 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 /admin when admin_auth_enabled is true (i.e. a password is actually set).

    diff --git a/novaconium/pages/admin/docs/caching/index.twig b/novaconium/pages/admin/docs/caching/index.twig index af67f41..365df3a 100644 --- a/novaconium/pages/admin/docs/caching/index.twig +++ b/novaconium/pages/admin/docs/caching/index.twig @@ -1,5 +1,7 @@ {% extends 'admin/docs/_layout/layout.twig' %} +{% import '_layout/icons.twig' as icons %} + {% block title %}Static caching{% endblock %} {% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %} @@ -11,6 +13,8 @@

    If a page has no sidecar, its rendered HTML is written to public/cache/<path>/index.html after the first request. .htaccess 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.

    +

    Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every {{ icons.lock() }}draft page and every /admin/* route. Both are gated by {{ icons.lock() }}HTTP Basic Auth, and a cached copy would bypass that check entirely — .htaccess 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 {{ icons.lock() }}Draft pages for the full write-up.

    +

    To force a single page to re-render, delete its file under public/cache/. To clear everything at once, there are two equivalent options: