diff --git a/.gitignore b/.gitignore index 397ada8..00b9a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ /data/*.sqlite-shm .claude/ /graphify-out/ -/images/* -!/images/.gitkeep +/public/uploads/* +!/public/uploads/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index e7faa99..fd7c023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//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//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//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: ` `, 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 `). 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 `