Admin login & user management (novaconium/ISSUES.md): session-based
login against a SQLite users table replaces the single-user HTTP Basic
Auth stopgap (admin_username/admin_password_hash and /admin/password-hash
are gone; one admin_auth_enabled flag, off by default with zero DB
footprint). New /admin/login, /admin/logout (POST-only, real page), and
/admin/users pages plus bin/create-admin-user.php.
First user created is the admin; everyone after is registered with a
unique normalized email and an optional group. /admin/* and drafts are
admin-only; Lib\Access gates page content from sidecars
(Access::require('group:members')) with login-redirect/404 responses —
public by default, static pages always public by construction. User
management covers disable/enable, delete, promote/demote, group, email,
and password, with last-active-admin lockout guards.
Also: Session::regenerate() against fixation, friendly missing-PDO-driver
errors in Lib\Db, docs at /admin/docs/access-control and updates across
admin-auth/drafts/sidecars/config/libraries and README/AGENTS.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
262 lines
23 KiB
Twig
262 lines
23 KiB
Twig
{% extends 'admin/docs/_layout/layout.twig' %}
|
|
|
|
{% block title %}Sidecars{% endblock %}
|
|
|
|
{% block description %}Where your PHP logic goes — the optional index.php sidecar.{% endblock %}
|
|
|
|
{% block robots %}noindex, nofollow{% endblock %}
|
|
|
|
{% block docs_content %}
|
|
<h1>Sidecars — where your PHP logic goes</h1>
|
|
|
|
<p>Drop an <code>index.php</code> next to any <code>index.twig</code> and it becomes that page's data provider. It runs before the template and can return one of two things:</p>
|
|
|
|
<h2>An array — becomes the Twig context</h2>
|
|
|
|
<pre><code><?php
|
|
// App/pages/contact/index.php
|
|
use Lib\Input;
|
|
use Lib\Mailer;
|
|
|
|
$errors = [];
|
|
$old = ['name' => '', 'email' => '', 'message' => ''];
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
// ...validate Input::post() into $old/$errors...
|
|
|
|
if (!$errors) {
|
|
(new Mailer())->send($old['name'], $old['email'], $old['message']);
|
|
return \App\Response::redirect('/contact?sent=1');
|
|
}
|
|
}
|
|
|
|
return ['errors' => $errors, 'old' => $old];</code></pre>
|
|
|
|
<p><code>$params</code> (the captured route segments, e.g. an <code>[id]</code> directory's <code>id</code>) is already in scope — no need to touch <code>$_GET</code>.</p>
|
|
|
|
<h2>A Response object — short-circuits Twig entirely</h2>
|
|
|
|
<pre><code>use App\Response;
|
|
|
|
Response::redirect('/somewhere');
|
|
Response::json(['ok' => true]);
|
|
Response::xml('<root/>');
|
|
Response::html('<h1>raw</h1>');</code></pre>
|
|
|
|
<p>A sidecar-only page (no <code>index.twig</code> at all) is fine too — e.g. an <code>App/pages/api/posts/index.php</code> returning just <code>Response::json([...])</code> for a JSON-only endpoint. Twig never runs for that route at all; the sidecar's return value is the entire response.</p>
|
|
|
|
<p><strong>Form handling</strong> — a sidecar can branch on <code>$_SERVER['REQUEST_METHOD']</code>, validate <code>$_POST</code>, call a library, and only return <code>Response::redirect()</code> once it succeeds (the classic POST/redirect/GET pattern, so a page refresh doesn't resubmit the form). See <code>App/pages/contact/index.php</code> + <code>App/pages/contact/index.twig</code> for the full example — it validates name/email/message, calls <code>Lib\Mailer::send()</code>, and redirects to <code>/contact?sent=1</code> to show a success message.</p>
|
|
|
|
<h2>Form security</h2>
|
|
|
|
<p>Every form on this site combines three independent layers, all reusable <code>Lib\</code> classes: input cleaning, CSRF protection, and spam prevention. None of them depend on each other — a form can use any subset.</p>
|
|
|
|
<h3>Input cleaning</h3>
|
|
|
|
<p><strong><code>Lib\Input</code></strong> (<code>novaconium/lib/Input.php</code>) is a drop-in replacement for reading <code>$_POST</code>/<code>$_GET</code> directly — every sidecar on this site uses it instead of touching the superglobals:</p>
|
|
|
|
<pre><code>use Lib\Input;
|
|
|
|
$name = Input::post('name', ''); // trimmed, tags stripped, null bytes removed
|
|
$sent = Input::get('sent') !== null; // was ?sent= present at all?
|
|
$all = Input::post(); // the whole cleaned $_POST array</code></pre>
|
|
|
|
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
|
|
|
|
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ '{{ }}' }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries. <a href="/admin/docs/database">Lib\Db</a>'s <code>query()</code> method uses PDO prepared statements exclusively for exactly this reason. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
|
|
|
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed or verified, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later (or failing a login whose password actually matches). See the password fields in <code>novaconium/pages/admin/users/index.php</code> and <code>novaconium/pages/admin/login/index.php</code> for the places this framework does that on purpose.</p>
|
|
|
|
<p>A sidecar is also where a page gets assigned to a user or group: one <code>Lib\Access</code> call at the top, returning its <code>Response</code> when access is denied — see <a href="/admin/docs/access-control">Access control</a>.</p>
|
|
|
|
<h3>CSRF protection</h3>
|
|
|
|
<p><strong><code>Lib\Csrf</code></strong> (<code>novaconium/lib/Csrf.php</code>) is a standalone session-token CSRF guard — standalone meaning it isn't wired into <code>FormValidator</code>'s chain, so a sidecar calls it directly, typically as the very first check on a POST:</p>
|
|
|
|
<pre><code>use Lib\Csrf;
|
|
use Lib\Input;
|
|
|
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
|
return \App\Response::redirect('/contact?error=security');
|
|
}</code></pre>
|
|
|
|
<p>with a matching hidden field alongside the honeypot/timestamp fields:</p>
|
|
|
|
<pre><code><input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}"></code></pre>
|
|
|
|
<p>and two extra keys in the sidecar's returned context:</p>
|
|
|
|
<pre><code>'csrfField' => Csrf::fieldName(),
|
|
'csrfToken' => Csrf::token(),</code></pre>
|
|
|
|
<p><code>Csrf::token()</code> is idempotent per session — it doesn't rotate on every call, so a form that re-renders after a validation error still verifies correctly on the next submit. It's the first thing in the framework that starts a native PHP session, and only lazily: a page that never calls <code>Csrf</code> never gets a session cookie, so the rest of the site stays session-free. The session cookie itself is hardened (<code>httponly</code>, <code>SameSite=Lax</code>, <code>secure</code> when the request is HTTPS) inside <code>Csrf</code>'s private <code>ensureSession()</code>.</p>
|
|
|
|
<p>A failed CSRF check shows a real, visible error — <strong>"Your session expired before submitting — please try again."</strong> — unlike a failed spam check, which redirects to the same success URL either way. That's deliberate: a CSRF failure is usually a legitimate stale-tab case for a real visitor, not something worth hiding, whereas hiding a spam block from a bot is the whole point of that check.</p>
|
|
|
|
<h3>Spam prevention</h3>
|
|
|
|
<p>The contact form fends off basic spam without an external CAPTCHA service (no CDN script, no site key/secret key, no outbound API call on every submission — consistent with this project's self-hosted-everything approach), using <strong><code>Lib\SpamGuard</code></strong> (<code>novaconium/lib/SpamGuard.php</code>) — a framework-default <code>Lib\</code> class, reusable on any form a project adds, the same way <code>Lib\Mailer</code> is:</p>
|
|
|
|
<pre><code>use Lib\Input;
|
|
use Lib\SpamGuard;
|
|
|
|
$spamGuard = new SpamGuard(); // honeypot field 'website', timestamp field 'rendered_at', 2s minimum
|
|
|
|
if (!$spamGuard->isSpam(Input::post())) {
|
|
// ...actually send the message...
|
|
}
|
|
|
|
// In the sidecar's returned context, for the hidden timestamp field:
|
|
'renderedAt' => $spamGuard->renderedAt(),</code></pre>
|
|
|
|
<p>Two checks run inside <code>isSpam()</code>, both purely server-side:</p>
|
|
|
|
<ul>
|
|
<li><strong>Honeypot field.</strong> The paired Twig template renders a <code>website</code> input inside a <code>.hp-field</code> wrapper, positioned off-screen with CSS (<code>position: absolute; left: -9999px</code> — deliberately <em>not</em> <code>display: none</code> or <code>visibility: hidden</code>, since some spam bots specifically skip fields hidden that way) and marked <code>aria-hidden="true"</code> with <code>tabindex="-1"</code> so it's invisible to real visitors and screen readers alike. A bot that auto-fills every input on a form fills this one too; any non-empty value counts as spam.</li>
|
|
<li><strong>Timing check.</strong> A hidden field (rendered via <code>$spamGuard->renderedAt()</code>, read back as <code>rendered_at</code>) carries the Unix timestamp of when the form was rendered. On submit, anything completed in under the constructor's <code>$minSeconds</code> (2 by default) counts as spam — plausible for a script filling and submitting a form instantly, implausible for a human reading the form and typing a message. This value isn't cryptographically signed, so a determined bot could forge it; it's a deterrent against unsophisticated spam, not a security boundary.</li>
|
|
</ul>
|
|
|
|
<p><code>isSpam()</code> tripping either check doesn't stop the sidecar from validating and redirecting normally — see <code>App/pages/contact/index.php</code>, which still returns <code>Response::redirect('/contact?sent=1')</code> regardless, and only skips <code>Lib\Mailer::send()</code> when spam is detected. That's deliberate: a bot gets the exact same success response a human would, with nothing revealing which check it tripped, or that a check exists at all. Both the honeypot field name, timestamp field name, and minimum seconds are constructor arguments (<code>new SpamGuard('website', 'rendered_at', 2)</code>), so a second form on the same site can use different field names without the two forms interfering with each other.</p>
|
|
|
|
<p>Field validation (required fields, email format, length limits) is its own reusable class, <strong><code>Lib\FormValidator</code></strong> (<code>novaconium/lib/FormValidator.php</code>) — an accumulating validator so a sidecar doesn't hand-roll the same checks and <code>$errors</code> array every time:</p>
|
|
|
|
<pre><code>use Lib\FormValidator;
|
|
|
|
$validator = (new FormValidator())
|
|
->required($old['name'], 'name', 'Name is required.')
|
|
->email($old['email'], 'email', 'A valid email is required.')
|
|
->required($old['message'], 'message', 'Message is required.')
|
|
->maxLength($old['message'], 'message', 2000, 'Message is too long.');
|
|
|
|
if ($validator->passes()) {
|
|
// ...
|
|
}
|
|
|
|
$errors = $validator->errors();</code></pre>
|
|
|
|
<p><code>FormValidator</code> doesn't implement validation logic itself — each check delegates to <strong><code>Lib\Validate</code></strong> (<code>novaconium/lib/Validate.php</code>), a set of stateless, static validation primitives modeled after <a href="https://github.com/nickyeoman/php-validation-class">the project author's own reusable validation class</a>: <code>clean()</code>, <code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code> (e.g. a "confirm email" field), <code>isPhone()</code> (7- or 10-digit, with optional extension), and <code>isPostalCode()</code>/<code>isZipCode()</code>. Call <code>Validate</code> directly from a sidecar when you just need a validated/normalized value back — e.g. <code>Validate::isPhone($old['phone'], withExtension: true)</code> — rather than an accumulated field-error:</p>
|
|
|
|
<pre><code>use Lib\Validate;
|
|
|
|
$phone = Validate::isPhone($old['phone']); // '5551234567' or false</code></pre>
|
|
|
|
<p>All three classes live under <code>novaconium/lib/</code> as framework defaults — like any other <code>Lib\</code> class, a project can override any of them by dropping a same-named file in <code>App/lib/</code> (see <a href="/admin/docs/libraries">Libraries</a>).</p>
|
|
|
|
<h2>Copy-paste starter: a sidecar, four ways</h2>
|
|
|
|
<p>Drop this in as <code>App/pages/example/index.php</code> (next to an <code>App/pages/example/index.twig</code> using the <a href="/admin/docs/seo">SEO starter template</a>) and delete whichever example you don't need:</p>
|
|
|
|
<pre><code>{% verbatim %}<?php
|
|
// App/pages/example/index.php
|
|
|
|
// 1. Say hello — becomes Twig context, so {{ message }} works in index.twig.
|
|
return [
|
|
'message' => 'Hello, World!',
|
|
];
|
|
|
|
// 2. Show the visitor's IP — same idea, just another key in the array.
|
|
// return [
|
|
// 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
|
// ];
|
|
|
|
// 3. Run phpinfo() — a Response short-circuits Twig entirely, which
|
|
// phpinfo() needs since it echoes its own complete HTML page. Capture
|
|
// that output with ob_start()/ob_get_clean() and hand it to
|
|
// Response::html() rather than letting phpinfo() echo directly (which
|
|
// would print before whatever index.twig renders, out of order).
|
|
// use App\Response;
|
|
//
|
|
// ob_start();
|
|
// phpinfo();
|
|
// return Response::html(ob_get_clean());
|
|
|
|
// 4. Require a login — gate the page to a group, a user, or any
|
|
// logged-in account before doing anything else. Access::require()
|
|
// returns null when the visitor may proceed, or a ready-made Response
|
|
// (a login redirect that comes back here afterwards, or a 404 for the
|
|
// wrong account) for you to return as-is. Needs admin_auth_enabled and
|
|
// at least one user — see /admin/docs/access-control for the rules and
|
|
// /admin/docs/admin-auth for accounts and groups.
|
|
// use Lib\Access;
|
|
//
|
|
// if ($denied = Access::require('group:members')) {
|
|
// return $denied;
|
|
// }
|
|
//
|
|
// return [
|
|
// 'message' => 'Hello, member!',
|
|
// ];{% endverbatim %}</code></pre>
|
|
|
|
<p>Only one of the numbered <code>return</code>s in a real sidecar ever runs, obviously — pick one, or branch between them with an <code>if</code>. The IP example works with or without a sidecar-only page (no <code>index.twig</code>); the <code>phpinfo()</code> example needs <em>no</em> <code>index.twig</code> at all, since <code>Response::html()</code> bypasses Twig — see the JSON-only example above for another sidecar-only page. The login gate in example 4 isn't really an alternative to the other three — it's a first line that composes with any of them: gate first, then return whatever the page normally would. Swap the rule for <code>Access::require('user:bob')</code>, several rules (any one grants access), or no rules at all for "anyone logged in"; admins always pass.</p>
|
|
|
|
<p><strong>Never ship <code>phpinfo()</code> to production</strong> — it dumps environment variables, file paths, loaded extensions, and configuration values that are useful to an attacker mapping your server. Delete the page after you're done with it, or at minimum gate it behind <a href="/admin/docs/admin-auth">admin authentication</a> the same way <code>/admin/*</code> already is, so it's never reachable by the public.</p>
|
|
|
|
<h2>For developers: using <code>Response.php</code> directly</h2>
|
|
|
|
<p><code>novaconium/src/Response.php</code> is the small value object behind <code>Response::redirect()</code>/<code>::json()</code>/<code>::xml()</code>/<code>::html()</code> above. Like <code>Route</code> (see <code>/admin/docs/routing</code>'s "How <code>Route.php</code> fits in" section), it's a dumb data carrier with one job: describe a response, without emitting anything itself, so it can be constructed in a sidecar and only actually acted on later, by <code>Renderer</code>.</p>
|
|
|
|
<h3>How it fits in</h3>
|
|
|
|
<p>Its constructor is <code>private</code> — you never call <code>new Response(...)</code> directly, only one of the four named factories, each of which fixes the <code>type</code>/<code>headers</code> that go with that kind of response:</p>
|
|
|
|
<pre><code>Response::redirect(string $url, int $status = 301): self // type 'redirect', no extra headers
|
|
Response::json(mixed $data, int $status = 200): self // type 'json', Content-Type: application/json
|
|
Response::xml(string $xml, int $status = 200): self // type 'xml', Content-Type: application/xml
|
|
Response::html(string $html, int $status = 200): self // type 'html', Content-Type: text/html</code></pre>
|
|
|
|
<p>Every factory returns a fully-formed, readonly <code>Response</code> — <code>type</code>, <code>body</code>, <code>status</code>, <code>headers</code> — and nothing has happened yet. A sidecar just returns that object; it's <code>Renderer::render()</code> (see this page's <code>Renderer.php</code> section above) that checks <code>$result instanceof Response</code> and, if so, calls <code>$result->emit()</code> and stops, skipping Twig entirely. That's the entire contract between a sidecar and the framework: return an array for Twig context, or return a <code>Response</code> to bypass it.</p>
|
|
|
|
<h3>What <code>emit()</code> actually does</h3>
|
|
|
|
<p><code>emit()</code> is the one method with side effects, and it's only ever called from inside <code>Renderer</code>, never from a sidecar itself:</p>
|
|
|
|
<ol>
|
|
<li>Sets the HTTP status code via <code>http_response_code($this->status)</code>.</li>
|
|
<li>Sends each header in <code>$this->headers</code> (empty for <code>redirect</code>, a single <code>Content-Type</code> for the other three).</li>
|
|
<li>For a <code>redirect</code>, sends a <code>Location</code> header (the URL passed to <code>Response::redirect()</code>) and returns — no body.</li>
|
|
<li>For <code>json</code>, encodes <code>$this->body</code> with <code>json_encode(..., JSON_THROW_ON_ERROR)</code> and echoes it — the <code>JSON_THROW_ON_ERROR</code> flag means an unencodable value (e.g. a resource, or a value containing invalid UTF-8) throws a <code>JsonException</code> rather than silently emitting <code>false</code> as the body.</li>
|
|
<li>For <code>xml</code>/<code>html</code>, <code>$this->body</code> is already a string (built by the caller), so it's echoed as-is — <code>Response</code> doesn't validate or escape it.</li>
|
|
</ol>
|
|
|
|
<p>Because every property is <code>readonly</code> and the type/body/status/headers are fixed at construction, a <code>Response</code> is safe to build early in a sidecar and pass around (or return immediately) without worrying about it changing shape before <code>Renderer</code> gets to it — there's no setter to call by mistake.</p>
|
|
|
|
<h2>For developers: using <code>Renderer.php</code> directly</h2>
|
|
|
|
<p><code>novaconium/src/Renderer.php</code> is the class that actually runs a sidecar and turns its return value into a response — everything in this page so far (arrays becoming Twig context, <code>Response</code> objects short-circuiting) is <code>Renderer</code>'s doing. Unlike <code>Router</code> (see <code>/admin/docs/routing</code>'s "For developers" section), it isn't a pure lookup: <code>render()</code> and <code>renderNotFound()</code> both emit directly — <code>http_response_code()</code>, <code>header()</code>, <code>echo</code> — rather than returning a string, so it's a class you call once per request for its side effects, not one you inspect a return value from.</p>
|
|
|
|
<h3>How it fits in</h3>
|
|
|
|
<p><code>Renderer</code> is the last thing a request touches, in <code>novaconium/bootstrap.php</code>: <code>Router::resolve()</code> produces a <code>Route</code>, <code>AdminAuth</code> optionally gates it, and then <code>Renderer</code> reads that same <code>Route</code> to actually produce output. It never talks back to <code>Router</code> or <code>AdminAuth</code> — by the time it runs, routing and auth are already decided, and its only inputs are the <code>Route</code> and the raw request URI:</p>
|
|
|
|
<pre><code>$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
|
|
|
|
if (!$route->found) {
|
|
$renderer->renderNotFound($requestUri);
|
|
return;
|
|
}
|
|
|
|
$renderer->render($route, $requestUri);</code></pre>
|
|
|
|
<h3>Constructing it</h3>
|
|
|
|
<p>The first two constructor arguments are the same <code>pagesDirs</code> override-root list every other class here takes, plus a <code>Cache</code> instance (<code>novaconium/src/Cache.php</code>) it writes sidecar-less pages' output to. The remaining four — admin-auth-enabled flag, Matomo URL/site ID, site name — aren't used for routing or rendering logic at all; they're registered as Twig globals (<code>$this->twig->addGlobal(...)</code>) purely so every template can read <code>matomo_url</code>, <code>site_name</code>, etc. without a sidecar having to pass them through manually. A fifth global, <code>is_404</code>, defaults to <code>false</code> here and is overridden per-render — see below.</p>
|
|
|
|
<h3>What <code>render()</code> actually does, in order</h3>
|
|
|
|
<ol>
|
|
<li>Looks for <code>index.php</code> at <code>$route->dir</code> via <code>Overlay::findFile()</code> (checking <code>App/pages/</code> before <code>novaconium/pages/</code>, same as everywhere else) and, if one exists, requires it in a scope where <code>$params</code> and <code>$cache</code> are already defined — see <code>runSidecar()</code>.</li>
|
|
<li>If the sidecar returned a <code>Response</code>, calls <code>$result->emit()</code> and returns immediately — Twig never runs, nothing gets cached.</li>
|
|
<li>Otherwise treats the return value as Twig context, merging in <code>params</code>, the resolved <code>layout</code> path, and <code>request_path</code>.</li>
|
|
<li>Renders <code>index.twig</code> at <code>$route->dir</code> with that context, emits <code>200</code> + the HTML.</li>
|
|
<li>Only if there was <strong>no</strong> sidecar, writes the rendered HTML to the static cache (<code>Cache::write()</code>) — a page with a sidecar is never cached this way, since its output can vary per request.</li>
|
|
</ol>
|
|
|
|
<p><code>renderNotFound()</code> is a smaller version of the same idea: no sidecar to run, always emits <code>404</code>, renders <code>404/index.twig</code> if one exists in either page root (with <code>is_404</code> forced to <code>true</code> in that render's context — see <code>/admin/docs/matomo</code> for what that flag is used for), and falls back to a bare <code>404 Not Found</code> string if even the default 404 template is missing.</p>
|
|
|
|
<h3>Layout resolution</h3>
|
|
|
|
<p>Both methods get their <code>layout</code> value from the private <code>relativeLayoutPath()</code>, which walks upward from the matched directory looking for the nearest <code>_layout/layout.twig</code> — checking both page roots at every level before going up one more directory — until it either finds one or runs out of directory to walk (returning <code>null</code>, which only happens if even the root <code>_layout/layout.twig</code> is missing from both roots). This is what lets <code>App/pages/blog/_layout/layout.twig</code> override the site-wide layout for just that subtree, per <a href="/admin/docs/layouts">Layouts</a>.</p>
|
|
|
|
<p>Because <code>render()</code>/<code>renderNotFound()</code> write straight to PHP's output buffer and response headers rather than returning anything, testing <code>Renderer</code> in isolation means capturing output (e.g. <code>ob_start()</code>) and inspecting headers, rather than asserting on a return value the way you can with <code>Router::resolve()</code>'s <code>Route</code>.</p>
|
|
{% endblock %}
|