Compare commits
13 Commits
b37b13120e
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf0582468 | |||
| 7650354abd | |||
| 0831331fe3 | |||
| 76fd1ca3ed | |||
| 15b32ed256 | |||
| d1ce803412 | |||
| 8540c6d9ea | |||
| 64defe7f74 | |||
| e699027b4b | |||
| c0455241ea | |||
| a0ae58c29e | |||
| 6624c4fdc3 | |||
| f6fb2f12c6 |
@@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
graphify-out/
|
||||||
|
.claude/
|
||||||
|
public/cache/*
|
||||||
|
!public/cache/.gitkeep
|
||||||
|
data/*.sqlite*
|
||||||
|
images/*
|
||||||
|
!images/.gitkeep
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.sass
|
||||||
@@ -6,3 +6,7 @@
|
|||||||
/data/*.sqlite-wal
|
/data/*.sqlite-wal
|
||||||
/data/*.sqlite-shm
|
/data/*.sqlite-shm
|
||||||
.claude/
|
.claude/
|
||||||
|
/graphify-out/
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitkeep
|
||||||
|
.env
|
||||||
@@ -1,45 +1,42 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
Context for any coding agent working in this repo — Claude, DeepSeek, or
|
||||||
otherwise; this file (and the maintenance rule below) applies regardless of
|
otherwise. Full narrative docs live at `/admin/docs` when the app is
|
||||||
which model or CLI is driving. Full narrative docs live at `/admin/docs`
|
running. `README.md` is the GitHub-facing pitch, `novaconium/ISSUES.md` is
|
||||||
when the app is running (also the *only* place Twig upgrade instructions
|
the roadmap/backlog, and this file is the short, agent-facing version:
|
||||||
live now — see `/admin/docs/upgrading-twig`; there's no separate
|
load-bearing gotchas and conventions only, not narrative history.
|
||||||
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.
|
|
||||||
|
|
||||||
## Documentation is duplicated on purpose — keep all copies in sync
|
**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.
|
||||||
|
|
||||||
|
## Docs live in one place: `/admin/docs` — README stays thin
|
||||||
|
|
||||||
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
|
Every topic (routing, sidecars, libraries, layouts, caching, SEO, Matomo,
|
||||||
admin authentication, styling, project layout, third-party) exists in
|
admin auth, styling, Docker, project layout, third-party) has exactly one
|
||||||
**two** places: a page under `novaconium/pages/admin/docs/<topic>/index.twig`
|
canonical writeup: a page under `novaconium/pages/admin/docs/<topic>/index.twig`.
|
||||||
(the canonical reference), and (for anything a README-reading human needs
|
`README.md` deliberately does **not** mirror this content — it's a short
|
||||||
up front) a mention in `README.md`. This is intentional — `/admin/docs` is
|
GitHub-facing pitch (what this is, minimal steps to get it running, a
|
||||||
for reading against a running instance with no internet needed, and
|
pointer into `/admin/docs`) plus the Third-party section, nothing more. The
|
||||||
`README.md` is the GitHub-facing pitch — but it means **any agent that
|
full feature list lives as a blog post, `App/pages/blog/novaconium-features/`
|
||||||
changes framework behavior or adds a feature must update both copies in
|
(sample content, replaceable like any other post), not in the README. This
|
||||||
the same change**, not just the one that was open. Concretely, after
|
was a deliberate change (2026-07-15) away from an earlier "keep README and
|
||||||
touching routing/rendering/caching/SEO behavior or adding a new top-level
|
docs in sync" convention that had made the README long and hard to scan —
|
||||||
docs topic:
|
don't re-add a feature list or per-topic bullet list to README.md.
|
||||||
|
|
||||||
1. Update (or add) the matching page under
|
Any change to framework behavior or a new feature:
|
||||||
`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 —
|
1. Update/add the docs page, and if new, link it from both
|
||||||
verify the other copy before considering the task done.
|
`admin/docs/index.twig` and the nav in `admin/docs/_layout/layout.twig`.
|
||||||
|
2. Update `App/pages/blog/novaconium-features/index.twig` (and its entry in
|
||||||
|
`App/pages/blog/index.php`) if it affects the feature tour.
|
||||||
|
3. Update `README.md` only if it affects the one-paragraph pitch, the
|
||||||
|
minimal getting-started steps, or the Third-party section — not a
|
||||||
|
per-feature bullet.
|
||||||
|
4. Update this file only if it affects a convention an agent needs to know
|
||||||
|
before editing code.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
@@ -50,375 +47,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
|
from Apache after that. No Composer, no build step to install — Twig is
|
||||||
vendored as plain source files.
|
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 project: `App/pages/`, `App/lib/` (`Lib\` classes),
|
||||||
|
`App/config.php`, `App/migrations/`, `App/sass/`. The only directory a
|
||||||
- **`App/`** — the actual project: `App/pages/` (routes/content) and
|
|
||||||
`App/lib/` (project's own `Lib\` classes). This is the only directory a
|
|
||||||
site author is expected to touch.
|
site author is expected to touch.
|
||||||
- **`novaconium/`** — the framework itself: router/renderer core
|
- **`novaconium/`** — the framework: router/renderer core
|
||||||
(`novaconium/src/`), default pages (`novaconium/pages/` — root layout,
|
(`novaconium/src/`), default pages/libs, vendored Twig, autoloader,
|
||||||
404, the `/admin` tools), default `Lib\` classes (`novaconium/lib/`),
|
config, bootstrap.
|
||||||
vendored Twig, autoloader, config, bootstrap.
|
|
||||||
|
|
||||||
Routing and rendering resolve against **both roots, in order** —
|
Routing/rendering resolve against **both roots, `App/` first** (via
|
||||||
`App/pages/` first, `novaconium/pages/` as fallback — via
|
`novaconium/src/Overlay.php` for pages, `novaconium/autoload.php` for
|
||||||
`novaconium/src/Overlay.php`. Same mechanism for `Lib\` classes:
|
`Lib\` classes) — same override-by-presence mechanism used for
|
||||||
`App/lib/` is checked before `novaconium/lib/` in `novaconium/autoload.php`.
|
`config.php`, Twig's `FilesystemLoader`, and Sass (see below). A project
|
||||||
Concretely: dropping a file at the same relative path in `App/` overrides
|
only lists the config keys it's changing in `App/config.php`; never edit
|
||||||
the `novaconium/` default; nothing needs to be duplicated for the site to
|
`novaconium/config.php` directly.
|
||||||
work, since `novaconium/pages/` already supplies a working layout and 404.
|
|
||||||
|
|
||||||
Twig's `FilesystemLoader` is constructed with both paths as an array, so
|
**`db_connections` is the one config key that isn't a plain shallow-merge.**
|
||||||
`{% extends %}` / `{% include %}` get this override-then-fallback
|
`Lib\Db::config()` (and the duplicate in `bin/migrate.php`) merges it one
|
||||||
resolution for free — no custom logic needed there.
|
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`:
|
`Lib\Db` supports multiple, simultaneously-open named connections
|
||||||
if `App/config.php` exists, `novaconium/bootstrap.php` and
|
(`'sqlite'`/`'mysql'` drivers only). Each connection migrates lazily on
|
||||||
`novaconium/bin/clear-cache.php` shallow-merge it over the framework defaults
|
first use, tracked by path **relative to the repo root** (not bare
|
||||||
with `array_merge()`. A project only needs to list the keys it's changing
|
filename — two roots can share a filename). `migrations_dir` accepts an
|
||||||
— never edit `novaconium/config.php` directly.
|
ordered list of roots, each fully processed before the next.
|
||||||
|
|
||||||
`config['matomo_url']` / `config['matomo_site_id']` (both default `''`)
|
**The default DB path (`data/novaconium.sqlite`) lives outside `public/`
|
||||||
gate the Matomo tracking script emitted by the root layout — set both via
|
(web-accessible) and `novaconium/`** (wholesale-replaced by framework
|
||||||
`App/config.php` to enable it, since either being empty disables tracking
|
updates) — it's a project-owned top-level dir, gitignored per-content with
|
||||||
entirely. `bootstrap.php` normalizes a missing trailing slash on
|
a tracked `.gitkeep`. Uploaded files (see Media manager,
|
||||||
`matomo_url` before passing it to `Renderer`, which exposes `matomo_url`,
|
`/admin/docs/media-manager`) live under `public/uploads/` instead, since
|
||||||
`matomo_site_id`, and `is_404` as Twig globals (`is_404` is overridden to
|
they need to be web-reachable directly — a separate, plain static
|
||||||
`true` in the 404 template's local render context by
|
directory on its own volume, not coupled to the SQLite path, since a
|
||||||
`Renderer::renderNotFound()`, per Twig's local-context-over-global
|
project may run MySQL or no DB at all.
|
||||||
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`.
|
|
||||||
|
|
||||||
`config['site_name']` (default `'My Site'`) is the same pattern — passed to
|
## Standing rule: caching vs. any content-hiding mechanism
|
||||||
`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.
|
|
||||||
|
|
||||||
`config['admin_auth_enabled']` (default `false`) gates every `/admin/*`
|
**Any mechanism that conditionally hides page content from the public
|
||||||
route behind a session login against the `users` table on `Lib\Db`'s
|
must be threaded into `Renderer::render()`'s `$excludeFromCache` param, not
|
||||||
default connection (`novaconium/migrations/0002_create_users.sql`) — the
|
just a pre-render auth gate.** `Renderer::render()` writes a sidecar-less
|
||||||
multi-user system from `novaconium/ISSUES.md`'s "Admin login & user
|
page's output to the static HTML cache, and `.htaccess` serves a cached
|
||||||
management" entry, which **replaced** the old single-user HTTP Basic Auth
|
file *before PHP (and therefore any auth check) ever runs again*. A route
|
||||||
stopgap (the `admin_username`/`admin_password_hash` config keys and the
|
gated only at the auth-check level still leaks to the public the moment an
|
||||||
`/admin/password-hash` page are gone; that stopgap had itself replaced
|
authorized user views it once, if the page has no sidecar. `draft_routes`
|
||||||
the even older `docs_enabled` flag). Same off-by-default posture as
|
and every `/admin/*` route already pass `true` for this reason. Any new
|
||||||
`content_index_enabled`, for the same reason: it depends on SQLite, so
|
feature that gates a route by anything other than a sidecar check needs the
|
||||||
when the flag is false, `/admin/*` is wide open, the three auth routes
|
same treatment — this has caused a real bug before, twice.
|
||||||
(`/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.
|
|
||||||
|
|
||||||
`Lib\Db` (`novaconium/lib/Db.php`) is the SQLite/MySQL groundwork tracked
|
Corollary: `Lib\Access` (the sidecar-level content gate, see
|
||||||
in `novaconium/ISSUES.md` — a thin, no-ORM PDO wrapper, `Lib\` (not `App\`)
|
`/admin/docs/access-control`) is safe by construction — a page with no
|
||||||
so a project can override it via `App/lib/Db.php` like any other `Lib\`
|
sidecar can't call `Access`, and only sidecar-less pages get cached, so a
|
||||||
class. It supports multiple, independently-configured, **simultaneously
|
gated page can never leak through the cache with no extra wiring needed.
|
||||||
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).
|
|
||||||
|
|
||||||
**`db_connections` is the one config key in the project that isn't plain
|
## Reentrancy hazard: ContentIndexer
|
||||||
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.
|
|
||||||
|
|
||||||
**`config['db_connections']['default']['path']` must stay outside both
|
`ContentIndexer::reindex()` renders every routable page, including
|
||||||
`public/` (would be web-accessible) and `novaconium/`** — unlike
|
`/search` itself, which also calls `ContentIndexer::ensureFresh()`.
|
||||||
`cache_dir`/`contact-log.txt`, which are disposable, a SQLite file is data a
|
Guarded by a `private static bool $indexing` flag checked at the top of
|
||||||
project can't afford to lose, and `novaconium/` gets wholesale-replaced by
|
both methods — don't remove it, any new consumer route inherits the same
|
||||||
the "Updating the framework" workflow (`/admin/docs/getting-started`: `rm
|
hazard automatically. `reindex()` also forces
|
||||||
-rf novaconium && cp -r <new-novaconium>`). The default
|
`$_SERVER['REQUEST_METHOD']` to `'GET'` for the duration of the crawl
|
||||||
(`data/novaconium.sqlite`) lives in a new top-level `data/` directory
|
(restored in a `finally`) so a lazy reindex triggered from a POST can't
|
||||||
instead — project-owned like `App/`, gitignored per-file
|
leak that POST into an unrelated page's sidecar.
|
||||||
(`*.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`.
|
|
||||||
|
|
||||||
`Lib\Session` (`novaconium/lib/Session.php`) is a thin wrapper around
|
## Vendored dependency placement
|
||||||
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.
|
|
||||||
|
|
||||||
Flash data (`Session::flash()`/`::getFlash()`) is one swap, not a
|
**Server-side-only (PHP, autoloaded) → `novaconium/vendor/`. Anything a
|
||||||
sweep/expiry pass: the first `Session` method call in a request snapshots
|
browser fetches (`.js`, `.css`, images) → `public/vendor/`** — `novaconium/`
|
||||||
whatever was flashed on the *previous* request into an in-memory static
|
is never web-reachable. This matters beyond correctness: `public/` is
|
||||||
(`self::$currentFlash`) for that request's `getFlash()` reads, then
|
project-owned and untouched by a framework update, so a `public/vendor/`
|
||||||
immediately empties the stored flash bucket so `flash()` calls made
|
dependency bump does **not** propagate automatically the way a
|
||||||
*during* the current request start filling a fresh bucket for the request
|
`novaconium/vendor/` bump would — re-vendoring is a manual step per
|
||||||
after this one. This relies on static properties not persisting across
|
dependency (see `/admin/docs/upgrading-highlightjs`).
|
||||||
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.
|
|
||||||
|
|
||||||
**Standing rule: any mechanism that conditionally hides page content from
|
## Twig gotchas that will fatal without `mbstring`
|
||||||
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.
|
|
||||||
|
|
||||||
`AdminAuth::isAdmin(bool $enabled): bool` / `::isLoggedIn(bool
|
Don't use `|slice` on a **string** (calls `mb_substr()` unconditionally) or
|
||||||
$enabled): bool` (`novaconium/src/AdminAuth.php`) are the access checks
|
`|escape('js')`/`'js'` arg to `|e` (calls `mb_ord()`) — both hard-require
|
||||||
on their own, with no response side effects — `requireLogin()` is
|
`mbstring` and fatal without it; this project deliberately avoids that
|
||||||
`isLoggedIn()` plus a 303 redirect to `/admin/login` on failure, and a
|
dependency. Truncate strings in PHP with an `mb_substr`/`substr` fallback
|
||||||
different caller can react to failure differently. The draft-page gate
|
instead. For markup destined for inline `<script>`, render into a
|
||||||
in `bootstrap.php` is the first such caller, and it uses `isAdmin()`
|
`<template>` element and read `.innerHTML` in JS rather than
|
||||||
(drafts are admin-only — a logged-in registered user gets the same 404
|
`|escape('js')`.
|
||||||
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.
|
|
||||||
|
|
||||||
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
|
`class="nohighlight"` marks a `<pre><code>` block containing literal Twig
|
||||||
gate — how a page (or a section, one line per page; a shared
|
syntax (`{% %}`/`{{ }}`) — highlight.js has no Twig grammar and a
|
||||||
`_access.php` in the section directory is the documented pattern, since
|
restricted auto-detect still always guesses wrong without this class. Any
|
||||||
non-`index.*` files are invisible to the router) is assigned to a user
|
new Twig-syntax code sample needs it; PHP/Bash samples don't.
|
||||||
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.
|
|
||||||
|
|
||||||
`App\ContentIndexer` (`novaconium/src/ContentIndexer.php`) is the shared
|
## Sass override quirk
|
||||||
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.
|
|
||||||
|
|
||||||
**Reentrancy hazard, already hit once:** `ContentIndexer::reindex()`
|
`novaconium/sass/main.sass` does `@use 'colors' as *` with **no**
|
||||||
renders every routable page as part of the crawl — including `/search`
|
`_colors.sass` sibling in `novaconium/sass/` — on purpose. Dart Sass
|
||||||
itself, which is also a real page and also calls
|
resolves a bare `@use` relative to the importing file's own directory
|
||||||
`ContentIndexer::ensureFresh()` from its own sidecar. Without a guard,
|
*before* `--load-path`, so a sibling file would always win and silently
|
||||||
crawling `/search` would trigger a nested `reindex()` call mid-transaction
|
defeat the `App/sass/_colors.sass` override. The framework default lives
|
||||||
and fatal on a second `PDO::beginTransaction()`. `ContentIndexer` guards
|
at `novaconium/sass/defaults/_colors.sass` instead. Don't move it back.
|
||||||
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.
|
|
||||||
|
|
||||||
`reindex()` also forces `$_SERVER['REQUEST_METHOD']` to `'GET'` for the
|
Every color rule in `main.sass` reads a CSS custom property (`var(--bg)`,
|
||||||
duration of the crawl (restoring whatever it was before, in a `finally`)
|
etc.), never a Sass variable directly — required for the runtime dark/light
|
||||||
— sidecars are expected to be side-effect-free for non-POST requests
|
toggle. Adding a color means adding both the plain and `-light` variable in
|
||||||
anyway (ordinary HTTP-safe-method hygiene), but this guarantees a lazy
|
**both** `_colors.sass` files and wiring it into both `:root` blocks.
|
||||||
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.
|
|
||||||
|
|
||||||
**Standing rule: a vendored dependency's files go under `novaconium/vendor/`
|
## Input handling
|
||||||
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
|
Sidecars read request data via `Lib\Input::post()`/`::get()`, not
|
||||||
literal Twig template syntax** (`{% %}`/`{{ }}`), so highlight.js's
|
`$_POST`/`$_GET` directly (trims, strips tags/null bytes — XSS
|
||||||
auto-detection (`novaconium/pages/_layout/syntax-highlight.twig`,
|
defense-in-depth, **not** SQL-injection protection; use PDO prepared
|
||||||
restricted to `configure({ languages: ['php', 'bash', 'xml', 'css',
|
statements via `Lib\Db::query()` for that, never string-interpolated SQL).
|
||||||
'python', 'javascript', 'yaml', 'json', 'ini'] })` — `yaml`/`json`/`ini`
|
Exception: fields needing an exact unmodified value (e.g. a password about
|
||||||
are vendored as separate per-language files under
|
to be hashed) read `$_POST` directly — see login/users sidecars.
|
||||||
`public/vendor/highlightjs/languages/`, not part of the core bundle like
|
`Lib\Csrf::verify()` is called directly by a sidecar, not wired into
|
||||||
the other six; see `/admin/docs/upgrading-highlightjs`) doesn't
|
`FormValidator`.
|
||||||
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`).
|
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -428,118 +172,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
|
`public/router.php` is dev-only, mimics `public/.htaccess`. There is no
|
||||||
test suite — verification is manual route-by-route (see
|
test suite — verification is manual route-by-route (see
|
||||||
`/admin/docs/design-notes`'s Verification section for the checklist used
|
`/admin/docs/design-notes`'s Verification section). After testing, clear
|
||||||
after any framework change).
|
stray cache with `php novaconium/bin/clear-cache.php` and remove any
|
||||||
After testing, clear stray cache with `php novaconium/bin/clear-cache.php` or
|
test-only debris from `App/lib/`/`App/pages/` — nothing there is gitignored
|
||||||
POST `/admin/clear-cache`, and remove anything written to `App/lib/` or
|
except `public/cache/*` and `novaconium/contact-log.txt`.
|
||||||
`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.
|
|
||||||
|
|
||||||
## Conventions worth knowing
|
## Conventions worth knowing
|
||||||
|
|
||||||
- Reserved segments: any path segment starting with `_` (e.g. `_layout/`)
|
- Reserved segments: any path segment starting with `_` or literally named
|
||||||
or literally named `404` is never routable — `Router::resolve()` 404s on
|
`404` is never routable — `Router::resolve()` 404s on sight.
|
||||||
sight, don't try to serve content directly at those paths.
|
- Sidecars (`index.php`) return an array (Twig context) or a `Response`.
|
||||||
- Sidecars (`index.php`) return either an array (Twig context) or a
|
`$params` and `$cache` are in scope automatically — see
|
||||||
`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
|
|
||||||
`novaconium/src/Renderer.php::runSidecar()`.
|
`novaconium/src/Renderer.php::runSidecar()`.
|
||||||
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader.
|
- No Composer — `novaconium/autoload.php` is a hand-rolled PSR-4 loader. A
|
||||||
Adding a new framework-core class means adding it under `App\` in
|
new framework-core class goes under `App\` in `novaconium/src/`; a new
|
||||||
`novaconium/src/`; a new `Lib\` class goes in `App/lib/` or
|
`Lib\` class goes in `App/lib/` or `novaconium/lib/`.
|
||||||
`novaconium/lib/` depending on whether it's project- or
|
- `novaconium/bin/` holds standalone CLI entry points
|
||||||
framework-specific.
|
(`php novaconium/bin/<script>.php`) — distinct from
|
||||||
- `novaconium/bin/` holds standalone CLI entry points meant to be run
|
`bootstrap.php`/`autoload.php`/`config.php`, which are only `require`'d.
|
||||||
directly (`php novaconium/bin/<script>.php`) — distinct from
|
- CSS compiles from `novaconium/sass/main.sass` (indented syntax) to
|
||||||
`bootstrap.php`/`autoload.php`/`config.php`, which are only ever
|
`public/css/main.css`:
|
||||||
`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:
|
|
||||||
`sass --load-path=App/sass --load-path=novaconium/sass/defaults --no-source-map novaconium/sass/main.sass 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`
|
— commit the regenerated CSS. See `/admin/docs/styling` for a Docker
|
||||||
avoids a stray `main.css.map` the project doesn't otherwise use). If
|
fallback if `sass` isn't installed locally.
|
||||||
`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.
|
|
||||||
|
|||||||
@@ -51,4 +51,13 @@ return [
|
|||||||
// `php novaconium/bin/index-content.php` (e.g. from a deploy step):
|
// `php novaconium/bin/index-content.php` (e.g. from a deploy step):
|
||||||
// 'content_index_enabled' => true,
|
// 'content_index_enabled' => true,
|
||||||
// 'content_index_auto' => false,
|
// 'content_index_auto' => false,
|
||||||
|
|
||||||
|
// Docs: /admin/docs/admin-auth — email verification. Set all four to
|
||||||
|
// switch Lib\Mailer's transactional mail (verification links, not the
|
||||||
|
// contact form) from the zero-dependency log fallback to MailJet:
|
||||||
|
// 'mail_driver' => 'mailjet',
|
||||||
|
// 'mail_from_email' => 'noreply@example.com',
|
||||||
|
// 'mail_from_name' => 'Example Site',
|
||||||
|
// 'mailjet_api_key' => '...',
|
||||||
|
// 'mailjet_api_secret' => '...',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -424,6 +424,24 @@ button:hover {
|
|||||||
.feature-card:nth-child(6) {
|
.feature-card:nth-child(6) {
|
||||||
animation-delay: 0.66s;
|
animation-delay: 0.66s;
|
||||||
}
|
}
|
||||||
|
.feature-card:nth-child(7) {
|
||||||
|
animation-delay: 0.72s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(8) {
|
||||||
|
animation-delay: 0.78s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(9) {
|
||||||
|
animation-delay: 0.84s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(10) {
|
||||||
|
animation-delay: 0.9s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(11) {
|
||||||
|
animation-delay: 0.96s;
|
||||||
|
}
|
||||||
|
.feature-card:nth-child(12) {
|
||||||
|
animation-delay: 1.02s;
|
||||||
|
}
|
||||||
.feature-card h2 {
|
.feature-card h2 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<article>
|
<article>
|
||||||
{% block blog_content %}{% endblock %}
|
{% block blog_content %}{% endblock %}
|
||||||
|
{# Only pages whose sidecar opts in by returning a 'comments'
|
||||||
|
key get a thread — see /admin/docs/comments and
|
||||||
|
App/pages/blog/hello-world/index.php. A sidecar-less post
|
||||||
|
never has this key, so it's silently skipped. #}
|
||||||
|
{% if comments is defined %}
|
||||||
|
{% include '_partials/comments/thread.twig' %}
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Demonstrates attaching Lib\Comments to a page — see /admin/docs/comments.
|
||||||
|
// This is the one post under App/pages/blog/ with a sidecar, specifically
|
||||||
|
// so it can carry a live comment thread; giving it one is what excludes
|
||||||
|
// it from the static HTML cache (Renderer::render() only ever caches
|
||||||
|
// sidecar-less pages) — every other post stays sidecar-less/cached and
|
||||||
|
// has no thread, since blog/_layout/layout.twig only includes one when
|
||||||
|
// 'comments' is present in the returned context.
|
||||||
|
|
||||||
|
use App\AdminAuth;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Comments;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\FormValidator;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\SpamGuard;
|
||||||
|
|
||||||
|
$pagePath = Comments::currentPagePath();
|
||||||
|
$user = AdminAuth::currentUser();
|
||||||
|
$spamGuard = new SpamGuard();
|
||||||
|
$commentError = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
return Response::redirect($pagePath . '?error=security');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return Response::redirect($pagePath . '?error=login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = Input::post('body', '');
|
||||||
|
$validator = (new FormValidator())
|
||||||
|
->required($body, 'body', 'Enter a comment.')
|
||||||
|
->maxLength($body, 'body', 2000, 'Comments are 2000 characters max.');
|
||||||
|
|
||||||
|
if ($validator->passes()) {
|
||||||
|
// Same "bot gets an identical response" reasoning as the contact
|
||||||
|
// form — see App/pages/contact/index.php. Only the insert is
|
||||||
|
// skipped for spam.
|
||||||
|
if (!$spamGuard->isSpam(Input::post())) {
|
||||||
|
Comments::create($pagePath, $user['id'], $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::redirect($pagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentError = $validator->errors()['body'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'comments' => Comments::forPage($pagePath),
|
||||||
|
'currentUser' => $user,
|
||||||
|
'commentError' => $commentError,
|
||||||
|
'renderedAt' => $spamGuard->renderedAt(),
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Comments Demo{% endblock %}
|
||||||
|
{% block description %}A worked example of Lib\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}comments, meta{% endblock %}
|
||||||
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block og_type %}article{% endblock %}
|
||||||
|
{% block og_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block og_description %}{{ block('description') }}{% endblock %}
|
||||||
|
{% block og_url %}{{ block('canonical') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block twitter_card %}summary{% endblock %}
|
||||||
|
{% block twitter_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block twitter_description %}{{ block('description') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block blog_content %}
|
||||||
|
<h1>Comments Demo</h1>
|
||||||
|
|
||||||
|
<p>Unlike every other post under <code>App/pages/blog/</code>, this one has its own <code>index.php</code> sidecar — <code>App/pages/blog/comments-demo/index.php</code> — which reads and writes a <code>comments</code> table via <code>Lib\Comments</code> and returns a <code>comments</code> key in its context. <code>App/pages/blog/_layout/layout.twig</code> only includes the comment-thread partial (<code>novaconium/pages/_partials/comments/thread.twig</code>) when that key is present, so this is the only post here with a thread below.</p>
|
||||||
|
|
||||||
|
<p>Having a sidecar means this page is never served from the static HTML cache the way its sidecar-less siblings are (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — an explicit, per-page tradeoff you accept the moment a page needs comments. See <a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a> for the full write-up of <code>Lib\Comments</code>, including why comments are tied to real logged-in accounts rather than anonymous name/email fields, and why they're auto-approved with after-the-fact moderation at <code>/admin/comments</code> rather than a pending queue.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -40,5 +40,17 @@ return [
|
|||||||
'excerpt' => 'How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.',
|
'excerpt' => 'How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.',
|
||||||
'published' => '2026-07-14',
|
'published' => '2026-07-14',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'novaconium-features',
|
||||||
|
'title' => 'Novaconium Features',
|
||||||
|
'excerpt' => 'A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.',
|
||||||
|
'published' => '2026-07-15',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'comments-demo',
|
||||||
|
'title' => 'Comments Demo',
|
||||||
|
'excerpt' => 'A worked example of Lib\\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.',
|
||||||
|
'published' => '2026-07-15',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Novaconium Features{% endblock %}
|
||||||
|
{% block description %}A tour of what ships with novaconium out of the box: routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}features, meta{% endblock %}
|
||||||
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block og_type %}article{% endblock %}
|
||||||
|
{% block og_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block og_description %}{{ block('description') }}{% endblock %}
|
||||||
|
{% block og_url %}{{ block('canonical') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block twitter_card %}summary{% endblock %}
|
||||||
|
{% block twitter_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block twitter_description %}{{ block('description') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block blog_content %}
|
||||||
|
<h1>Novaconium Features</h1>
|
||||||
|
|
||||||
|
<p>A tour of what ships with novaconium out of the box. Every topic below has a full writeup at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a> on any running instance — this post is the overview.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>File-based routing</strong> — a directory under <code>App/pages/</code> <em>is</em> a route (Hugo-style page bundles). No route table to maintain.</li>
|
||||||
|
<li><strong><code>[param]</code> segments</strong> — a directory literally named <code>[param]</code> (e.g. <code>App/pages/products/[id]/</code>) captures any single URL segment into <code>$params['param']</code> for clean URLs, no query strings.</li>
|
||||||
|
<li><strong>Optional PHP "sidecars"</strong> — drop an <code>index.php</code> next to any <code>index.twig</code> to supply Twig context data, or return a <code>Response</code> (redirect/JSON/XML/HTML) to short-circuit templating entirely.</li>
|
||||||
|
<li><strong>Static caching, zero config</strong> — sidecar-less pages render once and are written to <code>public/cache/</code>; <code>.htaccess</code> serves the cached file directly on every later hit, skipping PHP and Twig entirely.</li>
|
||||||
|
<li><strong>Override-by-path</strong> — <code>App/</code> (your project) is checked before <code>novaconium/</code> (the framework defaults) for every page, layout, <code>Lib\</code> class, and even the Sass color palette (<code>App/sass/_colors.sass</code>). Drop a file at the same relative path to override it; nothing needs duplicating to get a working site.</li>
|
||||||
|
<li><strong>Layout inheritance</strong> — <code>_layout/layout.twig</code> directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.</li>
|
||||||
|
<li><strong>SEO boilerplate out of the box</strong> — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.</li>
|
||||||
|
<li><strong>Built-in Matomo analytics</strong> — set <code>matomo_url</code> and <code>matomo_site_id</code> in <code>App/config.php</code> to enable tracking site-wide, including automatic 404 tracking. Off by default.</li>
|
||||||
|
<li><strong>Admin authentication</strong> — gate every <code>/admin/*</code> route behind a session login with multi-user management: a SQLite-backed <code>users</code> table, <code>/admin/login</code>/<code>/admin/logout</code>, and an <code>/admin/users</code> page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a <code>novaconium/bin/create-admin-user.php</code> CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Every account after the first must verify its email (a link sent via <code>Lib\Mailer</code>, logged to a file by default or sent through MailJet if configured) before it can log in. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; off by default.</li>
|
||||||
|
<li><strong>Access control</strong> — assign a page (or a section, one line per page) to a user or group from its sidecar: <code>Access::require('group:members')</code> returns <code>null</code> or a ready-made <code>Response</code> (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction.</li>
|
||||||
|
<li><strong>Draft pages</strong> — list a route under <code>draft_routes</code> in <code>App/config.php</code> to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt.</li>
|
||||||
|
<li><strong>Media manager</strong> — <code>/admin/media</code>, an upload/browse/delete UI for files under <code>public/uploads/</code>, covered by the existing <code>/admin/*</code> auth gate with no separate flag needed. Extension allowlist and max upload size are configurable.</li>
|
||||||
|
<li><strong>Comments</strong> — <code>Lib\Comments</code>, a reusable comment thread any page can attach to itself via its own sidecar (see <code>App/pages/blog/comments-demo/</code>). Tied to real logged-in accounts, not anonymous name/email fields; auto-approved on submission with after-the-fact hide/delete moderation at <code>/admin/comments</code>.</li>
|
||||||
|
<li><strong>Dark/light theme toggle</strong> — a nav button flips a <code>data-theme</code> attribute (persisted to <code>localStorage</code>) that swaps every color via CSS custom properties.</li>
|
||||||
|
<li><strong>Self-hosted spam prevention & form validation</strong> — <code>Lib\SpamGuard</code> (honeypot + submission-timing check, no external CAPTCHA), <code>Lib\FormValidator</code>, and <code>Lib\Validate</code>, demonstrated on the contact form.</li>
|
||||||
|
<li><strong>Form security by default</strong> — <code>Lib\Input</code> (cleaning accessor for <code>$_POST</code>/<code>$_GET</code>) and <code>Lib\Csrf</code> (standalone session-token CSRF protection), wired into the contact form and every admin form.</li>
|
||||||
|
<li><strong>SQLite/MySQL database, zero setup</strong> — <code>Lib\Db</code>, a thin PDO wrapper (no ORM) supporting multiple named connections open at once, each with its own plain-SQL migration convention, applied automatically on first use or via <code>php novaconium/bin/migrate.php</code>.</li>
|
||||||
|
<li><strong>Sessions with flash data</strong> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions with CodeIgniter-style flash values for post/redirect/GET flows.</li>
|
||||||
|
<li><strong>Content index: sitemap, search, tags</strong> — <code>/sitemap.xml</code>, full-text <code>/search</code> (SQLite FTS5), and blog tag browsing all share one crawler. Off by default; reindexes lazily on demand or via <code>php novaconium/bin/index-content.php</code>.</li>
|
||||||
|
<li><strong>Blog RSS feed</strong> — <code>/blog/feed</code>, built from the same hand-written post list <code>App/pages/blog/index.php</code> itself renders from, so it works with no database at all.</li>
|
||||||
|
<li><strong>Syntax-highlighted code blocks</strong> — vendored <a href="https://highlightjs.org/">highlight.js</a> colors PHP/Bash/HTML code blocks site-wide, auto-detected with no per-block markup.</li>
|
||||||
|
<li><strong>No build step, no Composer</strong> — clone it, point Apache (or <code>php -S</code>) at <code>public/</code>, and it runs. Twig is vendored as source.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Full details on every one of these live at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo, admin authentication, access control, draft pages, media manager, styling, project layout, and third-party notices.</p>
|
||||||
|
{% endblock %}
|
||||||
+30
-2
@@ -56,8 +56,36 @@
|
|||||||
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
|
<p>Meta description, canonical links, Open Graph, and Twitter Card tags ship by default, all overridable per page.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature-card">
|
<article class="feature-card">
|
||||||
<h2>Matomo & admin auth</h2>
|
<h2>Admin authentication</h2>
|
||||||
<p>Built-in analytics tracking and a multi-user session login for <code>/admin/*</code> — both off until you turn them on in <code>App/config.php</code>.</p>
|
<p>A multi-user session login gates every <code>/admin/*</code> route, with email verification for new accounts. Off by default in <code>App/config.php</code>.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Access control & drafts</h2>
|
||||||
|
<p>Gate a page to a user or group with one <code>Lib\Access</code> call in its sidecar, or preview an unfinished page as an admin-only draft.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Media manager & comments</h2>
|
||||||
|
<p>An upload/browse/delete UI at <code>/admin/media</code>, and <code>Lib\Comments</code> for a moderated comment thread on any page.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Database, zero setup</h2>
|
||||||
|
<p><code>Lib\Db</code> wraps PDO for SQLite or MySQL, multiple named connections at once, migrating automatically on first use.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Search, sitemap & tags</h2>
|
||||||
|
<p>One content index backs full-text <code>/search</code>, <code>/sitemap.xml</code>, and blog tag browsing — reindexed lazily, no extra steps.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Blog RSS feed</h2>
|
||||||
|
<p><code>/blog/feed</code> is built from the same hand-written post list <code>App/pages/blog/index.php</code> renders from — no database required.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Matomo & dark/light theme</h2>
|
||||||
|
<p>Built-in analytics tracking, off by default, alongside a nav toggle that swaps every color via CSS custom properties.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card">
|
||||||
|
<h2>Form security by default</h2>
|
||||||
|
<p><code>Lib\Csrf</code>, <code>Lib\SpamGuard</code>'s honeypot check, and cleaning input accessors — wired into the contact form and every admin form.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="feature-card">
|
<article class="feature-card">
|
||||||
<h2>Override anything</h2>
|
<h2>Override anything</h2>
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
|
# Agent permissions
|
||||||
|
|
||||||
|
- Only run `git` commands with the user's explicit permission for that specific command/action.
|
||||||
|
- Never run `docker` commands (build, compose up, run, etc.) — leave all Docker execution to the user.
|
||||||
|
|
||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# Official PHP + Apache image for running novaconium in production.
|
||||||
|
# See /admin/docs/docker for the bind-mounted paths, docker-entrypoint.sh's
|
||||||
|
# seeding/permissions behavior, and optional MySQL wiring.
|
||||||
|
|
||||||
|
# Build: docker build --no-cache -t novaconium:latest .
|
||||||
|
|
||||||
|
# Fixed: full official image tag (was missing "php:")
|
||||||
|
FROM php:8.5.8-apache-trixie
|
||||||
|
|
||||||
|
# Pin to a specific tag (not a floating "php:apache") so a rebuild months
|
||||||
|
# from now installs the same PHP/Apache/Debian base instead of whatever
|
||||||
|
# happens to be current that day. Bump the tag above deliberately (e.g. to
|
||||||
|
# pick up a PHP security release), not as a side effect of an unrelated
|
||||||
|
# rebuild.
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libsqlite3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& docker-php-ext-install pdo_sqlite pdo_mysql \
|
||||||
|
&& a2enmod rewrite
|
||||||
|
|
||||||
|
# Point DocumentRoot at public/ and allow .htaccess overrides there.
|
||||||
|
RUN sed -ri -e 's#/var/www/html#/var/www/html/public#g' \
|
||||||
|
/etc/apache2/sites-available/*.conf \
|
||||||
|
&& sed -ri -e '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' \
|
||||||
|
/etc/apache2/apache2.conf
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY novaconium/ ./novaconium/
|
||||||
|
COPY public/ ./public/
|
||||||
|
COPY App/ ./App/
|
||||||
|
|
||||||
|
# Pristine copy of the starter App/, kept outside /var/www/html so
|
||||||
|
# docker-entrypoint.sh can reseed a bind-mounted (but empty/missing) App/ on
|
||||||
|
# first start — see docker-entrypoint.sh and /admin/docs/docker.
|
||||||
|
RUN cp -a App/ /opt/novaconium-app-default/
|
||||||
|
|
||||||
|
# Runtime-writable paths — cache/uploads/App/data are bind-mounted from the
|
||||||
|
# host by docker-compose.yml, so docker-entrypoint.sh re-chowns them at
|
||||||
|
# every container start (a build-time chown only survives on the image
|
||||||
|
# layer, not on a host bind mount). This chown still covers a fresh
|
||||||
|
# container with no bind mounts configured at all.
|
||||||
|
RUN mkdir -p public/cache public/uploads data \
|
||||||
|
&& touch novaconium/contact-log.txt \
|
||||||
|
&& chown -R www-data:www-data public/cache public/uploads data App novaconium/contact-log.txt
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["apache2-foreground"]
|
||||||
@@ -1,122 +1,53 @@
|
|||||||
# novaconium
|

|
||||||
|
|
||||||
A tiny, Hugo-flavored PHP micro-framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards. No Composer — Twig is vendored directly into the repo as plain source files.
|
A Hugo-flavored PHP framework. Routes are directories on disk, pages render with [Twig](https://twig.symfony.com/), and any page that needs real logic gets an optional PHP "sidecar" file. Pages without a sidecar are pre-rendered once and served as static HTML straight from Apache afterwards.
|
||||||
|
|
||||||
## Features
|
For a full tour of what's included — routing, sidecars, caching, admin auth, access control, media manager, database, search, RSS, and more — see the [Novaconium Features](http://127.0.0.1:8000/blog/novaconium-features) post once the site is running, or `/admin/docs` (see Documentation below).
|
||||||
|
|
||||||
- **File-based routing** — a directory under `App/pages/` *is* a route (Hugo-style page bundles). No route table to maintain.
|
|
||||||
- **`[param]` segments** — a directory literally named `[param]` (e.g. `App/pages/products/[id]/`) captures any single URL segment into `$params['param']` for clean URLs, no query strings.
|
|
||||||
- **Optional PHP "sidecars"** — drop an `index.php` next to any `index.twig` to supply Twig context data, or return a `Response` (redirect/JSON/XML/HTML) to short-circuit templating entirely.
|
|
||||||
- **Static caching, zero config** — sidecar-less pages render once and are written to `public/cache/`; `.htaccess` serves the cached file directly on every later hit, skipping PHP and Twig entirely.
|
|
||||||
- **Override-by-path** — `App/` (your project) is checked before `novaconium/` (the framework defaults) for every page, layout, `Lib\` class, and even the Sass color palette (`App/sass/_colors.sass`). Drop a file at the same relative path to override it; nothing needs duplicating to get a working site.
|
|
||||||
- **Layout inheritance** — `_layout/layout.twig` directories are resolved by walking upward from the matched page, so you can override the layout for a whole subtree.
|
|
||||||
- **SEO boilerplate out of the box** — the default layout ships meta description, canonical link, robots, Open Graph, and Twitter Card tags, all overridable per-page via Twig blocks.
|
|
||||||
- **Built-in Matomo analytics** — set `matomo_url` and `matomo_site_id` in `App/config.php` to enable tracking site-wide, including automatic 404 tracking. Off by default.
|
|
||||||
- **Admin authentication** — gate every `/admin/*` route behind a session login with multi-user management: a SQLite-backed `users` table, `/admin/login`/`/admin/logout`, and an `/admin/users` page to create, disable, delete, group, promote/demote, and change the email or password of accounts (plus a `novaconium/bin/create-admin-user.php` CLI for the first user or deploy scripts). Two roles: the first user created is the admin; everyone after is a registered user with an optional group. Every account has a unique, normalized email address — groundwork for email verification later. Enabled with a single `admin_auth_enabled` flag in `App/config.php`; off by default, and reusable for any admin page a project adds later.
|
|
||||||
- **Access control** — assign a page (or a section, one line per page) to a user or group from its sidecar: `Access::require('group:members')` returns `null` or a ready-made `Response` (login redirect with a return path, or a 404 for the wrong account). Public is the default — a sidecar that never calls it is untouched, and static (sidecar-less, cached) pages are always public by construction. Gated pages stay out of `/search` and `/sitemap.xml` automatically.
|
|
||||||
- **Draft pages** — list a route under `draft_routes` in `App/config.php` to make it visible only to an authenticated admin; anyone else gets a plain 404, not a login prompt. Reuses the admin auth check directly, and is excluded from static caching so a cached copy can't leak the draft to the public. See `/admin/docs/drafts`.
|
|
||||||
- **Dark/light theme toggle** — a nav button flips a `data-theme` attribute (persisted to `localStorage`) that swaps every color via CSS custom properties; both palettes live in `App/sass/_colors.sass`, same override mechanism as everything else.
|
|
||||||
- **Self-hosted spam prevention & form validation** — `Lib\SpamGuard`, a reusable class for any form: a CSS-hidden honeypot field plus a submission-timing check, no external CAPTCHA service, site key, or outbound API call. Pairs with `Lib\FormValidator` (accumulating required-field/email/length checks) and `Lib\Validate` (the underlying validation primitives — email, length, phone, postal/zip, spam-word checks). All three ship in `novaconium/lib/`, demonstrated on the contact form.
|
|
||||||
- **Form security by default** — `Lib\Input`, a cleaning accessor for `$_POST`/`$_GET` (defense-in-depth against HTML/script injection, not a substitute for parameterized queries), and `Lib\Csrf`, standalone session-token CSRF protection called directly from a sidecar. Both ship in `novaconium/lib/`, wired into the contact form, `/admin/clear-cache`, `/admin/login`, and `/admin/users`.
|
|
||||||
- **SQLite/MySQL database, zero setup** — `Lib\Db`, a thin PDO wrapper (no ORM) supporting multiple named connections open at once — e.g. this site's own SQLite data plus a MySQL connection to a legacy database, usable in the same sidecar — each with its own plain-SQL migration convention, applied automatically on first use or via `php novaconium/bin/migrate.php`. SQLite data lives in a project-owned top-level `data/` directory, outside both `public/` and `novaconium/`. See `/admin/docs/database`.
|
|
||||||
- **Sessions with flash data** — `Lib\Session`, a thin wrapper around native PHP sessions with CodeIgniter-style flash values (set now, readable on exactly the next request) for post/redirect/GET flows without a query-string flag. Lazy-start, same mechanism `Lib\Csrf` already uses. See `/admin/docs/session`.
|
|
||||||
- **Content index: sitemap, search, tags** — `/sitemap.xml`, full-text `/search` (SQLite FTS5), and blog tag browsing all share one crawler that renders every page and harvests `keywords`/`tags`/`changefreq`/`priority` Twig blocks via Twig's own `renderBlock()` — no front-matter, no separate metadata files. Off by default (depends on SQLite); reindexes lazily on demand or via `php novaconium/bin/index-content.php`. See `/admin/docs/content-index`.
|
|
||||||
- **Blog RSS feed** — `/blog/feed`, built from the same hand-written post list `App/pages/blog/index.php` itself renders from, so it works with no database at all. `Lib\Rss` (a small RSS 2.0 envelope builder) also backs a per-tag feed, `/blog/tag/<tag>/feed`, once the content index above is enabled. Auto-discovered via a `<link rel="alternate">` on `/blog/*` pages.
|
|
||||||
- **Syntax-highlighted code blocks** — vendored [highlight.js](https://highlightjs.org/) colors PHP/Bash/HTML code blocks site-wide, auto-detected with no per-block markup, swapping between dark (`ir-black`) and light (`github`) themes along with the existing dark/light toggle. Twig-syntax samples (which highlight.js can't parse) are left plain rather than colored wrong. See `/admin/docs/upgrading-highlightjs`.
|
|
||||||
- **No build step, no Composer** — clone it, point Apache (or `php -S`) at `public/`, and it runs. Twig is vendored as source; see `/admin/docs/upgrading-twig` for upgrading it.
|
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
**Requirements:** PHP 8.1+ (uses `readonly` constructor-promoted properties) with the `pdo_sqlite` extension (bundled with PHP, just needs to be enabled — no separate install; add `pdo_mysql` too if using a MySQL connection), and, for production, Apache with `mod_rewrite` and `AllowOverride All`. The content index's search (`/admin/docs/content-index`) additionally needs SQLite's FTS5 extension, bundled with `pdo_sqlite` on virtually every modern PHP build — only relevant if `content_index_enabled` is turned on.
|
### Requirements:
|
||||||
|
|
||||||
### Run it locally (no Apache needed)
|
PHP 8.1+ (uses `readonly` constructor-promoted properties) and, for production, Apache with `mod_rewrite` and `AllowOverride All`. A few optional features (database, content index/search, admin authentication) need the `pdo_sqlite` extension — see `/admin/docs` for details once running.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Run it locally, no Apache needed:
|
||||||
|
|
||||||
```
|
```
|
||||||
php -S 127.0.0.1:8000 -t public public/router.php
|
php -S 127.0.0.1:8000 -t public public/router.php
|
||||||
```
|
```
|
||||||
|
|
||||||
`public/router.php` is a dev-only script that mimics the `.htaccess` rules (canonical redirects + static cache lookup) so you can develop without Apache. It is never used in production — Apache reads `public/.htaccess` directly.
|
Visit `http://127.0.0.1:8000/` — click around the example pages, then open `http://127.0.0.1:8000/admin/docs` for the complete documentation, rendered live from this same instance.
|
||||||
|
|
||||||
Visit `http://127.0.0.1:8000/` for the static home page, then click around — `/about`, `/blog/hello-world`, `/contact`, and `/admin` (cache clearing + these same docs, rendered live) are all included as working examples.
|
### Production
|
||||||
|
|
||||||
### Deploy on Apache
|
|
||||||
|
|
||||||
Point the vhost's document root at `public/`, make sure `mod_rewrite` is enabled and `AllowOverride All` is set for that directory so `public/.htaccess` takes effect, and it just works — no build step required.
|
|
||||||
|
|
||||||
### Starting a new project
|
|
||||||
|
|
||||||
Clone this repo and drop its Git history — no Composer scaffold or installer:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
git clone --depth 1 <novaconium-repo-url> my-new-project
|
#docker buildx build --no-cache -t 4lights/novaconium:2.0.0-beta -t 4lights/corxn:latest --load .
|
||||||
cd my-new-project
|
#docker login -u <username>
|
||||||
rm -rf .git && git init && git add -A && git commit -m "Initial commit from novaconium template"
|
#docker push 4lights/novaconium:2.0.0
|
||||||
|
#docker push 4lights/novaconium:latest
|
||||||
|
|
||||||
|
docker buildx build --no-cache -t 4lights/novaconium:2.0.0-beta --load .
|
||||||
|
docker login git.4lt.ca -u nick
|
||||||
|
docker pull git.4lt.ca/4lt/novaconium:2.0.0-beta
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
root@b2c4133264c6:/var/www/html# php novaconium/bin/create-admin-user.php nick c@nickyeoman.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Then replace the example content under `App/pages/` with your own; leave `novaconium/` and `public/` alone.
|
### Webmasters
|
||||||
|
|
||||||
### Updating the framework
|
- Clone this repo.
|
||||||
|
- docker build: ``` docker build -t novaconium . ```
|
||||||
Since the framework core lives entirely under `novaconium/`, pick up a new release by overwriting just that directory against a tag and committing the diff:
|
- docker compose up -d
|
||||||
|
|
||||||
```
|
|
||||||
git clone --depth 1 --branch <release-tag> <novaconium-repo-url> /tmp/nova-update
|
|
||||||
rm -rf novaconium && cp -r /tmp/nova-update/novaconium ./novaconium && rm -rf /tmp/nova-update
|
|
||||||
git add novaconium && git commit -m "Update novaconium framework to <release-tag>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Safe by construction — `App/` always overrides `novaconium/`, so an update can't clobber project customizations. See [Getting started](http://127.0.0.1:8000/admin/docs/getting-started) for the full write-up.
|
|
||||||
|
|
||||||
### Add a page
|
|
||||||
|
|
||||||
Create a directory under `App/pages/` with an `index.twig` — the directory path *is* the URL:
|
|
||||||
|
|
||||||
```
|
|
||||||
App/pages/pricing/index.twig -> /pricing
|
|
||||||
```
|
|
||||||
|
|
||||||
Add an `index.php` next to it if the page needs data or logic. See [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars) in the docs for the full contract, or [SEO](http://127.0.0.1:8000/admin/docs/seo) for a ready-to-paste starter template with every overridable block — or skip the copy-paste and scaffold it:
|
|
||||||
|
|
||||||
```
|
|
||||||
php novaconium/bin/create-static-page.php blog/my-new-post
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The full framework documentation — routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, draft pages, styling, project layout, and third-party notices — lives at `/admin/docs` on any running instance (so it travels with the code, no internet connection needed). Highlights:
|
The full framework documentation lives inside the framework itself, at `/admin/docs` on any running instance — so it travels with the code, no internet connection needed. That's the canonical reference for everything: requirements, running locally, deploying on Apache or Docker, starting a new project, updating the framework, adding a page, routing, sidecars, libraries, database, session, content index, XML sitemap, RSS feeds, layouts, static caching, SEO, Matomo analytics, admin authentication, access control, draft pages, media manager, styling, and project layout.
|
||||||
|
|
||||||
- [Getting started](http://127.0.0.1:8000/admin/docs/getting-started)
|
|
||||||
- [Routing](http://127.0.0.1:8000/admin/docs/routing)
|
|
||||||
- [Sidecars](http://127.0.0.1:8000/admin/docs/sidecars)
|
|
||||||
- [Libraries](http://127.0.0.1:8000/admin/docs/libraries)
|
|
||||||
- [Database](http://127.0.0.1:8000/admin/docs/database)
|
|
||||||
- [Session](http://127.0.0.1:8000/admin/docs/session)
|
|
||||||
- [Content index](http://127.0.0.1:8000/admin/docs/content-index)
|
|
||||||
- [XML sitemap](http://127.0.0.1:8000/admin/docs/sitemap)
|
|
||||||
- [RSS feeds](http://127.0.0.1:8000/admin/docs/rss)
|
|
||||||
- [Layouts](http://127.0.0.1:8000/admin/docs/layouts)
|
|
||||||
- [Static caching](http://127.0.0.1:8000/admin/docs/caching)
|
|
||||||
- [SEO](http://127.0.0.1:8000/admin/docs/seo)
|
|
||||||
- [Matomo](http://127.0.0.1:8000/admin/docs/matomo)
|
|
||||||
- [Admin authentication](http://127.0.0.1:8000/admin/docs/admin-auth)
|
|
||||||
- [Access control](http://127.0.0.1:8000/admin/docs/access-control)
|
|
||||||
- [Draft pages](http://127.0.0.1:8000/admin/docs/drafts)
|
|
||||||
- [Styling](http://127.0.0.1:8000/admin/docs/styling)
|
|
||||||
- [Project layout](http://127.0.0.1:8000/admin/docs/project-layout)
|
|
||||||
- [Third-party](http://127.0.0.1:8000/admin/docs/third-party)
|
|
||||||
|
|
||||||
`AGENTS.md` is the short, agent-facing version for coding assistants working in this repo, and `novaconium/ISSUES.md` is the roadmap/backlog.
|
`AGENTS.md` is the short, agent-facing version for coding assistants working in this repo, and `novaconium/ISSUES.md` is the roadmap/backlog.
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
App/ your project — pages/ (routes), lib/ (Lib\ classes), sass/ (color overrides) — the only directory you're expected to edit
|
|
||||||
public/ Apache document root — front controller, .htaccess, static cache, compiled CSS
|
|
||||||
novaconium/ the framework itself — router, renderer, vendored Twig, default pages/lib/sass — not edited per-project
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Project layout](http://127.0.0.1:8000/admin/docs/project-layout) for the full tree with every file explained.
|
|
||||||
|
|
||||||
## Third-party
|
## Third-party
|
||||||
|
|
||||||
[Twig](https://twig.symfony.com/) is vendored in source form under `novaconium/vendor/twig/` (no Composer — see `/admin/docs/upgrading-twig` for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at `novaconium/vendor/twig/LICENSE`.
|
[Twig](https://twig.symfony.com/) is vendored in source form under `novaconium/vendor/twig/` (no Composer — see `/admin/docs/upgrading-twig` for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at `novaconium/vendor/twig/LICENSE`.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ${NOVACONIUM_IMAGE:-4lights/novaconium:2.0.0-beta}
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ${PROJECT_PATH:-/data}:/var/www/html/App
|
||||||
|
- ${PROJECT_PATH:-/data}/css:/var/www/html/public/css
|
||||||
|
- ${VOL_PATH:-/data}/novaconium/cache:/var/www/html/public/cache
|
||||||
|
- ${VOL_PATH:-/data}/novaconium/uploads:/var/www/html/public/uploads
|
||||||
|
- ${VOL_PATH:-/data}/novaconium/data:/var/www/html/data
|
||||||
|
|
||||||
|
# Optional — only needed if App/config.php adds a db_connections entry
|
||||||
|
# with driver: mysql. See /admin/docs/database.
|
||||||
|
# db:
|
||||||
|
# image: mysql:8
|
||||||
|
# environment:
|
||||||
|
# MYSQL_DATABASE: novaconium
|
||||||
|
# MYSQL_USER: novaconium
|
||||||
|
# MYSQL_PASSWORD: change-me
|
||||||
|
# MYSQL_ROOT_PASSWORD: change-me
|
||||||
|
# volumes:
|
||||||
|
# - mysql-data:/var/lib/mysql
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs once per container start, before Apache — see /admin/docs/docker.
|
||||||
|
#
|
||||||
|
# docker-compose.yml bind-mounts App/, public/cache/, public/uploads/, and
|
||||||
|
# data/ from the host so a project's content/db survive a rebuild and can be
|
||||||
|
# edited without one. Two problems a plain COPY-at-build-time image can't
|
||||||
|
# solve on its own:
|
||||||
|
#
|
||||||
|
# 1. A bind mount to an empty (or not-yet-created) host directory shadows
|
||||||
|
# whatever COPY baked into that path in the image, replacing it with
|
||||||
|
# nothing — Docker does not seed bind mounts from image content the way
|
||||||
|
# it seeds a fresh named volume. App/ is only ever the docs/starter
|
||||||
|
# content wanted on host: seed it from the pristine copy stashed
|
||||||
|
# at build time (/opt/novaconium-app-default) if the mounted dir is
|
||||||
|
# empty, so `docker compose up` produces a working site on a first run
|
||||||
|
# with no manual copy step.
|
||||||
|
# 2. A bind-mounted host directory keeps the host's ownership, not the
|
||||||
|
# image's — the build-time `chown` in the Dockerfile never applies to
|
||||||
|
# it. Re-chown the mounted paths to the Apache worker user on every
|
||||||
|
# start so they're writable regardless of the host-side UID/GID.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -z "$(ls -A /var/www/html/App 2>/dev/null)" ]; then
|
||||||
|
cp -a /opt/novaconium-app-default/. /var/www/html/App/
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R www-data:www-data /var/www/html/public/cache /var/www/html/public/uploads /var/www/html/App /var/www/html/data
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
+72
-415
@@ -20,9 +20,14 @@ tracker issue is filed, for things that are still just an idea.
|
|||||||
be actionable/discussed, not necessarily when the idea is first written
|
be actionable/discussed, not necessarily when the idea is first written
|
||||||
down here.
|
down here.
|
||||||
- When work begins, move the item to **In Progress**.
|
- When work begins, move the item to **In Progress**.
|
||||||
- When shipped, move it to **Done**, keep the entry (don't delete), and add
|
- When shipped, move it to **Done** and add a `Shipped:` line with the date
|
||||||
a `Shipped:` line with the date and, once committed, the commit/PR
|
and, once committed, the commit/PR reference. Keep a Done entry only as
|
||||||
reference.
|
long as it's referenced by (a `Depends on:`, or otherwise relevant
|
||||||
|
context for) something still in Backlog/In Progress — once nothing
|
||||||
|
active points back to it, delete it rather than letting this file grow
|
||||||
|
without bound. This is a change from the file's earlier "never delete"
|
||||||
|
policy; if a stale Done entry's history is ever needed again, it's in
|
||||||
|
git history / the linked tracker issue.
|
||||||
- If something is decided against, move it to **Won't Do** with a `Reason:`
|
- If something is decided against, move it to **Won't Do** with a `Reason:`
|
||||||
line rather than deleting it — the "why not" is worth keeping. Close the
|
line rather than deleting it — the "why not" is worth keeping. Close the
|
||||||
corresponding tracker issue with a link back to that entry.
|
corresponding tracker issue with a link back to that entry.
|
||||||
@@ -53,122 +58,85 @@ expected vs. actual behavior. For features, include the motivating use case.>
|
|||||||
|
|
||||||
Suggested build order (foundations first):
|
Suggested build order (foundations first):
|
||||||
|
|
||||||
1. **Media/file manager** — no hard dependency; usable standalone, and
|
1. **Ecommerce: Lib\Money** — no dependencies, and the other two Ecommerce
|
||||||
already gated behind admin login the moment it exists under `/admin/*`.
|
pieces below both need it.
|
||||||
2. **In-house comments** — admin login & user management (Done
|
2. **Ecommerce: Lib\Cart** — needs Lib\Money.
|
||||||
2026-07-14) unblocked this; comments are tied to real user accounts,
|
3. **Ecommerce: payment gateway helper** — needs Lib\Money; independent of
|
||||||
not anonymous.
|
Lib\Cart, so it could also go before it.
|
||||||
3. **Ecommerce functionality** — its dependencies (SQLite groundwork,
|
4. **Paywall functionality** — needs the payment gateway helper above for
|
||||||
session handling for the cart, admin login for order/product admin)
|
its recurring-billing/payment plumbing; build after it rather than in
|
||||||
are all done now.
|
parallel — also now has a concrete precedent to follow for the "gated
|
||||||
4. **Paywall functionality** — needs everything Ecommerce needs, plus
|
content must skip the static cache" part of its design (see Draft
|
||||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
pages (admin-only preview) in Done, and the caching/auth standing rule
|
||||||
build after it rather than in parallel — also now has a concrete
|
in `AGENTS.md`), which was still an open question when this entry was
|
||||||
precedent to follow for the "gated content must skip the static cache"
|
originally written.
|
||||||
part of its design (see Draft pages (admin-only preview) in Done, and
|
|
||||||
the caching/auth standing rule in `AGENTS.md`), which was still an open
|
|
||||||
question when this entry was originally written.
|
|
||||||
|
|
||||||
MySQL support, Session handling (with flash sessions), Draft pages
|
Session handling (with flash sessions), Draft pages (admin-only preview),
|
||||||
(admin-only preview), Blog tags/categories + Internal search + XML
|
and Admin login & user management all shipped (see Done) — every open
|
||||||
sitemap (shipped together as one content index — see Done), Blog RSS
|
entry above still depends on at least one of them. The original single
|
||||||
feed, Syntax highlighting on code blocks, and Admin login & user
|
"Ecommerce functionality" entry was scoped down into the three
|
||||||
management all shipped 2026-07-14.
|
Lib\-helper pieces above on 2026-07-15 — see Lib\Money's entry for why.
|
||||||
|
|
||||||
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
|
||||||
|
|
||||||
### Email verification for user accounts
|
### Ecommerce: Lib\Money
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** Admin login & user management (Done)
|
|
||||||
- **Added:** 2026-07-14
|
|
||||||
|
|
||||||
Verify a user's email address by sending a confirmation link — the
|
|
||||||
groundwork is already in place: every account has a required, unique,
|
|
||||||
normalized email (`users.email`, added the same day as user deletion —
|
|
||||||
see User deletion & email addresses under Done). Needs a `verified_at`
|
|
||||||
(or token) column, a token-generation/expiry scheme, a send path
|
|
||||||
(`Lib\Mailer` is currently a log-to-file stand-in — this feature is
|
|
||||||
probably what forces it to grow a real mail transport), and a decision
|
|
||||||
on what an unverified account may do (log in but fail `Lib\Access`
|
|
||||||
rules? not log in at all?). Also the natural home for password-reset-
|
|
||||||
by-email later, which shares all the same plumbing.
|
|
||||||
|
|
||||||
### Media/file manager
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
An upload/browse/delete UI for media (images, PDFs, etc.) so sidecars and
|
|
||||||
Twig templates have a consistent place to reference uploaded files from
|
|
||||||
— e.g. a blog post's header image — instead of authors manually copying
|
|
||||||
files into `public/`. Likely a new `/admin/media` page — already covered
|
|
||||||
by the admin authentication gate (every `/admin/*` route) the moment it's
|
|
||||||
added, no extra wiring needed — backed by a plain directory under
|
|
||||||
`public/uploads/` rather than a database (files are already static
|
|
||||||
assets; no need for SQLite here unless metadata like alt text/captions is
|
|
||||||
wanted later, in which case that part could ride on SQLite groundwork).
|
|
||||||
Needs basic safety handling: extension allowlist, filename sanitization,
|
|
||||||
and a max upload size, since this is a file-write surface.
|
|
||||||
|
|
||||||
### In-house comments
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
|
|
||||||
- **Added:** 2026-07-14
|
|
||||||
|
|
||||||
A self-hosted comments library — no third-party service (Disqus,
|
|
||||||
Commento, etc.) — a `Lib\` class any sidecar can call to attach comments
|
|
||||||
to any page, not just blog posts, the same way `Lib\SpamGuard`/
|
|
||||||
`Lib\FormValidator` are reusable across any form rather than hardcoded to
|
|
||||||
the contact page. Comments tied to a real user account rather than
|
|
||||||
anonymous name/email fields, which is why this rides on Admin login &
|
|
||||||
user management rather than SQLite groundwork alone — needs that
|
|
||||||
feature's user store to exist first. Likely a `comments` table
|
|
||||||
(route/user/body/created_at/approved or similar — a migration under
|
|
||||||
`App/migrations/`, following the two-root convention documented in
|
|
||||||
`/admin/docs/database`) plus a small set of sidecar-callable methods
|
|
||||||
(list comments for a route, submit one, moderate one). Needs a decision
|
|
||||||
on moderation model (auto-approve vs. admin-approval queue, reusing the
|
|
||||||
`/admin/*` auth gate for the moderation UI) and on spam handling (reuse
|
|
||||||
`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second
|
|
||||||
mechanism, consistent with how CSRF protection already works — see
|
|
||||||
`/admin/docs/sidecars`'s "Form security" section for the existing
|
|
||||||
input-cleaning/CSRF/spam-prevention layers a comment form should compose
|
|
||||||
the same way the contact form does).
|
|
||||||
|
|
||||||
### Ecommerce functionality
|
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Low
|
- **Priority:** Low
|
||||||
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
- **Added:** 2026-07-15
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Product catalog, cart, checkout, and order storage — a `products` /
|
First and smallest piece of the former "Ecommerce functionality" entry —
|
||||||
`orders` table in SQLite, a session-based cart (rides on the flash-session
|
scoped down (2026-07-15) from a full catalog/cart/checkout/order-admin
|
||||||
work above), and a payment gateway integration for actually taking money.
|
system to a handful of composable `Lib\` helpers, since a project's idea
|
||||||
Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling
|
of a "product" is too site-specific to standardize; the project builds
|
||||||
a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL)
|
its own catalog and admin UI on `Lib\Db` the same way it would for any
|
||||||
over vendoring a full SDK, same reasoning as vendoring only Twig's `src/`
|
other feature, the same split `Lib\Comments` already makes for what a
|
||||||
rather than pulling in a package manager. Needs a decision on which
|
"page" is. `Lib\Money`: integer-cents arithmetic (add/subtract/multiply
|
||||||
provider(s) to support first. Order/product management rides on admin
|
by a quantity) and formatting, avoiding the classic float-rounding bugs
|
||||||
login above. Large feature — likely worth its own sub-breakdown (catalog,
|
of storing prices as floats. No dependencies — the foundation `Lib\Cart`
|
||||||
cart, checkout, order admin) once it's actually picked up rather than
|
and the payment gateway helper below both need a non-lossy way to
|
||||||
planning it all up front here.
|
represent an amount before either can be built.
|
||||||
|
|
||||||
|
### Ecommerce: Lib\Cart
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Backlog
|
||||||
|
- **Priority:** Low
|
||||||
|
- **Depends on:** Ecommerce: Lib\Money, Session handling (with flash sessions) (Done)
|
||||||
|
- **Added:** 2026-07-15
|
||||||
|
|
||||||
|
Second piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||||
|
above for the scoping note). A session-based cart primitive — add/remove/
|
||||||
|
update line items, line and cart totals via `Lib\Money` — riding on
|
||||||
|
`Lib\Session` the same way `Lib\Csrf`/`Lib\AdminAuth` lazily touch the
|
||||||
|
native session. Generic on purpose: the cart holds id/qty/price entries a
|
||||||
|
sidecar hands it, with no opinion on what a "product" is or where its
|
||||||
|
catalog data comes from.
|
||||||
|
|
||||||
|
### Ecommerce: payment gateway helper
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Backlog
|
||||||
|
- **Priority:** Low
|
||||||
|
- **Depends on:** Ecommerce: Lib\Money
|
||||||
|
- **Added:** 2026-07-15
|
||||||
|
|
||||||
|
Third piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||||
|
above for the scoping note). A driver-dispatched `Lib\` class for taking
|
||||||
|
a payment, mirroring `Lib\Mailer`'s `mail_driver` config-key pattern —
|
||||||
|
Stripe first (one `charge()`-shaped call plus webhook signature
|
||||||
|
verification), calling the provider's REST API directly via cURL rather
|
||||||
|
than vendoring an SDK, same reasoning as vendoring only Twig's `src/`
|
||||||
|
rather than pulling in a package manager. Adding a second provider later
|
||||||
|
means one more driver case, same as `Lib\Mailer::sendMail()`.
|
||||||
|
|
||||||
### Paywall functionality
|
### Paywall functionality
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Low
|
- **Priority:** Low
|
||||||
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
- **Depends on:** Ecommerce: payment gateway helper, SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
|
||||||
- **Added:** 2026-07-12
|
- **Added:** 2026-07-12
|
||||||
|
|
||||||
Subscription/membership content gating, similar to OnlyFans/Patreon:
|
Subscription/membership content gating, similar to OnlyFans/Patreon:
|
||||||
@@ -195,33 +163,6 @@ _Nothing yet._
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
### User deletion & email addresses
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Done
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** Admin login & user management (Done)
|
|
||||||
- **Added:** 2026-07-14
|
|
||||||
- **Shipped:** 2026-07-14 (b882c30)
|
|
||||||
|
|
||||||
Second same-day follow-up to Admin login & user management (below),
|
|
||||||
also pre-commit — so the `email` column went into the existing
|
|
||||||
`0002_create_users.sql` like the roles change before it. `/admin/users`
|
|
||||||
gained a hard-delete action (username/email become reusable; any live
|
|
||||||
session dies on its next request via the same per-request row re-check
|
|
||||||
disabling uses; the last-active-admin guard covers delete as well as
|
|
||||||
disable/demote) and a change-email action. Every account now requires a
|
|
||||||
unique email address — validated and normalized (trim + lowercase) via
|
|
||||||
the existing `Lib\Validate::isEmail()`, so uniqueness is
|
|
||||||
case-insensitive by construction (verified: `BOB@example.com` collides
|
|
||||||
with `bob@example.com`) — on the create form, the change-email action,
|
|
||||||
and `bin/create-admin-user.php` (now `<username> <email>`). Not used
|
|
||||||
for login or any mail yet; it exists so the planned email-verification
|
|
||||||
flow (new Backlog entry above) has an address for every account that
|
|
||||||
predates it. Delete stays deliberately distinct from disable in the UI
|
|
||||||
(inside a confirm-style `<details>` with a warning): disable is
|
|
||||||
keep-but-shut-out, delete is gone-for-good.
|
|
||||||
|
|
||||||
### User roles, groups & page access control
|
### User roles, groups & page access control
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -322,220 +263,6 @@ the session cookie (including confirming a pre-existing static-cache copy
|
|||||||
of a page still serves after the page is marked a draft until the cache
|
of a page still serves after the page is marked a draft until the cache
|
||||||
is cleared — the documented cache-clear step, not a new bug).
|
is cleared — the documented cache-clear step, not a new bug).
|
||||||
|
|
||||||
### Syntax highlighting on code blocks
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Done
|
|
||||||
- **Priority:** Low
|
|
||||||
- **Added:** 2026-07-13
|
|
||||||
- **Shipped:** 2026-07-14
|
|
||||||
|
|
||||||
Colors `<pre><code>` blocks site-wide via vendored highlight.js v11.11.1
|
|
||||||
(pinned to that stable tag, not `main`, which tracks an in-progress
|
|
||||||
`11.0.0-beta1`), auto-detected and restricted to `configure({ languages:
|
|
||||||
['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json',
|
|
||||||
'ini'] })` — no per-block markup needed for the ~60 code blocks across the
|
|
||||||
site. `css`/`python`/`javascript` ship in the core bundle alongside the
|
|
||||||
original `php`/`bash`/`xml`; `yaml`/`json`/`ini` (the last covers
|
|
||||||
`.env`-style files too) don't and are vendored as three separate
|
|
||||||
per-language files under `public/vendor/highlightjs/languages/` — added
|
|
||||||
after initial shipment, once `/blog/code-highlighting` (a new reference
|
|
||||||
post, worked example of each of the nine) needed them. Themes swap with the
|
|
||||||
existing dark/light toggle: **ir-black** (dark, as originally specified)
|
|
||||||
+ **github** (light, new — resolving the open question this entry
|
|
||||||
originally left for "decide whether code blocks stay ir-black regardless
|
|
||||||
of site theme"), via the same `data-theme`-driven mechanism as the main
|
|
||||||
palette (`novaconium/pages/_layout/syntax-highlight-init.twig`/
|
|
||||||
`syntax-highlight.twig`, mirroring `theme-init.twig`/`nav.twig`'s split —
|
|
||||||
a `MutationObserver` on `data-theme` swaps the theme `<link>` live,
|
|
||||||
without touching the existing toggle button's own click handler at all).
|
|
||||||
|
|
||||||
Twig-syntax code blocks (no highlight.js grammar exists for Twig) are
|
|
||||||
marked `class="nohighlight"` by hand at the source — the entry's own
|
|
||||||
suggested fallback — rather than force-matched into the restricted
|
|
||||||
candidate set, which would color them *wrong* rather than leave them
|
|
||||||
plain (auto-detection with a restricted language list always returns its
|
|
||||||
best guess among the allowed set, never "gives up"). 15 blocks across 9
|
|
||||||
files needed the marker; verified by finding every `<pre><code>`
|
|
||||||
containing literal `{% %}`/`{{ }}` syntax rather than guessing, and
|
|
||||||
confirmed two files matching that initial grep (`sidecars`, one block in
|
|
||||||
`forms`) turned out to be `{% verbatim %}`-wrapped *PHP* snippets
|
|
||||||
(verbatim just protecting a stray `{{ }}` mention), correctly left alone
|
|
||||||
for auto-detection. Same class of gotcha hit again writing the bash
|
|
||||||
example for `/blog/code-highlighting`: a command starting with the
|
|
||||||
literal word `php` (`php -S 127.0.0.1:8000 ...`) auto-detects as PHP, not
|
|
||||||
bash — not a bug, just a reminder that short/ambiguous snippets can
|
|
||||||
mis-detect regardless of the restricted candidate set; the shipped bash
|
|
||||||
example uses a `#!/bin/bash` shebang instead, a strong, reliable signal,
|
|
||||||
verified against the real detector before committing to it.
|
|
||||||
|
|
||||||
**One correction to this entry's own suggested approach, found while
|
|
||||||
implementing it:** vendoring highlight.js under `novaconium/vendor/` next
|
|
||||||
to Twig (as originally suggested) would have been wrong and silently
|
|
||||||
broken — Twig is server-side PHP, never fetched by a browser, but
|
|
||||||
highlight.js's `.js`/`.css` files are, and only `public/` is web
|
|
||||||
-reachable. Vendored to `public/vendor/highlightjs/` instead — see the
|
|
||||||
standing rule added to `AGENTS.md` and `/admin/docs/upgrading-highlightjs`
|
|
||||||
for the consequence: `public/` isn't touched by the usual
|
|
||||||
`novaconium/`-swap framework-update workflow, so a future highlight.js
|
|
||||||
version bump won't propagate to existing projects automatically the way
|
|
||||||
everything else under `novaconium/` does.
|
|
||||||
|
|
||||||
**A real bug caught by testing, not review:** `hljs.highlightAll()`
|
|
||||||
doesn't defer itself if called while `document.readyState` is still
|
|
||||||
`"loading"` — it silently no-ops permanently rather than waiting and
|
|
||||||
retrying, confirmed with an actual DOM test (jsdom) before it was
|
|
||||||
noticed, not assumed safe just because the script tag sits near the end
|
|
||||||
of `<body>`. Fixed by wrapping the call in the same `DOMContentLoaded`
|
|
||||||
pattern `code-copy.twig` already uses. Verified end-to-end with a real
|
|
||||||
`highlight.js` execution against real rendered page HTML (not a hand
|
|
||||||
-rolled mock): correct language detection on real PHP/Bash blocks,
|
|
||||||
`nohighlight` blocks left untouched, and `code.textContent` (what the
|
|
||||||
copy-to-clipboard button reads) confirmed unchanged after `<span>`
|
|
||||||
-wrapping — the specific regression this entry flagged as a risk.
|
|
||||||
|
|
||||||
### Blog RSS feed
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Done
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
- **Shipped:** 2026-07-14
|
|
||||||
|
|
||||||
`App/pages/blog/feed/index.php`, sidecar-only, `Response::xml(...)` — as
|
|
||||||
originally scoped, no new mechanism needed. Deliberately independent of
|
|
||||||
the content index above: it reads the same hand-written `$posts` array
|
|
||||||
`App/pages/blog/index.php` itself renders from (now with a `published`
|
|
||||||
date field added per entry, illustrative — this repo's posts all arrived
|
|
||||||
in one batch import, no authentic per-post history to derive real dates
|
|
||||||
from), so it works with `content_index_enabled` left at its shipped
|
|
||||||
default of `false`. Sorted newest-first for the feed only; the array's
|
|
||||||
own order (and the `/blog` listing page) is untouched. `<link>`/`<guid>`
|
|
||||||
are site-relative paths, consistent with how `canonical`/`og:url` already
|
|
||||||
work in this framework (no site-wide base-URL config exists to build
|
|
||||||
absolute URLs from — not adding one for this alone); `<guid
|
|
||||||
isPermaLink="false">` is the spec-correct way to mark a non-absolute
|
|
||||||
identifier.
|
|
||||||
|
|
||||||
Shipped a per-tag feed too (`App/pages/blog/tag/[tag]/feed/index.php`),
|
|
||||||
gated on `content_index_enabled` the same way `blog/tag/[tag]/index.php`
|
|
||||||
is, `<pubDate>` from each page's `source_mtime` (a stand-in for a real
|
|
||||||
publish date, which the content index doesn't track). Both feeds share
|
|
||||||
`Lib\Rss::render()` (`novaconium/lib/Rss.php`, new — a generic RSS 2.0
|
|
||||||
envelope builder, framework-default since only its two call sites are
|
|
||||||
blog-specific, not the class itself) rather than duplicating the same XML
|
|
||||||
-building logic twice.
|
|
||||||
|
|
||||||
Feed auto-discovery needed a new `head_extra` block in the root layout
|
|
||||||
(`novaconium/pages/_layout/layout.twig`, empty by default, rendered right
|
|
||||||
before `</head>`) — the root layout had no open-ended "extra head
|
|
||||||
content" extension point before this; `App/pages/blog/_layout/layout.twig`
|
|
||||||
overrides it with the `<link rel="alternate">`, so it only appears on
|
|
||||||
`/blog/*` pages, not site-wide.
|
|
||||||
|
|
||||||
### Content index: keywords, tags/categories, search, XML sitemap
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Done
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork (Done)
|
|
||||||
- **Added:** 2026-07-12 (Blog tags/categories, Internal search, XML
|
|
||||||
sitemap entries) / 2026-07-14 (keywords, combined)
|
|
||||||
- **Shipped:** 2026-07-14
|
|
||||||
|
|
||||||
Shipped Blog tags/categories, Internal search, and XML sitemap together,
|
|
||||||
plus a new meta-keywords request, as one feature rather than three —
|
|
||||||
exactly the "worth deciding together when either is picked up" call this
|
|
||||||
file made when XML sitemap was first written. All three (plus a fourth,
|
|
||||||
new: `<meta name="keywords">`) turned out to be one shared crawler with
|
|
||||||
thin consumers, not separate mechanisms.
|
|
||||||
|
|
||||||
Design: content stays in files (Twig pages, no CMS-style body-in-database
|
|
||||||
— keeps the Hugo-style file-based-routing pitch intact). Per-page metadata
|
|
||||||
is four Twig blocks in `novaconium/pages/_layout/layout.twig`, the same
|
|
||||||
override mechanism already used for `title`/`description`/`og_*`:
|
|
||||||
`keywords` (rendered, new `<meta>` tag), `tags` (comma-separated, not
|
|
||||||
rendered), `changefreq`/`priority` (sitemap hints, not rendered). No
|
|
||||||
front-matter, no separate metadata file convention. `App\ContentIndexer`
|
|
||||||
(`novaconium/src/ContentIndexer.php`) crawls every routable page
|
|
||||||
(`Overlay::listPageDirs()`, a new method — skips `_`/`404`/`[param]`
|
|
||||||
directories, matching `Router::resolve()`'s reserved-segment rule and the
|
|
||||||
original XML sitemap entry's stated V1 limitation that wildcard routes
|
|
||||||
aren't crawled without a data source to resolve concrete values) and pulls
|
|
||||||
each block's value via `Renderer::renderForIndex()` (new method) calling
|
|
||||||
Twig's own `TemplateWrapper::renderBlock()` — not regex-parsing `.twig`
|
|
||||||
source — so overrides and layout inheritance resolve exactly like a real
|
|
||||||
render. Rendered HTML is `strip_tags()`-stripped into a SQLite FTS5 table
|
|
||||||
for search. A page listed in `draft_routes` or whose `robots` block
|
|
||||||
resolves to `noindex` is skipped entirely (never indexed), same convention
|
|
||||||
`/admin/docs/seo` already documents for admin/internal pages.
|
|
||||||
|
|
||||||
**Off by default** (`content_index_enabled`, default `false`) — all three
|
|
||||||
consumers depend on SQLite, a real dependency plenty of sites built on
|
|
||||||
this framework won't want, same reasoning that already keeps Matomo/admin
|
|
||||||
auth off by default. Verified end-to-end that disabling it is a true
|
|
||||||
zero-footprint no-op: no `data/novaconium.sqlite` gets created just
|
|
||||||
because the feature exists in the codebase, and all three consumer routes
|
|
||||||
404 exactly as if they didn't exist.
|
|
||||||
|
|
||||||
Two trigger paths sharing one `reindex()`: lazy (`content_index_auto`,
|
|
||||||
default `true` — a cheap mtime-staleness check on first touch of a
|
|
||||||
consumer route, never on a normal page view) and explicit
|
|
||||||
(`php novaconium/bin/index-content.php`, same shape as `bin/migrate.php`,
|
|
||||||
ignores `content_index_auto`).
|
|
||||||
|
|
||||||
**Two real bugs caught by testing, not review:**
|
|
||||||
1. **Reentrancy** — the crawl renders every page, including `/search`
|
|
||||||
itself, whose own sidecar calls `ContentIndexer::ensureFresh()`;
|
|
||||||
without a guard this triggered a nested `reindex()` mid-transaction and
|
|
||||||
fataled on a second `PDO::beginTransaction()`. Fixed with a
|
|
||||||
`private static bool $indexing` guard checked at the top of both
|
|
||||||
`ensureFresh()` and `reindex()`.
|
|
||||||
2. **Wrong PDO constant** (`PDO::KEY_PAIR` instead of
|
|
||||||
`PDO::FETCH_KEY_PAIR`) in the search sidecar, caught immediately by
|
|
||||||
actually hitting `/search` with a real query rather than trusting the
|
|
||||||
code read correctly.
|
|
||||||
|
|
||||||
Also fixed two unrelated pre-existing bugs discovered while building this
|
|
||||||
(both blocked/were adjacent to the crawler rendering every page for real):
|
|
||||||
`novaconium/pages/admin/docs/sidecars/index.twig` had a literal
|
|
||||||
un-escaped `{{ }}` in prose text that fataled Twig with a syntax error on
|
|
||||||
any real render of that page (it had apparently never actually been
|
|
||||||
visited before); and both that page and `Lib\Input`'s doc-comment still
|
|
||||||
said "there's no database layer in this framework yet" despite `Lib\Db`
|
|
||||||
having shipped weeks earlier.
|
|
||||||
|
|
||||||
**`migrations_dir` (`Lib\Db`) now accepts an ordered list of roots, not
|
|
||||||
just one path** — needed so the content index's schema
|
|
||||||
(`novaconium/migrations/0001_create_content_index.sql`) could ship as a
|
|
||||||
framework migration without colliding with project migrations in
|
|
||||||
`App/migrations/`. This is the first framework-shipped migration, and the
|
|
||||||
two-root extension point `AGENTS.md` flagged as a future need when SQLite
|
|
||||||
groundwork shipped. Migrations are now tracked by path relative to the
|
|
||||||
repo root (not bare filename) specifically to prevent two roots each
|
|
||||||
containing a same-named file from shadowing one another —
|
|
||||||
`realpath()`-normalized so a `migrations_dir` containing `..` (like the
|
|
||||||
default connection's own `__DIR__ . '/../App/migrations'`) doesn't produce
|
|
||||||
an ugly, unstable tracked name.
|
|
||||||
|
|
||||||
New consumer routes: `novaconium/pages/sitemap.xml/index.php` (framework
|
|
||||||
-default — confirmed a directory literally named `sitemap.xml` resolves
|
|
||||||
correctly, since `Router` only splits on `/`), `novaconium/pages/search/`
|
|
||||||
(framework-default, FTS5-backed, the search term wrapped as an escaped
|
|
||||||
quoted phrase before binding — parameter binding stops SQL injection but
|
|
||||||
not FTS5's own query-language parsing of the bound value, verified against
|
|
||||||
a literal `"` and several FTS operator characters, not just assumed safe),
|
|
||||||
and `App/pages/blog/tag/[tag]/` (project-owned, since `blog/` is project
|
|
||||||
content — `App/pages/blog/index.php`'s hand-written post array is
|
|
||||||
untouched, `content_tags` is a derived index on top of it, not a
|
|
||||||
replacement). Added `{% block tags %}` to the 4 existing blog posts so tag
|
|
||||||
browsing has real content to demonstrate against — an exception to the
|
|
||||||
"ship mechanism only, no demo content" pattern of prior sessions, since
|
|
||||||
here the target content already existed and tag browsing is meaningless
|
|
||||||
to verify without it. Documented at `/admin/docs/content-index`, with
|
|
||||||
supporting updates to `/admin/docs/seo`, `/admin/docs/database`, and
|
|
||||||
`novaconium/bin/create-static-page.php`'s scaffolded template.
|
|
||||||
|
|
||||||
### Draft pages (admin-only preview)
|
### Draft pages (admin-only preview)
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -607,44 +334,6 @@ admin login (next up) depends on it for logged-in state. Documented at
|
|||||||
`/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db`
|
`/admin/docs/session` and in `AGENTS.md` next to the `Lib\Csrf`/`Lib\Db`
|
||||||
sections.
|
sections.
|
||||||
|
|
||||||
### MySQL support
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Done
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork (Done)
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
- **Shipped:** 2026-07-14
|
|
||||||
|
|
||||||
Let a project point `Lib\Db` at MySQL — but went further than the original
|
|
||||||
spec ("point the `Db` wrapper at MySQL instead of SQLite"): a real
|
|
||||||
requirement surfaced during implementation that a single request may need
|
|
||||||
**both** at once (sidecars have full access to any `Lib\` class, so nothing
|
|
||||||
stops one from querying this site's own SQLite data and a legacy MySQL
|
|
||||||
database in the same request). So `Lib\Db` was redesigned around multiple,
|
|
||||||
independently-configured, simultaneously-open named connections
|
|
||||||
(`config['db_connections']`, keyed by name — `'default'` is the only
|
|
||||||
required one) rather than one global connection switched by a single
|
|
||||||
`db_driver` key. This superseded the flat `db_driver`/`db_path`/
|
|
||||||
`db_migrations_dir` keys the SQLite groundwork entry above originally
|
|
||||||
shipped with (which had no downstream consumers yet, so no migration path
|
|
||||||
was needed). `Db::query(string $sql, array $params = [], string
|
|
||||||
$connection = 'default')` and `Db::connection(string $name = 'default')`
|
|
||||||
both default to `'default'` so the common single-database case reads the
|
|
||||||
same as before; a third/first argument targets any other configured
|
|
||||||
connection. Each connection has its own lazy PDO connect (`'sqlite'` and
|
|
||||||
`'mysql'` drivers implemented), its own optional `migrations_dir`, and its
|
|
||||||
own independent `schema_migrations` table — verified for real (not just by
|
|
||||||
inspection) by running a local MariaDB instance alongside the existing
|
|
||||||
SQLite connection and executing queries against both from the same script.
|
|
||||||
`db_connections` needed one deliberate exception to the project's usual
|
|
||||||
shallow config-merge rule — merged one level deeper, by connection name, so
|
|
||||||
an `App/config.php` adding a `legacy` connection doesn't silently delete
|
|
||||||
the framework's `default` one — documented in `AGENTS.md` and
|
|
||||||
`/admin/docs/database`, including the exact "capture defaults before the
|
|
||||||
top-level `array_merge()` overwrites them" ordering bug hit once while
|
|
||||||
building this (caught by the end-to-end MySQL test, not by review).
|
|
||||||
|
|
||||||
### SQLite groundwork
|
### SQLite groundwork
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -671,38 +360,6 @@ entry below (shipped 2026-07-14) for the connection/config/migration API,
|
|||||||
which superseded the single-connection shape (`db_driver`/`db_path`/
|
which superseded the single-connection shape (`db_driver`/`db_path`/
|
||||||
`db_migrations_dir` config keys) this entry originally shipped with.
|
`db_migrations_dir` config keys) this entry originally shipped with.
|
||||||
|
|
||||||
### Copy-to-clipboard button on code blocks
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Done
|
|
||||||
- **Priority:** Low
|
|
||||||
- **Added:** 2026-07-13
|
|
||||||
- **Shipped:** 2026-07-14
|
|
||||||
|
|
||||||
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
|
|
||||||
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — added a
|
|
||||||
small button on hover that copies the block's text via the
|
|
||||||
[Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
|
|
||||||
(`navigator.clipboard.writeText(...)`), consistent with this project's
|
|
||||||
no-build-step philosophy: vanilla JS, no dependency, same event-delegation
|
|
||||||
pattern as the dark/light theme toggle (`novaconium/pages/_layout/nav.twig`).
|
|
||||||
Implemented as a single site-wide partial
|
|
||||||
(`novaconium/pages/_layout/code-copy.twig`, included from
|
|
||||||
`_layout/layout.twig`'s footer) that injects a button into every `<pre>`
|
|
||||||
containing a `<code>` on `DOMContentLoaded`, rather than touching each doc
|
|
||||||
page's markup individually. Added `copy`/`check` icons to
|
|
||||||
`novaconium/pages/_layout/icons.twig` and matching styles in
|
|
||||||
`novaconium/sass/main.sass` (hover/focus-revealed button, `.copied` state).
|
|
||||||
Icon markup reaches JS via two `<template>` elements read through
|
|
||||||
`.innerHTML`, not Twig's `|escape('js')` — that filter calls
|
|
||||||
`Twig\Runtime\mb_ord()` and fatals without the `mbstring` extension, hit
|
|
||||||
for real once on a bare-PHP install; see the standing rule added to
|
|
||||||
`AGENTS.md` next to the existing `|slice`/`mb_substr` gotcha.
|
|
||||||
Copies via `code.textContent`, not `innerHTML`, so HTML-entity-escaped
|
|
||||||
samples (e.g. `<h1>` in the SEO starter template) come out as literal
|
|
||||||
characters rather than escaped markup. Shows a "Copied!" label/checkmark
|
|
||||||
for 1.5s after a successful copy.
|
|
||||||
|
|
||||||
## Won't Do
|
## Won't Do
|
||||||
|
|
||||||
### 404 tracking
|
### 404 tracking
|
||||||
|
|||||||
@@ -65,10 +65,15 @@ if (strlen($password) < 8) {
|
|||||||
|
|
||||||
// Always role 'admin', as the script name says — /admin/users is the
|
// Always role 'admin', as the script name says — /admin/users is the
|
||||||
// place to create registered users; this exists for first-user setup and
|
// place to create registered users; this exists for first-user setup and
|
||||||
// lockout recovery, both of which need an admin.
|
// lockout recovery, both of which need an admin. Auto-verified for the
|
||||||
|
// same reason /admin/users auto-verifies the very first user: an admin
|
||||||
|
// created this way has nobody else to have vouched for them, and lockout
|
||||||
|
// recovery in particular can't depend on a mail transport being
|
||||||
|
// configured (see /admin/docs/admin-auth's email verification section).
|
||||||
|
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||||
Db::query(
|
Db::query(
|
||||||
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, 'admin', '', 0, ?)",
|
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, 'admin', '', 0, ?, ?)",
|
||||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), gmdate('Y-m-d\TH:i:s\Z')]
|
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $now, $now]
|
||||||
);
|
);
|
||||||
|
|
||||||
echo "User '{$username}' created.\n";
|
echo "User '{$username}' created.\n";
|
||||||
|
|||||||
@@ -101,4 +101,42 @@ return [
|
|||||||
// deploy step.
|
// deploy step.
|
||||||
'content_index_enabled' => false,
|
'content_index_enabled' => false,
|
||||||
'content_index_auto' => true,
|
'content_index_auto' => true,
|
||||||
|
|
||||||
|
// Media manager (/admin/media — see /admin/docs/media-manager): an
|
||||||
|
// upload/browse/delete UI for files under public/uploads/, covered by
|
||||||
|
// the existing /admin/* auth gate the moment the page exists, so
|
||||||
|
// there's no separate *_enabled flag here (unlike admin_auth_enabled/
|
||||||
|
// content_index_enabled above, it has no SQLite dependency to gate).
|
||||||
|
// media_upload_extensions is an allowlist, matched case-insensitively
|
||||||
|
// against the uploaded filename's extension; media_upload_max_bytes
|
||||||
|
// caps a single file's size (checked against both $_FILES' reported
|
||||||
|
// size and PHP's own upload_max_filesize/post_max_size ini limits,
|
||||||
|
// see /admin/docs/media-manager). 'svg' is deliberately NOT in this
|
||||||
|
// default list: files under public/uploads/ are served directly from
|
||||||
|
// this origin, and an SVG can carry inline <script>, making it a
|
||||||
|
// stored-XSS vector. Add 'svg' back via App/config.php only if you
|
||||||
|
// serve uploads with a restrictive CSP or Content-Disposition:
|
||||||
|
// attachment.
|
||||||
|
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'txt', 'zip'],
|
||||||
|
'media_upload_max_bytes' => 10 * 1024 * 1024,
|
||||||
|
|
||||||
|
// Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's
|
||||||
|
// email verification section) — separate from Mailer::send(), the
|
||||||
|
// contact form's own log-to-file stand-in, which this doesn't touch.
|
||||||
|
// 'log' (default) writes to the same novaconium/contact-log.txt with no
|
||||||
|
// external dependency, so a fresh checkout with admin_auth_enabled on
|
||||||
|
// can still create/verify accounts (via the logged link) with zero
|
||||||
|
// setup. 'mailjet' sends through MailJet's Send API v3.1
|
||||||
|
// (https://api.mailjet.com/v3.1/send) using the four keys below — set
|
||||||
|
// all four via App/config.php, e.g.:
|
||||||
|
// 'mail_driver' => 'mailjet',
|
||||||
|
// 'mail_from_email' => 'noreply@example.com',
|
||||||
|
// 'mail_from_name' => 'Example Site',
|
||||||
|
// 'mailjet_api_key' => '...',
|
||||||
|
// 'mailjet_api_secret' => '...',
|
||||||
|
'mail_driver' => 'log',
|
||||||
|
'mail_from_email' => '',
|
||||||
|
'mail_from_name' => '',
|
||||||
|
'mailjet_api_key' => '',
|
||||||
|
'mailjet_api_secret' => '',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable comment thread any sidecar can attach to any page (not just
|
||||||
|
* blog posts), the same way Lib\SpamGuard/Lib\FormValidator are reusable
|
||||||
|
* across any form rather than hardcoded to the contact page. See
|
||||||
|
* /admin/docs/comments and novaconium/pages/_partials/comments/thread.twig
|
||||||
|
* for the paired Twig partial.
|
||||||
|
*
|
||||||
|
* Comments are tied to a real logged-in account (App\AdminAuth::currentUser()),
|
||||||
|
* never anonymous name/email fields — and since currentUser() already
|
||||||
|
* excludes disabled and unverified accounts, any user id passed to
|
||||||
|
* create() is already a real, verified account with nothing further to
|
||||||
|
* check here. Auto-approved on submission (no pending/approved state) —
|
||||||
|
* only a verified account can post one in the first place, so there's no
|
||||||
|
* anonymous-spam vector to pre-vet against — with a single is_hidden flag
|
||||||
|
* an admin can flip after the fact at /admin/comments, mirroring how
|
||||||
|
* /admin/users disables rather than pre-vets accounts.
|
||||||
|
*/
|
||||||
|
final class Comments
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current route's path, e.g. "/blog/hello-world" — the natural
|
||||||
|
* page_path key for forPage()/create(), derived from the request
|
||||||
|
* itself so callers never hand-write a path that could drift from the
|
||||||
|
* actual route.
|
||||||
|
*/
|
||||||
|
public static function currentPagePath(): string
|
||||||
|
{
|
||||||
|
return (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible (non-hidden) comments for a page, oldest first, joined to
|
||||||
|
* the posting user's username.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function forPage(string $pagePath): array
|
||||||
|
{
|
||||||
|
return Db::query(
|
||||||
|
'SELECT comments.id, comments.body, comments.created_at, users.username ' .
|
||||||
|
'FROM comments JOIN users ON users.id = comments.user_id ' .
|
||||||
|
'WHERE comments.page_path = ? AND comments.is_hidden = 0 ' .
|
||||||
|
'ORDER BY comments.created_at ASC',
|
||||||
|
[$pagePath]
|
||||||
|
)->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(string $pagePath, int $userId, string $body): void
|
||||||
|
{
|
||||||
|
Db::query(
|
||||||
|
'INSERT INTO comments (page_path, user_id, body, is_hidden, created_at) VALUES (?, ?, ?, 0, ?)',
|
||||||
|
[$pagePath, $userId, $body, gmdate('Y-m-d\TH:i:s\Z')]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setHidden(int $id, bool $hidden): void
|
||||||
|
{
|
||||||
|
Db::query('UPDATE comments SET is_hidden = ? WHERE id = ?', [$hidden ? 1 : 0, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $id): void
|
||||||
|
{
|
||||||
|
Db::query('DELETE FROM comments WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every comment, hidden or not, newest first — for the /admin/comments
|
||||||
|
* moderation list.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return Db::query(
|
||||||
|
'SELECT comments.id, comments.page_path, comments.body, comments.created_at, comments.is_hidden, users.username ' .
|
||||||
|
'FROM comments JOIN users ON users.id = comments.user_id ' .
|
||||||
|
'ORDER BY comments.created_at DESC'
|
||||||
|
)->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,4 +20,93 @@ final class Mailer
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactional mail (verification links, and later password reset) —
|
||||||
|
* separate from send() above, which is specifically the contact form's
|
||||||
|
* "notify the site owner of a submission" shape and stays untouched.
|
||||||
|
* Driver-dispatched via config['mail_driver'] (see /admin/docs/admin-auth
|
||||||
|
* and novaconium/config.php): 'log' (default, zero external dependency,
|
||||||
|
* same novaconium/contact-log.txt as send() above but a distinguishable
|
||||||
|
* line prefix) or 'mailjet' (Send API v3.1, https://api.mailjet.com/v3.1/send,
|
||||||
|
* Basic auth mailjet_api_key:mailjet_api_secret). Adding a future
|
||||||
|
* provider means adding a case here and a private sendVia*() method —
|
||||||
|
* callers never change.
|
||||||
|
*/
|
||||||
|
public function sendMail(string $toEmail, string $subject, string $textBody): bool
|
||||||
|
{
|
||||||
|
$config = self::config();
|
||||||
|
|
||||||
|
return match ($config['mail_driver']) {
|
||||||
|
'mailjet' => $this->sendViaMailjet($config, $toEmail, $subject, $textBody),
|
||||||
|
default => $this->sendViaLog($toEmail, $subject, $textBody),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendViaLog(string $toEmail, string $subject, string $textBody): bool
|
||||||
|
{
|
||||||
|
$line = sprintf(
|
||||||
|
"[%s] MAIL <%s> %s: %s\n",
|
||||||
|
date('c'),
|
||||||
|
$toEmail,
|
||||||
|
$subject,
|
||||||
|
str_replace("\n", ' ', $textBody)
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents(__DIR__ . '/../contact-log.txt', $line, FILE_APPEND);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendViaMailjet(array $config, string $toEmail, string $subject, string $textBody): bool
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'Messages' => [
|
||||||
|
[
|
||||||
|
'From' => [
|
||||||
|
'Email' => $config['mail_from_email'],
|
||||||
|
'Name' => $config['mail_from_name'],
|
||||||
|
],
|
||||||
|
'To' => [
|
||||||
|
['Email' => $toEmail],
|
||||||
|
],
|
||||||
|
'Subject' => $subject,
|
||||||
|
'TextPart' => $textBody,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init('https://api.mailjet.com/v3.1/send');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_USERPWD => $config['mailjet_api_key'] . ':' . $config['mailjet_api_secret'],
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
return $status >= 200 && $status < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same two-step App-over-novaconium shallow merge every entry point
|
||||||
|
* duplicates (see novaconium/bootstrap.php, Lib\Db::config()) rather
|
||||||
|
* than a shared Config class — no such class exists in this codebase.
|
||||||
|
*/
|
||||||
|
private static function config(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ CREATE VIRTUAL TABLE IF NOT EXISTS content_search USING fts5(route UNINDEXED, ti
|
|||||||
CREATE TABLE IF NOT EXISTS content_index_meta (
|
CREATE TABLE IF NOT EXISTS content_index_meta (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
newest_source_mtime INTEGER NOT NULL,
|
newest_source_mtime INTEGER NOT NULL,
|
||||||
|
source_count INTEGER NOT NULL DEFAULT 0,
|
||||||
indexed_at TEXT NOT NULL
|
indexed_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
role TEXT NOT NULL DEFAULT 'registered',
|
role TEXT NOT NULL DEFAULT 'registered',
|
||||||
user_group TEXT NOT NULL DEFAULT '',
|
user_group TEXT NOT NULL DEFAULT '',
|
||||||
is_disabled INTEGER NOT NULL DEFAULT 0,
|
is_disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
verified_at TEXT,
|
||||||
|
verification_token TEXT,
|
||||||
|
verification_token_expires_at TEXT
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_path TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_comments_page_path ON comments(page_path);
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<meta name="keywords" content="{% block keywords %}{% endblock %}">
|
<meta name="keywords" content="{% block keywords %}{% endblock %}">
|
||||||
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
|
<link rel="canonical" href="{% block canonical %}{{ request_path|default('/') }}{% endblock %}">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="generator" content="novaconium">
|
||||||
|
|
||||||
{# tags/changefreq/priority (below) are metadata-only, not meant to be
|
{# tags/changefreq/priority (below) are metadata-only, not meant to be
|
||||||
visible on the page. A block tag always emits its content wherever
|
visible on the page. A block tag always emits its content wherever
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{#
|
||||||
|
Reusable comment thread — included from any page whose sidecar
|
||||||
|
returns 'comments' (Lib\Comments::forPage()), 'currentUser'
|
||||||
|
(App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and
|
||||||
|
'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See
|
||||||
|
/admin/docs/comments for the full sidecar contract this expects, and
|
||||||
|
App/pages/blog/comments-demo/index.php for a worked example. A page
|
||||||
|
with no 'comments' key never includes this at all — see the
|
||||||
|
surrounding {% if comments is defined %} in blog/_layout/layout.twig.
|
||||||
|
#}
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
<section class="comments">
|
||||||
|
<h2 class="icon-heading">{{ icons.users() }}Comments</h2>
|
||||||
|
|
||||||
|
{% if comments is empty %}
|
||||||
|
<p>No comments yet.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for comment in comments %}
|
||||||
|
<article class="comment">
|
||||||
|
<p><strong>{{ comment.username }}</strong> — <small>{{ comment.created_at }}</small></p>
|
||||||
|
<p>{{ comment.body }}</p>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if currentUser %}
|
||||||
|
<form method="post" action="{{ request_path|default('/') }}">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<div class="hp-field" aria-hidden="true">
|
||||||
|
<label for="comment-website">Leave this field blank</label>
|
||||||
|
<input type="text" id="comment-website" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="rendered_at" value="{{ renderedAt }}">
|
||||||
|
<p>
|
||||||
|
<label for="comment-body">Add a comment</label><br>
|
||||||
|
<textarea id="comment-body" name="body" rows="4"></textarea>
|
||||||
|
{% if commentError %}<br><small>{{ commentError }}</small>{% endif %}
|
||||||
|
</p>
|
||||||
|
<button type="submit">Post comment</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p><a href="/admin/login">Log in</a> to leave a comment.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Comments;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\Session;
|
||||||
|
|
||||||
|
// No auth check here — bootstrap.php's admin gate already covers this
|
||||||
|
// route like every other /admin/* page.
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
Session::flash('comments_error', 'Your session expired before submitting — please try again.');
|
||||||
|
|
||||||
|
return Response::redirect('/admin/comments', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = Input::post('action', '');
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$target = Db::query('SELECT id FROM comments WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('comments_error', 'No such comment.');
|
||||||
|
} elseif ($action === 'hide') {
|
||||||
|
Comments::setHidden($id, true);
|
||||||
|
Session::flash('comments_notice', 'Comment hidden.');
|
||||||
|
} elseif ($action === 'show') {
|
||||||
|
Comments::setHidden($id, false);
|
||||||
|
Session::flash('comments_notice', 'Comment shown.');
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
Comments::delete($id);
|
||||||
|
Session::flash('comments_notice', 'Comment deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::redirect('/admin/comments', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncated in PHP, not with Twig's |slice — that filter calls
|
||||||
|
// mb_substr() unconditionally, a hard mbstring dependency this project
|
||||||
|
// deliberately avoids (see AGENTS.md).
|
||||||
|
$truncate = static function (string $body): string {
|
||||||
|
$limit = 140;
|
||||||
|
$length = function_exists('mb_strlen') ? mb_strlen($body) : strlen($body);
|
||||||
|
if ($length <= $limit) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (function_exists('mb_substr') ? mb_substr($body, 0, $limit) : substr($body, 0, $limit)) . '…';
|
||||||
|
};
|
||||||
|
|
||||||
|
$comments = Comments::all();
|
||||||
|
foreach ($comments as &$comment) {
|
||||||
|
$comment['excerpt'] = $truncate($comment['body']);
|
||||||
|
}
|
||||||
|
unset($comment);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'comments' => $comments,
|
||||||
|
'notice' => Session::getFlash('comments_notice'),
|
||||||
|
'error' => Session::getFlash('comments_error'),
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Comments{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Moderate comments left across the site.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h1 class="icon-heading">{{ icons.users() }}Comments</h1>
|
||||||
|
|
||||||
|
<p>Every comment left via <code>Lib\Comments</code>, across every page — auto-approved on submission (only a verified account can post one), moderated here after the fact instead of a pending queue. Hiding a comment removes it from its page immediately; deleting it is permanent. See <a class="icon-link" href="/admin/docs/comments">{{ icons.book() }}Comments</a>.</p>
|
||||||
|
|
||||||
|
{% if notice %}
|
||||||
|
<p><strong>{{ notice }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p><strong>{{ error }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if comments is empty %}
|
||||||
|
<p>No comments yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Page</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Comment</th>
|
||||||
|
<th>Posted</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for comment in comments %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ comment.page_path }}">{{ comment.page_path }}</a></td>
|
||||||
|
<td>{{ comment.username }}</td>
|
||||||
|
<td>{{ comment.excerpt }}</td>
|
||||||
|
<td>{{ comment.created_at }}</td>
|
||||||
|
<td>{{ comment.is_hidden ? 'Hidden' : 'Visible' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/comments">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="id" value="{{ comment.id }}">
|
||||||
|
<input type="hidden" name="action" value="{{ comment.is_hidden ? 'show' : 'hide' }}">
|
||||||
|
<button type="submit">{{ comment.is_hidden ? 'Show' : 'Hide' }}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/comments">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="id" value="{{ comment.id }}">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li>
|
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Overview</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li>
|
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/docker">{{ icons.book() }}Docker</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li>
|
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li>
|
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
|
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a></li>
|
||||||
@@ -21,6 +22,8 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
|
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li>
|
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
|
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
|
||||||
|
|||||||
@@ -37,7 +37,20 @@ return [
|
|||||||
|
|
||||||
<p>The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an <em>admin</em> — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at <code>/admin/users</code>. Running it <em>before</em> flipping <code>admin_auth_enabled</code> on is the safer order — the open setup window then never exists at all.</p>
|
<p>The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an <em>admin</em> — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at <code>/admin/users</code>. Running it <em>before</em> flipping <code>admin_auth_enabled</code> on is the safer order — the open setup window then never exists at all.</p>
|
||||||
|
|
||||||
<p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username) or for any mail yet, but required up front so the planned email-verification flow (see <code>novaconium/ISSUES.md</code>) has an address for every account that already exists by then.</p>
|
<p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username), but every account after the first now must verify it before logging in (see below).</p>
|
||||||
|
|
||||||
|
<h2>Email verification</h2>
|
||||||
|
|
||||||
|
<p>Every user created after the first must click a link emailed to their address before they can log in at all — <code>AdminAuth::attempt()</code> fails a login the same generic way it fails a disabled account or a wrong password, with no distinction made to an anonymous caller between "wrong password", "disabled", and "unverified" (the <code>verified_at</code> column in <code>novaconium/migrations/0002_create_users.sql</code>). Two accounts are exempt by design, both following the same reasoning as the last-active-admin guard elsewhere on this page — verification can't depend on a mail transport actually being configured:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>The very first user</strong> (created at <a href="/admin/users">/admin/users</a> or via the CLI below) is auto-verified at creation — there's no other admin to have vouched for them, and they're logged in immediately afterward regardless.</li>
|
||||||
|
<li><strong>Every row that existed before this feature shipped</strong> is grandfathered — the migration backfills <code>verified_at</code> from <code>created_at</code>, so nobody who could already log in gets locked out retroactively.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Every other new account gets a 24-hour verification link (<code>bin2hex(random_bytes(32))</code>, the same token pattern <code>Lib\Csrf::token()</code> uses) sent via <code>Lib\Mailer::sendMail()</code> to <code>/verify-email?token=...</code>. That route follows the same GET-confirms/POST-mutates shape as <a href="/admin/logout">/admin/logout</a>, and for the same two reasons: the content-index crawl hits every routable page as a forced GET, and email-security scanners prefetch links before a human clicks — either would burn a GET-mutates link's token. An invalid, already-used, or expired token never touches the database on either method — it just renders an "invalid or expired" page pointing back at <code>/admin/users</code>. Changing an account's email (the <code>email</code> action at <code>/admin/users</code>) resets verification and sends a fresh link to the <em>new</em> address, since an unconfirmed new address shouldn't inherit the old one's verified status — and because <code>AdminAuth::currentUser()</code> re-checks <code>verified_at</code> on every request (the same immediate re-check <code>is_disabled</code> already gets), a live session is locked out on its very next request too, not just future logins.</p>
|
||||||
|
|
||||||
|
<p><code>Lib\Mailer::sendMail()</code> is separate from the pre-existing <code>Mailer::send()</code> the contact form uses (unchanged) — it's driver-dispatched via <code>mail_driver</code> in <code>App/config.php</code>: <code>'log'</code> (the default) appends to the same <code>novaconium/contact-log.txt</code> the contact form uses, so a fresh checkout can create and verify accounts with zero external dependency; <code>'mailjet'</code> sends through MailJet's Send API v3.1 using <code>mailjet_api_key</code>/<code>mailjet_api_secret</code> plus <code>mail_from_email</code>/<code>mail_from_name</code>. Adding a different provider later means adding a new case to that dispatch — callers of <code>sendMail()</code> never change.</p>
|
||||||
|
|
||||||
<h2>Managing users</h2>
|
<h2>Managing users</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Comments{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Lib\Comments — a reusable comment thread any page can attach to itself via its own sidecar.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Comments</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Comments</code> is a self-hosted comment thread — no third-party service (Disqus, Commento, etc.) — a plain static class any sidecar can call to attach comments to any page, not just blog posts, the same way <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}<code>Lib\SpamGuard</code>/<code>Lib\FormValidator</code></a> are reusable across any form rather than hardcoded to the contact page. It depends on <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> being enabled — comments are tied to a real logged-in account (<code>App\AdminAuth::currentUser()</code>), never anonymous name/email fields, since <code>currentUser()</code> already excludes disabled and unverified accounts, there's nothing further to check before accepting a comment from whoever it returns.</p>
|
||||||
|
|
||||||
|
<h2>Auto-approve, moderate after</h2>
|
||||||
|
|
||||||
|
<p>A comment is visible the instant it's posted — there's no pending-approval queue. Since only a verified account can post one at all, there's no anonymous-spam vector to pre-vet against, the same reasoning <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> already applies by disabling rather than pre-vetting accounts. An admin can hide or permanently delete any comment after the fact at <a href="/admin/comments">/admin/comments</a> — hiding removes it from its page immediately (it's excluded at the query level, not just visually), deleting is permanent.</p>
|
||||||
|
|
||||||
|
<h2>Attaching a thread to a page</h2>
|
||||||
|
|
||||||
|
<p>A page needs its own sidecar to use <code>Lib\Comments</code> — this codebase has no client-side JS/fetch anywhere, every dynamic feature is a plain server-rendered POST form, and comments are no different. Giving a page a sidecar is exactly what excludes it from the <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}static HTML cache</a> (only sidecar-less pages are ever cached), so attaching comments to a previously sidecar-less page is an explicit, per-page tradeoff — the same one <a href="/admin/media">/admin/media</a> and <a href="/search">/search</a> already accept. See <code>App/pages/blog/comments-demo/index.php</code> for a full worked example (the one post under <code>App/pages/blog/</code> with a sidecar, specifically for this):</p>
|
||||||
|
|
||||||
|
<pre><code>$pagePath = Comments::currentPagePath();
|
||||||
|
$user = AdminAuth::currentUser();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) { ... }
|
||||||
|
if ($user === null) { ... } // must be logged in
|
||||||
|
// validate $body, run it past SpamGuard, then:
|
||||||
|
Comments::create($pagePath, $user['id'], $body);
|
||||||
|
return Response::redirect($pagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'comments' => Comments::forPage($pagePath),
|
||||||
|
'currentUser' => $user,
|
||||||
|
// ...csrfField/csrfToken/renderedAt, same as any other form sidecar
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p>Then include the reusable partial, <code>novaconium/pages/_partials/comments/thread.twig</code> — a <code>_</code>-prefixed segment, so it's never itself routable (see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a>'s reserved-segments rule), the same convention <code>_layout/</code> already uses:</p>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight">{% verbatim %}{% if comments is defined %}
|
||||||
|
{% include '_partials/comments/thread.twig' %}
|
||||||
|
{% endif %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>Guarding the include with <code>comments is defined</code> — as <code>App/pages/blog/_layout/layout.twig</code> does — means a layout shared by both comment-enabled and sidecar-less pages can include it unconditionally without breaking the pages that never set the key. The partial itself expects <code>comments</code>, <code>currentUser</code>, <code>csrfField</code>/<code>csrfToken</code>, and <code>renderedAt</code> in context — the same shape returned above — and renders the thread plus a honeypot-carrying submit form (mirroring the contact form's spam prevention, see <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a>) when someone's logged in, or a "log in to comment" link otherwise.</p>
|
||||||
|
|
||||||
|
<h2>Schema</h2>
|
||||||
|
|
||||||
|
<p>Ships as a framework migration, <code>novaconium/migrations/0003_create_comments.sql</code> — a <code>comments</code> table (<code>id</code>, <code>page_path</code>, <code>user_id</code>, <code>body</code>, <code>is_hidden</code>, <code>created_at</code>) on the same <code>default</code> connection as <code>users</code>, applied automatically the first time anything touches it, no manual step. <code>page_path</code> is whatever <code>Comments::currentPagePath()</code> derives from the request (e.g. <code>/blog/comments-demo</code>) — free-text, not a foreign key, so a thread survives even if the page it was attached to is later restructured.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Docker{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Running novaconium in a container: the Apache/PHP image, its four bind-mounted paths, and how docker-entrypoint.sh seeds and permissions them.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Docker</h1>
|
||||||
|
|
||||||
|
<p>The root <code>Dockerfile</code> builds on the official <a href="https://hub.docker.com/_/php"><code>php:8.3-apache</code></a> image, with <code>mod_rewrite</code>, <code>AllowOverride All</code>, and <code>pdo_sqlite</code>/<code>pdo_mysql</code> already enabled — nothing else in <a href="/admin/docs/getting-started">Getting started</a>'s "Deploy on Apache" section needs configuring by hand.</p>
|
||||||
|
|
||||||
|
<pre><code>docker compose up --build</code></pre>
|
||||||
|
|
||||||
|
<p>Visit <code>http://localhost:8080/</code>.</p>
|
||||||
|
|
||||||
|
<h2>The bind mounts</h2>
|
||||||
|
|
||||||
|
<p><code>docker-compose.yml</code> bind-mounts four host paths under <code>${VOL_PATH:-/data}/novaconium/</code> (override <code>VOL_PATH</code> in the environment to relocate all four at once) so a project's content, uploads, cache, and database live on the host — editable without a rebuild, and untouched by the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>App</code> → <code>/var/www/html/App</code> — the project itself (pages, lib, config, migrations). Edit it on the host; changes show up after <code>docker compose restart web</code>, no rebuild.</li>
|
||||||
|
<li><code>cache</code> → <code>public/cache/</code> — the static HTML page cache (see <a href="/admin/docs/caching">Static caching</a>). Disposable; clear it from inside the container with <code>docker compose exec web php novaconium/bin/clear-cache.php</code>.</li>
|
||||||
|
<li><code>uploads</code> → <code>public/uploads/</code> — files uploaded through <a class="icon-link" href="/admin/docs/media-manager">Media manager</a> (<code>/admin/media</code>).</li>
|
||||||
|
<li><code>data</code> → <code>data/</code> — holds <code>data/novaconium.sqlite</code> if <a href="/admin/docs/database">Database</a>-backed features (admin auth, content index) are enabled.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>A bind mount to a host directory shadows whatever the image's <code>COPY</code> put at that path — including with nothing at all, if the host directory doesn't exist yet or is empty. Docker doesn't seed a bind mount from image content the way it seeds a fresh named volume. Two consequences the plain image can't handle by itself, both worked around by <code>docker-entrypoint.sh</code> (the container's <code>ENTRYPOINT</code>, runs once per start before Apache):</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Empty <code>App/</code> on first run.</strong> The Dockerfile stashes a pristine copy of the baked-in <code>App/</code> at <code>/opt/novaconium-app-default</code> at build time. If the bind-mounted <code>/var/www/html/App</code> is empty when the container starts, the entrypoint copies that pristine copy in — so a first <code>docker compose up</code> against a fresh, empty <code>${VOL_PATH}/novaconium/App</code> on the host produces a working site instead of a blank one. It only seeds when the directory is empty, so it never clobbers content you've already put there.</li>
|
||||||
|
<li><strong>Permissions.</strong> A bind mount keeps the host directory's ownership, not the image's — the build-time <code>chown -R www-data:www-data</code> in the Dockerfile only applies to the image layer, not to whatever gets mounted over it. The entrypoint re-runs <code>chown -R www-data:www-data</code> on all four mounted paths on every container start, so Apache's worker user (<code>www-data</code>, the Debian default) can always write to them regardless of the host-side UID/GID — no manual <code>chmod</code> on the host required.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>MySQL</h2>
|
||||||
|
|
||||||
|
<p><code>Lib\Db</code> supports MySQL alongside or instead of SQLite (see <a href="/admin/docs/database">Database</a>). <code>docker-compose.yml</code> has a commented-out <code>db</code> service and <code>mysql-data</code> volume — uncomment both, add a <code>db_connections</code> entry with <code>driver: mysql</code> and matching credentials in <code>App/config.php</code>, and the app container can reach it at hostname <code>db</code>.</p>
|
||||||
|
|
||||||
|
<h2>Pinned base image</h2>
|
||||||
|
|
||||||
|
<p>The Dockerfile pins an exact tag (<code>php:8.3-apache</code>), not a floating <code>php:apache</code>, so a rebuild months from now installs the same PHP/Apache/Debian base instead of whatever the tag happens to point at that day. Bump the tag in the <code>FROM</code> line deliberately (e.g. to pick up a PHP security release), then rebuild:</p>
|
||||||
|
|
||||||
|
<pre><code>docker build --no-cache -t novaconium-beta .</code></pre>
|
||||||
|
|
||||||
|
<p><code>--no-cache</code> is worth using any time you change the Dockerfile itself (not just <code>App/</code> content) — Docker otherwise reuses a cached layer for an unchanged-looking <code>RUN</code> step, and <code>docker-compose.yml</code> here uses <code>image:</code> rather than <code>build:</code>, so <code>docker compose up</code> alone never rebuilds at all; you have to <code>docker build</code> (and re-tag, if needed) yourself first.</p>
|
||||||
|
|
||||||
|
<h2>What the image adds on top of <code>php:8.3-apache</code></h2>
|
||||||
|
|
||||||
|
<p>The base image ships Apache with <code>mod_rewrite</code> disabled and <code>AllowOverride None</code>, no <code>pdo_sqlite</code>/<code>pdo_mysql</code>, and a <code>/var/www/html</code> document root. The Dockerfile runs <code>a2enmod rewrite</code>, <code>docker-php-ext-install pdo_sqlite pdo_mysql</code> (after installing <code>libsqlite3-dev</code>, needed to build <code>pdo_sqlite</code>), and rewrites both the vhost and <code>apache2.conf</code> to point the document root at <code>public/</code> and set <code>AllowOverride All</code> there.</p>
|
||||||
|
|
||||||
|
<p>This is a separate Dockerfile from the one-off Dart Sass build tool described in <a href="/admin/docs/styling">Styling</a> — that one lives at <code>Dockerfile.sass</code>, a Debian-based image whose only job is running the <code>sass</code> CLI, not serving the app.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block title %}Docs{% endblock %}
|
{% block title %}Docs{% endblock %}
|
||||||
|
|
||||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, layouts, caching, styling.{% endblock %}
|
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, comments, layouts, caching, styling.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}noindex, nofollow{% endblock %}
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
<li><a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}Getting started</a> — requirements, running locally, deploying on Apache.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/docker">{{ icons.book() }}Docker</a> — the Apache/PHP image, its three volumes, and overriding <code>App/</code> without a rebuild.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> — how a URL maps to a directory under <code>App/pages/</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li>
|
<li><a class="icon-link" href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a> — where your PHP logic goes.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
|
<li><a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a> — building a custom form with a sidecar, using the novaconium validation/spam libraries.</li>
|
||||||
@@ -41,6 +42,8 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind a session login, with admin/registered roles, groups, and user management.</li>
|
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind a session login, with admin/registered roles, groups, and user management.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
|
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
|
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a> — upload, browse, and delete files under <code>public/uploads/</code> from <code>/admin/media</code>.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a> — <code>Lib\Comments</code>, a reusable comment thread any page can attach to itself via its own sidecar.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> — pages and layouts are overridable, just like <code>Lib\</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a> — how sidecar-less pages get served as static HTML.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a> — the meta tags every page gets for free, and how to override them.</li>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Media manager{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Upload, browse, and delete files under public/uploads/ from /admin/media — extension allowlist, filename sanitization, max upload size.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.tag() }}Media manager</h1>
|
||||||
|
|
||||||
|
<p><a href="/admin/media">/admin/media</a> is an upload/browse/delete UI for files (images, PDFs, and whatever else you allow) so sidecars and Twig templates have a consistent place to reference uploaded assets from — e.g. a blog post's header image — instead of authors manually copying files into <code>public/</code>. It's a plain directory, <code>public/uploads/</code>, not a database-backed feature: files are already static assets, so there's nothing for <code>Lib\Db</code> to do here.</p>
|
||||||
|
|
||||||
|
<p>Covered by the existing <code>/admin/*</code> auth gate the moment the page exists — unlike <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> or <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}the content index</a>, it has no SQLite dependency to gate behind its own flag, so there's no <code>media_manager_enabled</code> key — it's simply on wherever <code>/admin/*</code> is reachable.</p>
|
||||||
|
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
|
||||||
|
<p>Two keys in <code>App/config.php</code> (framework defaults in <code>novaconium/config.php</code>):</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>media_upload_extensions</code> — an allowlist of lowercase extensions (no leading dot), matched case-insensitively against the uploaded filename. Defaults to <code>['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip']</code>.</li>
|
||||||
|
<li><code>media_upload_max_bytes</code> — caps a single file's size. Defaults to 10 MB. A file larger than PHP's own <code>upload_max_filesize</code>/<code>post_max_size</code> ini limits is rejected by PHP itself before this check ever runs (reported via <code>UPLOAD_ERR_INI_SIZE</code>/<code>UPLOAD_ERR_FORM_SIZE</code>) — raise those ini limits too if you raise <code>media_upload_max_bytes</code> past them.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Safety handling</h2>
|
||||||
|
|
||||||
|
<p>Three things every upload goes through, in <code>novaconium/pages/admin/media/index.php</code>:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Extension allowlist</strong> — rejected before the file ever touches disk if its extension isn't in <code>media_upload_extensions</code>.</li>
|
||||||
|
<li><strong>Filename sanitization</strong> — the uploaded filename is run through <code>basename()</code> (strips directory components and <code>..</code>) and then reduced to a safe character set (<code>A-Za-z0-9._-</code>). A name collision gets a <code>-1</code>, <code>-2</code>, etc. suffix rather than overwriting the existing file. Deletes re-derive the same safe name from the request and re-verify with <code>realpath()</code> that the resolved path still lands inside <code>public/uploads/</code> before unlinking anything.</li>
|
||||||
|
<li><strong>Max upload size</strong> — checked against both <code>$_FILES</code>' reported size and PHP's own ini limits (see above).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>Uploaded files are served directly by Apache at <code>/uploads/<filename></code> — <code>public/uploads/</code> is a plain static directory, not routed through the framework, the same as <code>public/cache/</code>. It's gitignored per-file with a tracked <code>.gitkeep</code>, the same convention as <code>data/</code> (see <a class="icon-link" href="/admin/docs/project-layout">{{ icons.sitemap() }}Project layout</a>) — uploads are runtime content, not something a fresh checkout should ship with.</p>
|
||||||
|
|
||||||
|
<p>There's no metadata store (alt text, captions) — if you need that later, it's a natural fit for <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}SQLite</a> rather than encoding it into filenames.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
|
<p>There's no PHP-based Sass compiler in this project (PHP options like <code>scssphp</code> only understand SCSS syntax) — this is a manual/CI build step, not something the app does at runtime.</p>
|
||||||
|
|
||||||
<p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this Dockerfile, which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
|
<p>Don't have Dart Sass installed? Run it via Docker instead — copy-paste this into a file named <code>Dockerfile.sass</code> (the project's own root <code>Dockerfile</code> is the app container, see <a href="/admin/docs/docker">Docker</a> — this is a separate, one-off build tool, so it gets its own filename), which installs the same official standalone Dart Sass release used in this environment (<code>1.101.0</code>, via <code>pacman -S dart-sass</code> on Arch), not the npm-wrapped build:</p>
|
||||||
|
|
||||||
<pre><code>FROM debian:bookworm-slim
|
<pre><code>FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ ENTRYPOINT ["sass"]</code></pre>
|
|||||||
|
|
||||||
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
|
<p>Build it once, then run it the same way you'd run the local <code>sass</code> CLI (skip the leading <code>sass</code> in the command — the image's <code>ENTRYPOINT</code> already supplies it):</p>
|
||||||
|
|
||||||
<pre><code>docker build -t novaconium-sass -f Dockerfile .
|
<pre><code>docker build -t novaconium-sass -f Dockerfile.sass .
|
||||||
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
||||||
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
|
--load-path=App/sass --load-path=novaconium/sass/defaults novaconium/sass/main.sass public/css/main.css</code></pre>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a class="icon-link" href="/admin/clear-cache">{{ icons.trash() }}Clear cache</a></li>
|
<li><a class="icon-link" href="/admin/clear-cache">{{ icons.trash() }}Clear cache</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
|
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/media">{{ icons.tag() }}Media</a></li>
|
||||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
|
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
|
||||||
|
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/comments">{{ icons.users() }}Comments</a></li>{% endif %}
|
||||||
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
|
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\Session;
|
||||||
|
|
||||||
|
// Same two-step config load as other /admin/* sidecars that read config
|
||||||
|
// directly (e.g. admin/users) — no *_enabled flag to check here, this
|
||||||
|
// feature has no SQLite dependency, and bootstrap.php's admin gate already
|
||||||
|
// covers /admin/media like every other /admin/* route.
|
||||||
|
$config = require __DIR__ . '/../../../config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = __DIR__ . '/../../../../public/uploads';
|
||||||
|
$allowedExtensions = $config['media_upload_extensions'];
|
||||||
|
$maxBytes = $config['media_upload_max_bytes'];
|
||||||
|
|
||||||
|
// Filenames are user input (from the browser's original filename or a
|
||||||
|
// delete request) and end up in filesystem calls, so every path built from
|
||||||
|
// one is basename()'d first (strips directory components/`..`) and then
|
||||||
|
// re-verified with realpath() to land inside $uploadDir before any
|
||||||
|
// read/write/delete touches disk.
|
||||||
|
$safeName = static function (string $name): string {
|
||||||
|
$name = basename($name);
|
||||||
|
$name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? '';
|
||||||
|
$name = ltrim($name, '.');
|
||||||
|
|
||||||
|
return $name === '' ? 'file' : $name;
|
||||||
|
};
|
||||||
|
|
||||||
|
$resolveInUploadDir = static function (string $filename) use ($uploadDir): string|false {
|
||||||
|
$path = $uploadDir . '/' . $filename;
|
||||||
|
$real = realpath($path);
|
||||||
|
$realDir = realpath($uploadDir);
|
||||||
|
|
||||||
|
if ($real === false || $realDir === false || !str_starts_with($real, $realDir . DIRECTORY_SEPARATOR)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $real;
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
Session::flash('media_error', 'Your session expired before submitting — please try again.');
|
||||||
|
|
||||||
|
return Response::redirect('/admin/media', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = Input::post('action', '');
|
||||||
|
|
||||||
|
if ($action === 'upload') {
|
||||||
|
$file = $_FILES['file'] ?? null;
|
||||||
|
|
||||||
|
if ($file === null || !is_uploaded_file($file['tmp_name'] ?? '')) {
|
||||||
|
Session::flash('media_error', 'Choose a file to upload.');
|
||||||
|
} elseif ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) {
|
||||||
|
Session::flash('media_error', 'That file is too large.');
|
||||||
|
} elseif ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
Session::flash('media_error', 'Upload failed — please try again.');
|
||||||
|
} elseif ($file['size'] > $maxBytes) {
|
||||||
|
Session::flash('media_error', sprintf('That file is too large — %s max.', number_format($maxBytes / 1024 / 1024, 1) . ' MB'));
|
||||||
|
} else {
|
||||||
|
$extension = strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
if (!in_array($extension, $allowedExtensions, true)) {
|
||||||
|
Session::flash('media_error', sprintf("That file type isn\u{2019}t allowed — allowed types: %s.", implode(', ', $allowedExtensions)));
|
||||||
|
} else {
|
||||||
|
$name = $safeName((string) $file['name']);
|
||||||
|
|
||||||
|
// Avoid clobbering an existing file of the same name —
|
||||||
|
// append -1, -2, etc. before the extension until free.
|
||||||
|
$base = pathinfo($name, PATHINFO_FILENAME);
|
||||||
|
$ext = pathinfo($name, PATHINFO_EXTENSION);
|
||||||
|
$candidate = $name;
|
||||||
|
$i = 1;
|
||||||
|
|
||||||
|
while (is_file($uploadDir . '/' . $candidate)) {
|
||||||
|
$candidate = $ext !== '' ? "{$base}-{$i}.{$ext}" : "{$base}-{$i}";
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (move_uploaded_file($file['tmp_name'], $uploadDir . '/' . $candidate)) {
|
||||||
|
Session::flash('media_notice', "Uploaded \u{201C}{$candidate}\u{201D}.");
|
||||||
|
} else {
|
||||||
|
Session::flash('media_error', 'Upload failed — please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
$filename = $safeName((string) Input::post('filename', ''));
|
||||||
|
$real = $resolveInUploadDir($filename);
|
||||||
|
|
||||||
|
if ($real === false || !is_file($real)) {
|
||||||
|
Session::flash('media_error', 'No such file.');
|
||||||
|
} else {
|
||||||
|
unlink($real);
|
||||||
|
Session::flash('media_notice', "Deleted \u{201C}{$filename}\u{201D}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::redirect('/admin/media', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
if (is_dir($uploadDir)) {
|
||||||
|
foreach (scandir($uploadDir) as $entry) {
|
||||||
|
// .gitkeep keeps the otherwise-gitignored directory tracked in git
|
||||||
|
// (see .gitignore's /public/uploads/* rule) — not a real upload.
|
||||||
|
if ($entry === '.' || $entry === '..' || $entry === '.gitkeep' || str_starts_with($entry, '.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $uploadDir . '/' . $entry;
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = [
|
||||||
|
'name' => $entry,
|
||||||
|
'size' => filesize($path),
|
||||||
|
'modified' => gmdate('Y-m-d H:i', filemtime($path)),
|
||||||
|
'url' => '/uploads/' . rawurlencode($entry),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($files, static fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'files' => $files,
|
||||||
|
'allowedExtensions' => $allowedExtensions,
|
||||||
|
'maxBytes' => $maxBytes,
|
||||||
|
'notice' => Session::getFlash('media_notice'),
|
||||||
|
'error' => Session::getFlash('media_error'),
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Media{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Upload, browse, and delete files under public/uploads/.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h1 class="icon-heading">{{ icons.book() }}Media</h1>
|
||||||
|
|
||||||
|
<p>Files uploaded here live under <code>public/uploads/</code> and are served directly at the URL shown below each file — reference one from a sidecar or Twig template the same way you'd link any other static asset. Allowed types: {% for ext in allowedExtensions %}<code>.{{ ext }}</code>{% if not loop.last %}, {% endif %}{% endfor %}. Max size: {{ (maxBytes / 1024 / 1024)|round(1, 'floor') }} MB.</p>
|
||||||
|
|
||||||
|
{% if notice %}
|
||||||
|
<p><strong>{{ notice }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p><strong>{{ error }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Upload a file</h2>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/media" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="action" value="upload">
|
||||||
|
<p>
|
||||||
|
<label for="file">File</label><br>
|
||||||
|
<input type="file" id="file" name="file" required>
|
||||||
|
</p>
|
||||||
|
<button type="submit">Upload</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Files</h2>
|
||||||
|
|
||||||
|
{% if files is empty %}
|
||||||
|
<p>No files uploaded yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Modified</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for file in files %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ file.url }}">{{ file.name }}</a></td>
|
||||||
|
<td>{{ (file.size / 1024)|round(1, 'floor') }} KB</td>
|
||||||
|
<td>{{ file.modified }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/media">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="filename" value="{{ file.name }}">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,6 +5,7 @@ use App\Response;
|
|||||||
use Lib\Csrf;
|
use Lib\Csrf;
|
||||||
use Lib\Db;
|
use Lib\Db;
|
||||||
use Lib\Input;
|
use Lib\Input;
|
||||||
|
use Lib\Mailer;
|
||||||
use Lib\Session;
|
use Lib\Session;
|
||||||
use Lib\Validate;
|
use Lib\Validate;
|
||||||
|
|
||||||
@@ -35,6 +36,32 @@ $activeAdminCount = static fn (): int => (int) Db::query(
|
|||||||
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
|
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
|
||||||
)->fetchColumn();
|
)->fetchColumn();
|
||||||
|
|
||||||
|
// Issues a fresh verification token (bin2hex(random_bytes(32)), same
|
||||||
|
// pattern as Lib\Csrf::token()) for the given user id, emails the link via
|
||||||
|
// Lib\Mailer::sendMail() (log-file by default, MailJet if configured — see
|
||||||
|
// /admin/docs/admin-auth), and returns the token for callers that don't
|
||||||
|
// need it. Used by both the create action (new non-bootstrap users) and
|
||||||
|
// the resend_verification/email actions below.
|
||||||
|
$issueVerification = static function (int $id, string $email): void {
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$expiresAt = gmdate('Y-m-d\TH:i:s\Z', time() + 86400);
|
||||||
|
|
||||||
|
Db::query(
|
||||||
|
'UPDATE users SET verified_at = NULL, verification_token = ?, verification_token_expires_at = ? WHERE id = ?',
|
||||||
|
[$token, $expiresAt, $id]
|
||||||
|
);
|
||||||
|
|
||||||
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$verifyUrl = "{$scheme}://{$host}/verify-email?token={$token}";
|
||||||
|
|
||||||
|
(new Mailer())->sendMail(
|
||||||
|
$email,
|
||||||
|
'Verify your account',
|
||||||
|
"Confirm your email address to activate your account:\n\n{$verifyUrl}\n\nThis link expires in 24 hours."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
Session::flash('users_error', 'Your session expired before submitting — please try again.');
|
Session::flash('users_error', 'Your session expired before submitting — please try again.');
|
||||||
@@ -83,10 +110,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// /admin/docs/access-control.
|
// /admin/docs/access-control.
|
||||||
$wasFirstUser = !AdminAuth::hasUsers();
|
$wasFirstUser = !AdminAuth::hasUsers();
|
||||||
$role = $wasFirstUser ? 'admin' : 'registered';
|
$role = $wasFirstUser ? 'admin' : 'registered';
|
||||||
|
$now = gmdate('Y-m-d\TH:i:s\Z');
|
||||||
|
|
||||||
|
// The first user is auto-verified — there's no other admin to
|
||||||
|
// have vouched for them, and the very next line logs them in
|
||||||
|
// immediately, which a null verified_at would otherwise block
|
||||||
|
// (see AdminAuth::attempt() and /admin/docs/admin-auth). Every
|
||||||
|
// subsequent account starts unverified and gets a verification
|
||||||
|
// email instead, via $issueVerification below.
|
||||||
Db::query(
|
Db::query(
|
||||||
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)',
|
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)',
|
||||||
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, gmdate('Y-m-d\TH:i:s\Z')]
|
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, $now, $wasFirstUser ? $now : null]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Creating the first user is what closes the open setup gate —
|
// Creating the first user is what closes the open setup gate —
|
||||||
@@ -94,9 +128,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// bounce them to the login form they were just typing into.
|
// bounce them to the login form they were just typing into.
|
||||||
if ($wasFirstUser) {
|
if ($wasFirstUser) {
|
||||||
AdminAuth::attempt($username, $password);
|
AdminAuth::attempt($username, $password);
|
||||||
|
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created.");
|
||||||
|
} else {
|
||||||
|
$id = (int) Db::query('SELECT id FROM users WHERE username = ?', [$username])->fetchColumn();
|
||||||
|
$issueVerification($id, $email);
|
||||||
|
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created — a verification email was sent to {$email}; they can't log in until it's confirmed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created.");
|
|
||||||
}
|
}
|
||||||
} elseif ($action === 'disable' || $action === 'enable') {
|
} elseif ($action === 'disable' || $action === 'enable') {
|
||||||
$id = (int) Input::post('id', '0');
|
$id = (int) Input::post('id', '0');
|
||||||
@@ -138,6 +175,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// "keep the account but shut it out", so delete is for
|
// "keep the account but shut it out", so delete is for
|
||||||
// accounts that shouldn't exist at all. Any live session dies
|
// accounts that shouldn't exist at all. Any live session dies
|
||||||
// on its next request (currentUser() re-checks the row).
|
// on its next request (currentUser() re-checks the row).
|
||||||
|
//
|
||||||
|
// Remove the user's comments first: comments.user_id has a
|
||||||
|
// NOT NULL foreign key to users(id) (0003_create_comments.sql)
|
||||||
|
// and Lib\Db runs PRAGMA foreign_keys = ON, so deleting a user
|
||||||
|
// who has ever commented would otherwise raise a FOREIGN KEY
|
||||||
|
// constraint violation and 500. Fresh installs also get
|
||||||
|
// ON DELETE CASCADE on the FK, but this keeps already-migrated
|
||||||
|
// databases (whose FK predates that) working too.
|
||||||
|
Db::query('DELETE FROM comments WHERE user_id = ?', [$id]);
|
||||||
Db::query('DELETE FROM users WHERE id = ?', [$id]);
|
Db::query('DELETE FROM users WHERE id = ?', [$id]);
|
||||||
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
||||||
}
|
}
|
||||||
@@ -155,8 +201,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
} elseif ($emailExists) {
|
} elseif ($emailExists) {
|
||||||
Session::flash('users_error', 'That email address is already in use.');
|
Session::flash('users_error', 'That email address is already in use.');
|
||||||
} else {
|
} else {
|
||||||
|
// A changed address hasn't been proven owned yet — reset
|
||||||
|
// verification and send a fresh link to the *new* address,
|
||||||
|
// rather than silently keeping the old address's verified
|
||||||
|
// status attached to an unconfirmed one. $issueVerification
|
||||||
|
// also sets verified_at back to NULL, so the account can't log
|
||||||
|
// in again until this new address is confirmed.
|
||||||
Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]);
|
Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]);
|
||||||
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D}.");
|
$issueVerification($id, $email);
|
||||||
|
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D} — a verification email was sent to {$email}; they can't log in again until it's confirmed.");
|
||||||
|
}
|
||||||
|
} elseif ($action === 'resend_verification') {
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$target = Db::query('SELECT id, username, email, verified_at FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('users_error', 'No such user.');
|
||||||
|
} elseif ($target['verified_at'] !== null) {
|
||||||
|
Session::flash('users_error', "\u{201C}{$target['username']}\u{201D} is already verified.");
|
||||||
|
} else {
|
||||||
|
$issueVerification($id, $target['email']);
|
||||||
|
Session::flash('users_notice', "Verification email resent to \u{201C}{$target['username']}\u{201D}.");
|
||||||
}
|
}
|
||||||
} elseif ($action === 'group') {
|
} elseif ($action === 'group') {
|
||||||
$id = (int) Input::post('id', '0');
|
$id = (int) Input::post('id', '0');
|
||||||
@@ -192,7 +257,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
|
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at, verified_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
|
||||||
'currentUserId' => AdminAuth::currentUser()['id'] ?? null,
|
'currentUserId' => AdminAuth::currentUser()['id'] ?? null,
|
||||||
'notice' => Session::getFlash('users_notice'),
|
'notice' => Session::getFlash('users_notice'),
|
||||||
'error' => Session::getFlash('users_error'),
|
'error' => Session::getFlash('users_error'),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<article>
|
<article>
|
||||||
<h1 class="icon-heading">{{ icons.users() }}Users</h1>
|
<h1 class="icon-heading">{{ icons.users() }}Users</h1>
|
||||||
|
|
||||||
<p>Accounts that can log in at <a href="/admin/login">/admin/login</a>. The first user created is the <strong>admin</strong>; everyone after is <strong>registered</strong> — able to log in and see whatever pages <code>Lib\Access</code> assigns to their account or group (see <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a>), but not the admin area. A disabled user can't log in, and any session they already had is locked out on its next request.</p>
|
<p>Accounts that can log in at <a href="/admin/login">/admin/login</a>. The first user created is the <strong>admin</strong>; everyone after is <strong>registered</strong> — able to log in and see whatever pages <code>Lib\Access</code> assigns to their account or group (see <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a>), but not the admin area. A disabled user can't log in, and any session they already had is locked out on its next request. Every user after the first must also verify their email before they can log in at all — they're sent a verification link on creation (or resend it below), and changing an account's email requires re-verifying the new address.</p>
|
||||||
|
|
||||||
{% if notice %}
|
{% if notice %}
|
||||||
<p><strong>{{ notice }}</strong></p>
|
<p><strong>{{ notice }}</strong></p>
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
<th>Group</th>
|
<th>Group</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Verified</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
<td>{{ user.user_group ?: '—' }}</td>
|
<td>{{ user.user_group ?: '—' }}</td>
|
||||||
<td>{{ user.created_at }}</td>
|
<td>{{ user.created_at }}</td>
|
||||||
<td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td>
|
<td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td>
|
||||||
|
<td>{{ user.verified_at ? 'Verified' : 'Unverified' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/admin/users">
|
<form method="post" action="/admin/users">
|
||||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
@@ -53,6 +55,14 @@
|
|||||||
<input type="hidden" name="action" value="{{ user.is_disabled ? 'enable' : 'disable' }}">
|
<input type="hidden" name="action" value="{{ user.is_disabled ? 'enable' : 'disable' }}">
|
||||||
<button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button>
|
<button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if not user.verified_at %}
|
||||||
|
<form method="post" action="/admin/users">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="id" value="{{ user.id }}">
|
||||||
|
<input type="hidden" name="action" value="resend_verification">
|
||||||
|
<button type="submit">Resend verification</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="/admin/users">
|
<form method="post" action="/admin/users">
|
||||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
<input type="hidden" name="id" value="{{ user.id }}">
|
<input type="hidden" name="id" value="{{ user.id }}">
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\Session;
|
||||||
|
|
||||||
|
// Same two-step config load as /admin/login — this sidecar isn't handed
|
||||||
|
// $config, so it loads its own copy to read admin_auth_enabled before
|
||||||
|
// touching Lib\Db at all.
|
||||||
|
$config = require __DIR__ . '/../../config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to verify against without the users table — 404 exactly like a
|
||||||
|
// route that doesn't exist, same zero-footprint posture as
|
||||||
|
// login/logout/users, and never touch Lib\Db on a site that never opted
|
||||||
|
// in.
|
||||||
|
if (!$config['admin_auth_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$findByToken = static function (string $token): array|false {
|
||||||
|
if ($token === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = Db::query(
|
||||||
|
'SELECT id, username, verification_token_expires_at FROM users WHERE verification_token = ?',
|
||||||
|
[$token]
|
||||||
|
)->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($row === false || $row['verification_token_expires_at'] < gmdate('Y-m-d\TH:i:s\Z')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET renders an inert confirm page, POST does the mutation — same
|
||||||
|
// shape as /admin/logout, and for the same two reasons: the content-index
|
||||||
|
// crawl (ContentIndexer::reindex()) hits every routable page as a forced
|
||||||
|
// GET, and email-security scanners prefetch links before a human clicks —
|
||||||
|
// either one would burn the token on a GET-mutates link. A missing,
|
||||||
|
// wrong, or expired token never touches the database on GET or POST; it
|
||||||
|
// just renders the "invalid or expired" state.
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$token = (string) Input::post('token', '');
|
||||||
|
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
return Response::redirect('/verify-email?error=security', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $findByToken($token);
|
||||||
|
|
||||||
|
if ($user === false) {
|
||||||
|
return [
|
||||||
|
'valid' => false,
|
||||||
|
'token' => '',
|
||||||
|
'securityError' => false,
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::query(
|
||||||
|
'UPDATE users SET verified_at = ?, verification_token = NULL, verification_token_expires_at = NULL WHERE id = ?',
|
||||||
|
[gmdate('Y-m-d\TH:i:s\Z'), $user['id']]
|
||||||
|
);
|
||||||
|
|
||||||
|
Session::flash('admin_notice', "Email verified for \u{201C}{$user['username']}\u{201D} — you can log in now.");
|
||||||
|
|
||||||
|
return Response::redirect('/admin/login', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (string) Input::get('token', '');
|
||||||
|
$user = $findByToken($token);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => $user !== false,
|
||||||
|
'token' => $token,
|
||||||
|
'securityError' => Input::get('error') === 'security',
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Verify email{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Confirm a new account's email address.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h1 class="icon-heading">{{ icons.email() }}Verify email</h1>
|
||||||
|
|
||||||
|
{% if securityError %}
|
||||||
|
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if valid %}
|
||||||
|
<p>Click below to confirm this address and finish activating the account.</p>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="token" value="{{ token }}">
|
||||||
|
<button type="submit">Verify email</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p><strong>This link is invalid or has expired.</strong> Ask an admin to resend the verification email from <a href="/admin/users">/admin/users</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -386,7 +386,7 @@ button
|
|||||||
opacity: 0
|
opacity: 0
|
||||||
animation: fade-in-up 0.5s ease-out forwards
|
animation: fade-in-up 0.5s ease-out forwards
|
||||||
|
|
||||||
@for $i from 1 through 6
|
@for $i from 1 through 12
|
||||||
&:nth-child(#{$i})
|
&:nth-child(#{$i})
|
||||||
animation-delay: #{0.3 + $i * 0.06}s
|
animation-delay: #{0.3 + $i * 0.06}s
|
||||||
|
|
||||||
|
|||||||
@@ -110,9 +110,15 @@ final class AdminAuth
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies a username/password against the users table and, on
|
* Verifies a username/password against the users table and, on
|
||||||
* success, logs the session in. Disabled users fail exactly like a
|
* success, logs the session in. Disabled and unverified users fail
|
||||||
* wrong password — the response never distinguishes "no such user",
|
* exactly like a wrong password — the response never distinguishes
|
||||||
* "disabled", and "bad password".
|
* "no such user", "disabled", "unverified", and "bad password" (see
|
||||||
|
* /admin/docs/admin-auth's email verification section). Unset
|
||||||
|
* verified_at means never verified (the verified_at/verification_token
|
||||||
|
* columns are part of novaconium/migrations/0002_create_users.sql); the
|
||||||
|
* first user ever created is inserted already-verified (see
|
||||||
|
* /admin/users and bin/create-admin-user.php), so verification only
|
||||||
|
* actually gates accounts created after it.
|
||||||
*/
|
*/
|
||||||
public static function attempt(string $username, string $password): bool
|
public static function attempt(string $username, string $password): bool
|
||||||
{
|
{
|
||||||
@@ -121,11 +127,11 @@ final class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = Db::query(
|
$user = Db::query(
|
||||||
'SELECT id, username, email, password_hash, role, user_group, is_disabled FROM users WHERE username = ?',
|
'SELECT id, username, email, password_hash, role, user_group, is_disabled, verified_at FROM users WHERE username = ?',
|
||||||
[$username]
|
[$username]
|
||||||
)->fetch(PDO::FETCH_ASSOC);
|
)->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
if ($user === false || (int) $user['is_disabled'] === 1 || !password_verify($password, $user['password_hash'])) {
|
if ($user === false || (int) $user['is_disabled'] === 1 || $user['verified_at'] === null || !password_verify($password, $user['password_hash'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,10 +165,11 @@ final class AdminAuth
|
|||||||
/**
|
/**
|
||||||
* The logged-in user's row (id/username/email/role/user_group/
|
* The logged-in user's row (id/username/email/role/user_group/
|
||||||
* is_disabled — never the password hash), or null. Re-checked against
|
* is_disabled — never the password hash), or null. Re-checked against
|
||||||
* the users table on every request, not just at login, so disabling
|
* the users table on every request, not just at login, so disabling,
|
||||||
* or deleting a user locks their existing session out on their very
|
* deleting, or un-verifying (e.g. an email change reset — see
|
||||||
* next request — no "still logged in until the session expires"
|
* /admin/docs/admin-auth) a user locks their existing session out on
|
||||||
* window.
|
* its very next request — no "still logged in until the session
|
||||||
|
* expires" window.
|
||||||
*
|
*
|
||||||
* @return array<string, mixed>|null
|
* @return array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
@@ -180,7 +187,7 @@ final class AdminAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = Db::query(
|
$user = Db::query(
|
||||||
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0',
|
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0 AND verified_at IS NOT NULL',
|
||||||
[$userId]
|
[$userId]
|
||||||
)->fetch(PDO::FETCH_ASSOC);
|
)->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
|||||||
@@ -104,12 +104,21 @@ final class ContentIndexer
|
|||||||
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
|
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
|
||||||
|
|
||||||
$newestMtime = 0;
|
$newestMtime = 0;
|
||||||
|
$sourceCount = 0;
|
||||||
|
|
||||||
foreach ($routes as $dir) {
|
foreach ($routes as $dir) {
|
||||||
if (in_array($dir, $config['draft_routes'], true)) {
|
if (in_array($dir, $config['draft_routes'], true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count every non-draft routable page, regardless of
|
||||||
|
// whether it ends up indexed (a noindex or Response-only
|
||||||
|
// page still counts) — this is a stable fingerprint of the
|
||||||
|
// routable page *set*, so isStale() can notice a deletion,
|
||||||
|
// which the newest-mtime check alone can't (deleting a page
|
||||||
|
// only ever lowers the max mtime, never raises it).
|
||||||
|
$sourceCount++;
|
||||||
|
|
||||||
$mtime = self::sourceMtime($config['pages_dirs'], $dir);
|
$mtime = self::sourceMtime($config['pages_dirs'], $dir);
|
||||||
$newestMtime = max($newestMtime, $mtime);
|
$newestMtime = max($newestMtime, $mtime);
|
||||||
|
|
||||||
@@ -140,8 +149,8 @@ final class ContentIndexer
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pdo->prepare('DELETE FROM content_index_meta')->execute();
|
$pdo->prepare('DELETE FROM content_index_meta')->execute();
|
||||||
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, indexed_at) VALUES (1, ?, ?)')
|
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, source_count, indexed_at) VALUES (1, ?, ?, ?)')
|
||||||
->execute([$newestMtime, gmdate('Y-m-d\TH:i:s\Z')]);
|
->execute([$newestMtime, $sourceCount, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||||
|
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -166,17 +175,26 @@ final class ContentIndexer
|
|||||||
{
|
{
|
||||||
$pdo = Db::connection();
|
$pdo = Db::connection();
|
||||||
|
|
||||||
$meta = $pdo->query('SELECT newest_source_mtime FROM content_index_meta WHERE id = 1')->fetch();
|
$meta = $pdo->query('SELECT newest_source_mtime, source_count FROM content_index_meta WHERE id = 1')->fetch();
|
||||||
if ($meta === false) {
|
if ($meta === false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$newest = 0;
|
$newest = 0;
|
||||||
|
$count = 0;
|
||||||
foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) {
|
foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) {
|
||||||
|
if (in_array($dir, $config['draft_routes'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
$newest = max($newest, self::sourceMtime($config['pages_dirs'], $dir));
|
$newest = max($newest, self::sourceMtime($config['pages_dirs'], $dir));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $newest > (int) $meta['newest_source_mtime'];
|
// A newer source file means an edit/addition; a changed page count
|
||||||
|
// means a page was deleted (or a draft toggled) — the mtime check
|
||||||
|
// alone can't see a deletion, since removing a page only lowers the
|
||||||
|
// max mtime. Either signal means the index is stale.
|
||||||
|
return $newest > (int) $meta['newest_source_mtime'] || $count !== (int) $meta['source_count'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+5
-1
@@ -5,7 +5,11 @@
|
|||||||
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||||
|
|
||||||
if ($uri !== '/' && str_ends_with($uri, '/')) {
|
if ($uri !== '/' && str_ends_with($uri, '/')) {
|
||||||
header('Location: ' . rtrim($uri, '/'), true, 301);
|
// Preserve the query string across the canonical redirect — Apache's
|
||||||
|
// .htaccess does this automatically, so dev must too or post/redirect
|
||||||
|
// flows like /contact/?sent=1 lose their flags under `php -S`.
|
||||||
|
$query = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY);
|
||||||
|
header('Location: ' . rtrim($uri, '/') . ($query !== null ? '?' . $query : ''), true, 301);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user