Add Media manager (/admin/media), remove unused images/ scaffolding

Upload/browse/delete UI for files under public/uploads/, covered by the
existing /admin/* auth gate with no separate feature flag needed (no
SQLite dependency to gate). Extension allowlist and max upload size are
configurable; filenames are sanitized and de-duplicated on upload, and
deletes re-verify the resolved path lands inside the upload directory
before touching disk. Docker gains a fourth-turned-third named volume
for public/uploads/ so uploads survive a rebuild.

images/ (reserved scaffolding for a future image feature) is removed —
nothing ever consumed it, and public/uploads/ now covers that use case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
code
2026-07-15 07:35:45 +00:00
parent 6624c4fdc3
commit a0ae58c29e
15 changed files with 459 additions and 542 deletions
+133 -505
View File
@@ -1,45 +1,31 @@
# AGENTS.md
Context for any coding agent working in this repo — Claude, DeepSeek, or
otherwise; this file (and the maintenance rule below) applies regardless of
which model or CLI is driving. Full narrative docs live at `/admin/docs`
when the app is running (also the *only* place Twig upgrade instructions
live now — see `/admin/docs/upgrading-twig`; there's no separate
MAINTENANCE.md, keeping one copy in the docs page avoids drift). `README.md`
is the GitHub-facing pitch, `novaconium/ISSUES.md` is the roadmap/backlog,
and this file is the short, agent-facing version. The original design
rationale used to live in a standalone `plan.md`; it's now folded into
`/admin/docs/design-notes` (everything in it shipped) and the file was
deleted. There used to also be a
`GUIDE.md` mirroring `/admin/docs` for offline reading — it was removed to
cut a doc copy that had to be kept in sync; `/admin/docs` is the only
narrative reference now.
otherwise. Full narrative docs live at `/admin/docs` when the app is
running. `README.md` is the GitHub-facing pitch, `novaconium/ISSUES.md` is
the roadmap/backlog, and this file is the short, agent-facing version:
load-bearing gotchas and conventions only, not narrative history.
**This repo has a graphify knowledge graph (`graphify-out/`).** For design
rationale, "why was it built this way," or exploring how components relate,
query the graph instead of expecting this file to carry that context — this
file is kept intentionally short and only lists things that will cause a
bug or a broken convention if you don't know them going in.
## Documentation is duplicated on purpose — keep all copies in sync
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
admin authentication, styling, project layout, third-party) exists in
**two** places: a page under `novaconium/pages/admin/docs/<topic>/index.twig`
(the canonical reference), and (for anything a README-reading human needs
up front) a mention in `README.md`. This is intentional — `/admin/docs` is
for reading against a running instance with no internet needed, and
`README.md` is the GitHub-facing pitch — but it means **any agent that
changes framework behavior or adds a feature must update both copies in
the same change**, not just the one that was open. Concretely, after
touching routing/rendering/caching/SEO behavior or adding a new top-level
docs topic:
admin auth, styling, Docker, project layout, third-party) exists in two
places: a page under `novaconium/pages/admin/docs/<topic>/index.twig`
(canonical) and a mention in `README.md`. Any change to framework behavior
or a new feature must update both in the same change:
1. Update (or add) the matching page under
`novaconium/pages/admin/docs/<topic>/index.twig`, and if it's a new
topic, link it from both `admin/docs/index.twig` and the nav in
`admin/docs/_layout/layout.twig`.
2. Update `README.md` if the change affects the feature list, getting
started steps, or the docs index there.
3. Update this file if the change affects a convention an agent needs to
know before editing code (not just narrative docs).
A doc change that only touches one of these copies is incomplete —
verify the other copy before considering the task done.
1. Update/add the docs page, and if new, link it from both
`admin/docs/index.twig` and the nav in `admin/docs/_layout/layout.twig`.
2. Update `README.md` if it affects the feature list, getting-started
steps, or the docs index there.
3. Update this file only if it affects a convention an agent needs to know
before editing code.
## What this is
@@ -50,388 +36,122 @@ pages get pre-rendered to static HTML on first request and served straight
from Apache after that. No Composer, no build step to install — Twig is
vendored as plain source files.
## The two-root split — read this before editing anything under `pages/` or `lib/`
## The two-root split
Everything lives in one of two places:
- **`App/`** — the actual project: `App/pages/` (routes/content) and
`App/lib/` (project's own `Lib\` classes). This is the only directory a
- **`App/`** — the project: `App/pages/`, `App/lib/` (`Lib\` classes),
`App/config.php`, `App/migrations/`, `App/sass/`. The only directory a
site author is expected to touch.
- **`novaconium/`** — the framework itself: router/renderer core
(`novaconium/src/`), default pages (`novaconium/pages/` — root layout,
404, the `/admin` tools), default `Lib\` classes (`novaconium/lib/`),
vendored Twig, autoloader, config, bootstrap.
- **`novaconium/`** — the framework: router/renderer core
(`novaconium/src/`), default pages/libs, vendored Twig, autoloader,
config, bootstrap.
Routing and rendering resolve against **both roots, in order**
`App/pages/` first, `novaconium/pages/` as fallback — via
`novaconium/src/Overlay.php`. Same mechanism for `Lib\` classes:
`App/lib/` is checked before `novaconium/lib/` in `novaconium/autoload.php`.
Concretely: dropping a file at the same relative path in `App/` overrides
the `novaconium/` default; nothing needs to be duplicated for the site to
work, since `novaconium/pages/` already supplies a working layout and 404.
Routing/rendering resolve against **both roots, `App/` first** (via
`novaconium/src/Overlay.php` for pages, `novaconium/autoload.php` for
`Lib\` classes) — same override-by-presence mechanism used for
`config.php`, Twig's `FilesystemLoader`, and Sass (see below). A project
only lists the config keys it's changing in `App/config.php`; never edit
`novaconium/config.php` directly.
Twig's `FilesystemLoader` is constructed with both paths as an array, so
`{% extends %}` / `{% include %}` get this override-then-fallback
resolution for free — no custom logic needed there.
**`db_connections` is the one config key that isn't a plain shallow-merge.**
`Lib\Db::config()` (and the duplicate in `bin/migrate.php`) merges it one
level deeper, by connection name, so adding a second connection in
`App/config.php` can't silently delete the framework's `default`
connection. Capture the defaults *before* the top-level `array_merge()`
overwrites `$config['db_connections']`, not after. See `/admin/docs/database`.
The same override-by-presence pattern applies to `novaconium/config.php`:
if `App/config.php` exists, `novaconium/bootstrap.php` and
`novaconium/bin/clear-cache.php` shallow-merge it over the framework defaults
with `array_merge()`. A project only needs to list the keys it's changing
— never edit `novaconium/config.php` directly.
`Lib\Db` supports multiple, simultaneously-open named connections
(`'sqlite'`/`'mysql'` drivers only). Each connection migrates lazily on
first use, tracked by path **relative to the repo root** (not bare
filename — two roots can share a filename). `migrations_dir` accepts an
ordered list of roots, each fully processed before the next.
`config['matomo_url']` / `config['matomo_site_id']` (both default `''`)
gate the Matomo tracking script emitted by the root layout — set both via
`App/config.php` to enable it, since either being empty disables tracking
entirely. `bootstrap.php` normalizes a missing trailing slash on
`matomo_url` before passing it to `Renderer`, which exposes `matomo_url`,
`matomo_site_id`, and `is_404` as Twig globals (`is_404` is overridden to
`true` in the 404 template's local render context by
`Renderer::renderNotFound()`, per Twig's local-context-over-global
precedence). Any new Twig global added to `Renderer`'s constructor should
follow this same pattern: default value, `addGlobal()` call, documented
here and in `/admin/docs`.
**The default DB path (`data/novaconium.sqlite`) lives outside `public/`
(web-accessible) and `novaconium/`** (wholesale-replaced by framework
updates) — it's a project-owned top-level dir, gitignored per-content with
a tracked `.gitkeep`. Uploaded files (see Media manager,
`/admin/docs/media-manager`) live under `public/uploads/` instead, since
they need to be web-reachable directly — a separate, plain static
directory on its own volume, not coupled to the SQLite path, since a
project may run MySQL or no DB at all.
`config['site_name']` (default `'My Site'`) is the same pattern — passed to
`Renderer` and exposed as the `site_name` Twig global, used by
`novaconium/pages/_layout/layout.twig` for the default `title` block,
`og:site_name`, and the footer copyright line. Any other hardcoded
site-identity string that shows up in a shared template (as opposed to a
per-page override) should become a `config.php` key the same way, not stay
hardcoded in the template.
## Standing rule: caching vs. any content-hiding mechanism
`config['admin_auth_enabled']` (default `false`) gates every `/admin/*`
route behind a session login against the `users` table on `Lib\Db`'s
default connection (`novaconium/migrations/0002_create_users.sql`) — the
multi-user system from `novaconium/ISSUES.md`'s "Admin login & user
management" entry, which **replaced** the old single-user HTTP Basic Auth
stopgap (the `admin_username`/`admin_password_hash` config keys and the
`/admin/password-hash` page are gone; that stopgap had itself replaced
the even older `docs_enabled` flag). Same off-by-default posture as
`content_index_enabled`, for the same reason: it depends on SQLite, so
when the flag is false, `/admin/*` is wide open, the three auth routes
(`/admin/login`, `/admin/logout`, `/admin/users`) 404 as if they didn't
exist, their sidecars check the flag (via the same self-loaded two-step
config read `/search` uses) *before* touching `Lib\Db`, and no
`data/novaconium.sqlite` is ever created by this feature — verified
end-to-end. **Two roles** (`users.role`): the **first user ever created
is `'admin'`; everyone created after is `'registered'`**, each with an
optional single group (`users.user_group`, a plain text label — no
groups table). Admins run the site: `/admin/*`, drafts, and every
`Lib\Access` rule passes for them. Registered users log in at the same
`/admin/login` and see whatever content `Lib\Access` (below) grants
their account or group — but `/admin/*` renders a plain 404 for them
(they're authenticated; what they lack is the role, so bouncing them to
the login form would be wrong). Unlike the Twig-global pattern above,
the gate itself is enforced in `bootstrap.php`, before rendering, in two
steps: `AdminAuth::requireLogin($config['admin_auth_enabled'])`
(`novaconium/src/AdminAuth.php`) redirects anyone not logged in, then
`AdminAuth::isAdmin(...)` 404s logged-in non-admins — for any resolved
route whose path is `admin` or starts with `admin/`, **except
`admin/login` itself, which must stay reachable logged-out or the
redirect to it would loop.** **Any new admin page dropped under
`App/pages/admin/` or `novaconium/pages/admin/` is automatically
protected — no per-page wiring needed.** Bootstrapping the first user:
while the `users` table is empty, the gate deliberately returns open
access so the first user (the admin) can be created at `/admin/users`
(which auto-logs its creator in, closing the gate), mirroring the old
"wide open until a password is configured" posture;
`novaconium/bin/create-admin-user.php` creates an admin from the CLI
instead (password via stdin), which lets a deploy create the user
*before* flipping the flag so the open window never exists — it's also
the lockout-recovery path (usage: `<username> <email>`, password via
stdin). Every account has a **unique email address** (`users.email`),
stored normalized via `Lib\Validate::isEmail()` (trim + lowercase) so
the planned email-verification feature (see `novaconium/ISSUES.md`
Backlog) can match case-insensitively — not used for login (username)
or any mail yet. `/admin/users` is the management UI (create — always
`'registered'` except the first / disable / enable / delete / change
group / promote-demote / change email / change password); a disabled
**or deleted** user fails login and any existing session dies on its
next request (`currentUser()` re-checks the row per request), and
disabling, demoting, *or deleting* the last **active admin** is refused
— the empty-table setup window doesn't reopen once users exist, so that
would be a permanent lockout. Delete is a hard delete (username/email
become reusable); disable is the keep-but-shut-out option. `/admin/login` accepts a
`?return=` path (how `Lib\Access` sends someone back to the gated page
after login), validated to a local path (must start `/`, not `//`, no
`\`) so a crafted login link can't bounce a fresh login to another
site; with no return path, admins land on `/admin`, registered users on
`/`. Login
regenerates the session id (`Lib\Session::regenerate()`, added for this)
against session fixation; logout is a real page now — the pre-router
`/admin/logout` special case in `bootstrap.php` is gone — and it is
**POST-only with a GET confirm form** (same shape as
`/admin/clear-cache`), not logout-on-GET: the content-index crawl runs
every page's sidecar as a GET, so a GET side effect there would end the
crawling admin's own session mid-reindex. Passwords are read from `$_POST` directly, not
`Lib\Input` (the documented exact-value exception — see `Lib\Input`'s
doc-comment, which now points at the login/users sidecars). `Renderer`
still exposes the `admin_auth_enabled` Twig global (now mirroring the
config flag) so `admin/index.twig` can conditionally show the
"Admin users"/"Logout" links — a derived display flag, not the
enforcement mechanism itself, which never depends on Twig.
**Any mechanism that conditionally hides page content from the public
must be threaded into `Renderer::render()`'s `$excludeFromCache` param, not
just a pre-render auth gate.** `Renderer::render()` writes a sidecar-less
page's output to the static HTML cache, and `.htaccess` serves a cached
file *before PHP (and therefore any auth check) ever runs again*. A route
gated only at the auth-check level still leaks to the public the moment an
authorized user views it once, if the page has no sidecar. `draft_routes`
and every `/admin/*` route already pass `true` for this reason. Any new
feature that gates a route by anything other than a sidecar check needs the
same treatment — this has caused a real bug before, twice.
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
so a project can override it via `App/lib/Db.php` like any other `Lib\`
class. It supports multiple, independently-configured, **simultaneously
open** named connections (`config['db_connections']`, keyed by name) rather
than one global connection — because sidecars are plain PHP with full
access to any `Lib\` class, a single request can legitimately need more
than one database at once (e.g. this site's own SQLite data alongside a
MySQL connection to a legacy database). `Db::query(string $sql, array
$params = [], string $connection = 'default')` (prepare+execute) is the
only query-running helper — never add a string-interpolation shortcut; see
`Lib\Input`'s doc-comment, which already commits this project to
parameterized queries as the sole SQL-injection defense. Each connection is
lazy and independent (opened on first `Db::query()`/`Db::connection()` call
naming it, same shape as `Lib\Csrf`'s lazy session start), and migrates
automatically at that point: plain `.sql` files under that connection's own
`migrations_dir` — a single path, or (since the content index shipped) an
**ordered list of roots**, each root's own files filename-ordered and
roots processed fully in the order given, not interleaved by filename
across roots (so a framework root always finishes before a project root on
the same connection). Tracked by path **relative to the repo root** (e.g.
`novaconium/migrations/0001_x.sql`), not bare filename — two roots can
each contain a same-named file, and tracking by bare filename would make
the second one seen look "already applied" and silently skip it; a
repo-relative path is also portable across environments, unlike a full
absolute path, which would make every migration look new again after a
clone/deploy to a different directory. `realpath()` normalizes any `..` a
`migrations_dir` like `__DIR__ . '/../App/migrations'` would otherwise
leave in the tracked name. Each connection's own auto-created
`schema_migrations` table tracks its migrations independently of any other
connection's; each is only ever run once. `novaconium/bin/migrate.php`
loops every configured connection and runs the same migration step
explicitly (e.g. from a deploy script) without serving a request first.
Only `'sqlite'` and `'mysql'` drivers are implemented; a connection's
`migrations_dir` is optional — omit it to never run migrations against
that connection (e.g. a legacy database this project shouldn't manage
schema for).
Corollary: `Lib\Access` (the sidecar-level content gate, see
`/admin/docs/access-control`) is safe by construction — a page with no
sidecar can't call `Access`, and only sidecar-less pages get cached, so a
gated page can never leak through the cache with no extra wiring needed.
**`db_connections` is the one config key in the project that isn't plain
shallow-merge** — `bootstrap.php`/`bin/*.php`'s usual `array_merge($config,
$appConfig)` would let a project's `App/config.php` silently delete the
framework's `default` connection just by adding a second named connection
(a shallow merge replaces the whole key, it doesn't merge inside it). So
`Lib\Db::config()` (and the copy of this logic duplicated in
`bin/migrate.php`, same duplication precedent as the two-step config load
already duplicated across `bootstrap.php`/`bin/clear-cache.php`) merges
`db_connections` one level deeper, by connection name, **after** capturing
the framework defaults — capture the defaults *before* the top-level
`array_merge()` overwrites `$config['db_connections']`, not after, or the
deeper merge silently operates on the already-overwritten value and the
`default` connection vanishes anyway. (This exact bug was hit once while
building this feature — verified by testing a real `App/config.php`
override end-to-end, not just reading the code — so it's worth re-checking
by hand if this logic is ever touched again.) See
`/admin/docs/database` for the worked example.
## Reentrancy hazard: ContentIndexer
**`config['db_connections']['default']['path']` must stay outside both
`public/` (would be web-accessible) and `novaconium/`** — unlike
`cache_dir`/`contact-log.txt`, which are disposable, a SQLite file is data a
project can't afford to lose, and `novaconium/` gets wholesale-replaced by
the "Updating the framework" workflow (`/admin/docs/getting-started`: `rm
-rf novaconium && cp -r <new-novaconium>`). The default
(`data/novaconium.sqlite`) lives in a new top-level `data/` directory
instead — project-owned like `App/`, gitignored per-file
(`*.sqlite`/`-journal`/`-wal`/`-shm`, with a tracked `.gitkeep` so the
directory exists in a fresh clone) rather than wholesale like
`public/cache/`, since a project might reasonably want other non-DB files
there later. The default connection's `migrations_dir` scans
`novaconium/migrations/` (framework-shipped schema, e.g. the content
index below) before `App/migrations/` (project schema) — see the
`migrations_dir` array-form paragraph above. Any *other* connection a
project adds still defaults to no `migrations_dir` at all unless it sets
one; the two-root default is specific to `default`.
`ContentIndexer::reindex()` renders every routable page, including
`/search` itself, which also calls `ContentIndexer::ensureFresh()`.
Guarded by a `private static bool $indexing` flag checked at the top of
both methods — don't remove it, any new consumer route inherits the same
hazard automatically. `reindex()` also forces
`$_SERVER['REQUEST_METHOD']` to `'GET'` for the duration of the crawl
(restored in a `finally`) so a lazy reindex triggered from a POST can't
leak that POST into an unrelated page's sidecar.
A sibling top-level `images/` directory (same gitignore-wholesale-with-
`.gitkeep` treatment as `public/cache/`) exists as **reserved scaffolding
for a future image-upload feature — no config key, no upload code, nothing
reads or writes it yet.** It's deliberately a separate directory from
`data/`, not nested under it, even though both are "project data that
survives a framework update": a project may run MySQL, or no database at
all, so coupling image storage to the SQLite volume would be wrong. See
`novaconium/pages/admin/docs/docker/index.twig` for how the Docker Compose
setup gives it its own volume. Don't add an `images_dir` config key until
an actual feature reads it — an unused key would misrepresent the
framework's state, the same reasoning that keeps every other config key
tied to real consuming code.
## Vendored dependency placement
`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around
native PHP sessions (`session_start()`/`$_SESSION`, not a custom store),
all-static and lazy-start like `Lib\Csrf` — nothing calls `session_start()`
until the first real call to a `Session` method. Its `ensureSession()` is a
**deliberate duplicate** of `Csrf::ensureSession()` (same cookie params,
same `session_status()` guard) rather than a shared helper — keeps `Csrf`
standalone with zero new dependencies on a class that didn't exist when it
shipped, same tolerance for small duplication already established by the
config-load block duplicated across `bootstrap.php`/`bin/clear-cache.php`/
`Lib\Db::config()`. Both classes touching the same native session in the
same request is safe either way, since `session_start()` silently no-ops
if a session is already active — there's no ordering requirement between
`Csrf::token()`/`::verify()` and any `Session` method.
**Server-side-only (PHP, autoloaded) → `novaconium/vendor/`. Anything a
browser fetches (`.js`, `.css`, images) → `public/vendor/`** — `novaconium/`
is never web-reachable. This matters beyond correctness: `public/` is
project-owned and untouched by a framework update, so a `public/vendor/`
dependency bump does **not** propagate automatically the way a
`novaconium/vendor/` bump would — re-vendoring is a manual step per
dependency (see `/admin/docs/upgrading-highlightjs`).
Flash data (`Session::flash()`/`::getFlash()`) is one swap, not a
sweep/expiry pass: the first `Session` method call in a request snapshots
whatever was flashed on the *previous* request into an in-memory static
(`self::$currentFlash`) for that request's `getFlash()` reads, then
immediately empties the stored flash bucket so `flash()` calls made
*during* the current request start filling a fresh bucket for the request
after this one. This relies on static properties not persisting across
requests (true under `php -S`, mod_php, and PHP-FPM alike — each request
gets fresh PHP state regardless of worker-process reuse) — don't add any
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.
## Twig gotchas that will fatal without `mbstring`
**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.
Don't use `|slice` on a **string** (calls `mb_substr()` unconditionally) or
`|escape('js')`/`'js'` arg to `|e` (calls `mb_ord()`) — both hard-require
`mbstring` and fatal without it; this project deliberately avoids that
dependency. Truncate strings in PHP with an `mb_substr`/`substr` fallback
instead. For markup destined for inline `<script>`, render into a
`<template>` element and read `.innerHTML` in JS rather than
`|escape('js')`.
`AdminAuth::isAdmin(bool $enabled): bool` / `::isLoggedIn(bool
$enabled): bool` (`novaconium/src/AdminAuth.php`) are the access checks
on their own, with no response side effects — `requireLogin()` is
`isLoggedIn()` plus a 303 redirect to `/admin/login` on failure, and a
different caller can react to failure differently. The draft-page gate
in `bootstrap.php` is the first such caller, and it uses `isAdmin()`
(drafts are admin-only — a logged-in registered user gets the same 404
as an anonymous visitor): on failure it renders a plain 404 via the same
path an unmatched route takes, not a login redirect — bouncing to a
login at a draft URL would itself reveal that something is gated there,
which defeats the point of hiding it. Both return `true` (open access)
when `$enabled` is false or while the `users` table is empty, mirroring
`requireLogin()`'s posture, so a draft behaves consistently with the
rest of `/admin/*`: wide open until the feature is enabled and a first
user exists, gated after that.
`class="nohighlight"` marks a `<pre><code>` block containing literal Twig
syntax (`{% %}`/`{{ }}`) — highlight.js has no Twig grammar and a
restricted auto-detect still always guesses wrong without this class. Any
new Twig-syntax code sample needs it; PHP/Bash samples don't.
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
gate — how a page (or a section, one line per page; a shared
`_access.php` in the section directory is the documented pattern, since
non-`index.*` files are invisible to the router) is assigned to a user
or group: `Access::require('group:members', 'user:bob')` returns `null`
(allowed — no rules at all means any logged-in user, and **admins pass
every rule**) or a `Response` the sidecar returns as-is (anonymous → 303
to `/admin/login?return=<path>`; logged-in-but-not-allowed → plain-text
404, same hide-don't-tease posture as drafts). See
`/admin/docs/access-control`. **Public is the default, and static pages
are always public**: a page with no sidecar can't call `Access` — and
that's load-bearing, since only sidecar-less pages are written to the
static HTML cache; a gated page necessarily has a sidecar, so it can
never leak through the cache — the caching/auth standing rule below is
satisfied by construction, with no bootstrap exclusion needed. Same
open-until-configured / zero-DB-footprint posture as the rest of admin
auth when the flag is off or no users exist. Deliberately **no side
effects on deny** (the return path travels in the redirect URL, not the
session): the content-index crawl runs every sidecar as an anonymous
GET, so gated pages drop out of `/search`/`/sitemap.xml` automatically
(the crawler discards `Response`s) and a crawl must never scribble on
the visiting user's session — the same reasoning that made
`/admin/logout` POST-only.
## Sass override quirk
`App\ContentIndexer` (`novaconium/src/ContentIndexer.php`) is the shared
crawler behind `/sitemap.xml`, `/search`, and blog tag browsing (see
`/admin/docs/content-index`) — `App\`, not `Lib\`, since it's rendering
infrastructure akin to `Renderer`/`Router`, not project-overridable
content. Content stays in files; only metadata is indexed. Per-page
metadata is four Twig blocks declared in the root layout next to the SEO
blocks (`keywords`, `tags`, `changefreq`, `priority` — the last three
never rendered into the page, only harvested) and pulled via
`Renderer::renderForIndex()`, which calls Twig's own
`TemplateWrapper::renderBlock()` per block rather than parsing `.twig`
source — this is deliberate: it gets App-over-novaconium override and
layout-inheritance resolution for free, the same way a real render does.
**`config['content_index_enabled']` defaults to `false`** — same posture
as `matomo_url`/`admin_password_hash`, since this is a real SQLite
dependency plenty of sites won't want. Every consumer route checks the
flag *before* touching `Lib\Db` and 404s if it's off, so the feature has
zero filesystem footprint (no `data/novaconium.sqlite`) when disabled —
verified end-to-end, not assumed, since "off" silently still creating a
database file would defeat the point.
`novaconium/sass/main.sass` does `@use 'colors' as *` with **no**
`_colors.sass` sibling in `novaconium/sass/` — on purpose. Dart Sass
resolves a bare `@use` relative to the importing file's own directory
*before* `--load-path`, so a sibling file would always win and silently
defeat the `App/sass/_colors.sass` override. The framework default lives
at `novaconium/sass/defaults/_colors.sass` instead. Don't move it back.
**Reentrancy hazard, already hit once:** `ContentIndexer::reindex()`
renders every routable page as part of the crawl — including `/search`
itself, which is also a real page and also calls
`ContentIndexer::ensureFresh()` from its own sidecar. Without a guard,
crawling `/search` would trigger a nested `reindex()` call mid-transaction
and fatal on a second `PDO::beginTransaction()`. `ContentIndexer` guards
this with a `private static bool $indexing` flag, checked at the top of
both `ensureFresh()` and `reindex()` — either no-ops while a reindex is
already running on the call stack. Any future consumer route added under
this mechanism inherits the same hazard for free (it'll also get crawled,
and if its sidecar also calls `ensureFresh()`, the guard already covers
it) — don't remove the flag thinking it's unnecessary.
Every color rule in `main.sass` reads a CSS custom property (`var(--bg)`,
etc.), never a Sass variable directly — required for the runtime dark/light
toggle. Adding a color means adding both the plain and `-light` variable in
**both** `_colors.sass` files and wiring it into both `:root` blocks.
`reindex()` also forces `$_SERVER['REQUEST_METHOD']` to `'GET'` for the
duration of the crawl (restoring whatever it was before, in a `finally`)
— sidecars are expected to be side-effect-free for non-POST requests
anyway (ordinary HTTP-safe-method hygiene), but this guarantees a lazy
reindex triggered from within a POST request can never leak that POST
into an unrelated page's sidecar purely because the crawler happened to
render it. A crawl is a full truncate-and-rebuild inside one transaction,
not incremental — simple and correct at this site's scale; don't add
incremental/diffing logic without a real need for it.
## Input handling
**Standing rule: a vendored dependency's files go under `novaconium/vendor/`
only if they're server-side (PHP, autoloaded, never fetched by a browser)
— anything the browser has to fetch (`.js`, `.css`, images) has to live
under `public/vendor/` instead, since `public/` is the only web-reachable
directory (`novaconium/` isn't reachable at all — see `public/.htaccess`).**
Twig lives under `novaconium/vendor/twig/` correctly, since it's pure PHP
source. highlight.js (`/admin/docs/upgrading-highlightjs`,
`public/vendor/highlightjs/`) is the first vendored dependency that's
actually browser-servable, and originally almost got vendored to
`novaconium/vendor/` too, following Twig's precedent blindly — that would
have silently 404ed on every request, since nothing under `novaconium/`
is ever served to a browser. This has a real consequence beyond just
placement: `public/` is project-owned and untouched by the "Updating the
framework" workflow (`rm -rf novaconium && cp -r <new-novaconium>` — see
`/admin/docs/getting-started`), so a future framework release that bumps
a `public/vendor/`-placed dependency will **not** carry that upgrade to
an existing project automatically the way a `novaconium/vendor/` bump
would — re-vendoring it is a separate manual step every time, documented
per-dependency (see `/admin/docs/upgrading-highlightjs`).
**`class="nohighlight"` marks a `<pre><code>` block whose content is
literal Twig template syntax** (`{% %}`/`{{ }}`), so highlight.js's
auto-detection (`novaconium/pages/_layout/syntax-highlight.twig`,
restricted to `configure({ languages: ['php', 'bash', 'xml', 'css',
'python', 'javascript', 'yaml', 'json', 'ini'] })``yaml`/`json`/`ini`
are vendored as separate per-language files under
`public/vendor/highlightjs/languages/`, not part of the core bundle like
the other six; see `/admin/docs/upgrading-highlightjs`) doesn't
force-match it to whichever configured language scores highest — Twig has
no highlight.js grammar, and a restricted auto-detect still always
returns its best guess among the allowed set, never "give up," so an
unmarked Twig block would get colored *wrong*, not just left plain.
Currently on:
`novaconium/pages/admin/docs/{layouts,content-index,rss,sitemap,forms,seo}/index.twig`
and `App/pages/blog/{style-guide,twig-syntax-guide}/index.twig`. A new
Twig-syntax code sample added anywhere needs the same class — a PHP or
Bash sample doesn't (auto-detection handles those reliably on its own,
via strong signals like a leading `<?php`).
Sidecars read request data via `Lib\Input::post()`/`::get()`, not
`$_POST`/`$_GET` directly (trims, strips tags/null bytes — XSS
defense-in-depth, **not** SQL-injection protection; use PDO prepared
statements via `Lib\Db::query()` for that, never string-interpolated SQL).
Exception: fields needing an exact unmodified value (e.g. a password about
to be hashed) read `$_POST` directly — see login/users sidecars.
`Lib\Csrf::verify()` is called directly by a sidecar, not wired into
`FormValidator`.
## Running it
@@ -441,118 +161,26 @@ php -S 127.0.0.1:8000 -t public public/router.php
`public/router.php` is dev-only, mimics `public/.htaccess`. There is no
test suite — verification is manual route-by-route (see
`/admin/docs/design-notes`'s Verification section for the checklist used
after any framework change).
After testing, clear stray cache with `php novaconium/bin/clear-cache.php` or
POST `/admin/clear-cache`, and remove anything written to `App/lib/` or
`App/pages/` that was only for testing an override — nothing here is
gitignored except `public/cache/*` and `novaconium/contact-log.txt`, so
test debris left in `App/` will otherwise get committed or silently change
site behavior for the next person.
`/admin/docs/design-notes`'s Verification section). After testing, clear
stray cache with `php novaconium/bin/clear-cache.php` and remove any
test-only debris from `App/lib/`/`App/pages/` — nothing there is gitignored
except `public/cache/*` and `novaconium/contact-log.txt`.
## Conventions worth knowing
- Reserved segments: any path segment starting with `_` (e.g. `_layout/`)
or literally named `404` is never routable — `Router::resolve()` 404s on
sight, don't try to serve content directly at those paths.
- Sidecars (`index.php`) return either an array (Twig context) or a
`Response` (redirect/json/xml/html — `novaconium/src/Response.php`).
`$params` (route captures) and `$cache` (the `Cache` instance, e.g. for
`$cache->clear()`) are both in scope automatically — see
- Reserved segments: any path segment starting with `_` or literally named
`404` is never routable — `Router::resolve()` 404s on sight.
- Sidecars (`index.php`) return an array (Twig context) or a `Response`.
`$params` and `$cache` are in scope automatically — see
`novaconium/src/Renderer.php::runSidecar()`.
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader.
Adding a new framework-core class means adding it under `App\` in
`novaconium/src/`; a new `Lib\` class goes in `App/lib/` or
`novaconium/lib/` depending on whether it's project- or
framework-specific.
- `novaconium/bin/` holds standalone CLI entry points meant to be run
directly (`php novaconium/bin/<script>.php`) — distinct from
`bootstrap.php`/`autoload.php`/`config.php`, which are only ever
`require`'d, never invoked directly. `clear-cache.php` and
`create-static-page.php` (scaffolds a new page from the `/admin/docs/seo`
starter template) both live there; a new CLI tool goes there too.
- CSS is compiled from `novaconium/sass/main.sass` (indented syntax) to
`public/css/main.css`. `dart-sass` is installed in this environment
(Arch: `pacman -S dart-sass`) — after editing Sass source, run:
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader. A
new framework-core class goes under `App\` in `novaconium/src/`; a new
`Lib\` class goes in `App/lib/` or `novaconium/lib/`.
- `novaconium/bin/` holds standalone CLI entry points
(`php novaconium/bin/<script>.php`) — distinct from
`bootstrap.php`/`autoload.php`/`config.php`, which are only `require`'d.
- CSS compiles from `novaconium/sass/main.sass` (indented syntax) to
`public/css/main.css`:
`sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass public/css/main.css`
and commit the regenerated `public/css/main.css` (`--no-source-map`
avoids a stray `main.css.map` the project doesn't otherwise use). If
`sass` isn't available in whatever environment you're in, either run it
via Docker — `/admin/docs/styling` has a copy-pasteable
Dockerfile that installs the same standalone Dart Sass release used in
this environment (`1.101.0`) directly from GitHub, not via npm, plus
the `docker build`/`docker run` commands adjusted to this repo's paths
— or hand-edit both files in parallel and keep them in sync — that's
how the dark/teal theme and the homepage hero/animation
styling were originally written before `sass` was installed here.
- The Sass color palette follows the same App-over-novaconium override
pattern as pages/lib, but with a twist worth understanding before
touching it: `novaconium/sass/main.sass` does `@use 'colors' as *`, and
its own directory (`novaconium/sass/`) deliberately has **no**
`_colors.sass` sibling. Dart Sass resolves a bare `@use` relative to the
importing file's own directory *before* consulting `--load-path`
entries, so if `novaconium/sass/_colors.sass` existed next to
`main.sass`, it would always win regardless of load-path order —
silently defeating the override. Keeping the framework default at
`novaconium/sass/defaults/_colors.sass` (a different directory) forces
resolution through the load path, where `App/sass` (checked first) can
actually override it with `App/sass/_colors.sass`. Don't move
`defaults/_colors.sass` back next to `main.sass` — it was moved out on
purpose, and doing so reintroduces this bug.
- Every color rule in `main.sass` reads a CSS custom property
(`var(--bg)`, `var(--accent)`, etc.), never a Sass variable directly —
that indirection is what makes the dark/light theme toggle possible,
since Sass only runs at compile time and can't react to a runtime
choice on its own. The two `_colors.sass` files seed `:root` (dark,
the default) and `:root[data-theme="light"]` (via `-light`-suffixed
variables — `$bg-light`, `$accent-light`, etc., same files, same
override mechanism) once at compile time; the toggle button in
`novaconium/pages/_layout/nav.twig` flips the `data-theme` attribute on
`<html>` at runtime and persists it to `localStorage`.
`novaconium/pages/_layout/theme-init.twig` re-applies a saved choice
early in `<head>` (before the stylesheet link) to avoid a flash of the
wrong theme on load. If you add a new color to the palette, add both
the plain and `-light` variable in **both** `_colors.sass` files and
wire it into both `:root` blocks in `main.sass` — a color that's only
themed in one direction will look wrong after a toggle.
- Sidecars should read request data via `Lib\Input::post()`/`::get()`
(`novaconium/lib/Input.php`) rather than `$_POST`/`$_GET` directly — it
trims, strips tags, and strips null bytes automatically. This is
defense-in-depth against HTML/script injection, **not** SQL-injection
protection (no string transform makes input safe to concatenate into a
query — use PDO prepared statements once a database layer exists); don't
add an `sqlSafe()`-style method to `Input`. One documented exception: a
field needing an exact, unmodified value (e.g. a password about to be
hashed or verified) should read `$_POST` directly instead — see the
password fields in `novaconium/pages/admin/users/index.php` and
`novaconium/pages/admin/login/index.php`. `Lib\Csrf`
(`novaconium/lib/Csrf.php`) is standalone session-token CSRF protection, not
wired into `FormValidator` — a sidecar calls `Csrf::verify()` directly.
It was the first thing in the framework to start a native PHP session
(only lazily, when a form actually calls it); `Lib\Session` and
`AdminAuth`'s session login now share that same native session, safely
in any order.
- Don't use Twig's `|slice` filter on a **string** (as opposed to an
array) — it unconditionally calls PHP's `mb_substr()` with no fallback
(`novaconium/vendor/twig/src/Extension/CoreExtension.php`), which
hard-requires the `mbstring` extension and will fatal
(`Call to undefined function Twig\Extension\mb_substr()`) on a PHP
install without it — a real regression this project hit once already,
back when `/blog/hello-world` had a sidecar computing an excerpt this
way (see the footnote on `App/pages/blog/twig-syntax-guide/index.twig`
for the full story). Truncate strings in PHP instead, guarded with
`function_exists('mb_substr')` falling back to `substr()`, and pass the
already-truncated value into the template.
- Same class of bug, different filter: don't use Twig's `|escape('js')` (or
the `'js'` arg to `|e`) either — it calls `Twig\Runtime\mb_ord()`
(`novaconium/vendor/twig/src/Extension/EscaperExtension.php`), which
hard-requires `mbstring` the same way `|slice` does, and fatals
identically without it. Hit for real when
`novaconium/pages/_layout/code-copy.twig` used it to pass SVG icon
markup into an inline `<script>` as a JS string literal. Fixed by not
needing string-escaped markup in JS at all: render the markup as plain
HTML into a `<template>` element (default autoescaping, no mbstring
dependency) and read it in JS via that template element's `.innerHTML`
getter instead. Prefer that pattern — or a `data-*` attribute if the
value is plain text, not markup — over `|escape('js')` any time a Twig
value needs to reach JS.
commit the regenerated CSS. See `/admin/docs/styling` for a Docker
fallback if `sass` isn't installed locally.