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:
@@ -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/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/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/caching">{{ icons.book() }}Static caching</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>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>
|
||||
|
||||
<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' %}
|
||||
|
||||
{% 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 @@
|
||||
|
||||
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/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>
|
||||
|
||||
<ul>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% block docs_content %}
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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><?php
|
||||
// App/config.php
|
||||
return [
|
||||
'admin_username' => 'admin',
|
||||
'admin_password_hash' => '$2y$10$...',
|
||||
'draft_routes' => ['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 %}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -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/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/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/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>
|
||||
|
||||
Reference in New Issue
Block a user