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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user