Compare commits
25 Commits
fbd4e92e9f
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf0582468 | |||
| 7650354abd | |||
| 0831331fe3 | |||
| 76fd1ca3ed | |||
| 15b32ed256 | |||
| d1ce803412 | |||
| 8540c6d9ea | |||
| 64defe7f74 | |||
| e699027b4b | |||
| c0455241ea | |||
| a0ae58c29e | |||
| 6624c4fdc3 | |||
| f6fb2f12c6 | |||
| b37b13120e | |||
| b882c304b1 | |||
| cb64836901 | |||
| 74c0d59019 | |||
| a51faa7208 | |||
| 37b6764431 | |||
| 4862526fa1 | |||
| 5deb298b91 | |||
| 3a59269eaa | |||
| a3b996719a | |||
| 672f997d8b | |||
| fe5f5ee131 |
@@ -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
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
/public/cache/*
|
/public/cache/*
|
||||||
!/public/cache/.gitkeep
|
!/public/cache/.gitkeep
|
||||||
/novaconium/contact-log.txt
|
/novaconium/contact-log.txt
|
||||||
|
/data/*.sqlite
|
||||||
|
/data/*.sqlite-journal
|
||||||
|
/data/*.sqlite-wal
|
||||||
|
/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,85 +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_username']` / `config['admin_password_hash']` (username
|
**Any mechanism that conditionally hides page content from the public
|
||||||
defaults to `'admin'`, password hash defaults to `''`) gate every
|
must be threaded into `Renderer::render()`'s `$excludeFromCache` param, not
|
||||||
`/admin/*` route behind HTTP Basic Auth — this replaced the old
|
just a pre-render auth gate.** `Renderer::render()` writes a sidecar-less
|
||||||
`docs_enabled` flag entirely (removed); a single gate over all of
|
page's output to the static HTML cache, and `.htaccess` serves a cached
|
||||||
`/admin/*` (docs included) made a docs-only toggle redundant. Unlike the
|
file *before PHP (and therefore any auth check) ever runs again*. A route
|
||||||
Twig-global pattern above, the gate itself is enforced in `bootstrap.php`,
|
gated only at the auth-check level still leaks to the public the moment an
|
||||||
before rendering: `AdminAuth::requireLogin(...)`
|
authorized user views it once, if the page has no sidecar. `draft_routes`
|
||||||
(`novaconium/src/AdminAuth.php`) is called once for any resolved route
|
and every `/admin/*` route already pass `true` for this reason. Any new
|
||||||
whose path is `admin` or starts with `admin/`. **Any new admin page
|
feature that gates a route by anything other than a sidecar check needs the
|
||||||
dropped under `App/pages/admin/` or `novaconium/pages/admin/` is
|
same treatment — this has caused a real bug before, twice.
|
||||||
automatically protected — no per-page wiring needed.** `bootstrap.php`
|
|
||||||
also special-cases the literal path `/admin/logout` *before* router
|
Corollary: `Lib\Access` (the sidecar-level content gate, see
|
||||||
resolution — no page exists there — to call `AdminAuth::logout()`, which
|
`/admin/docs/access-control`) is safe by construction — a page with no
|
||||||
always issues a fresh 401 so the browser drops its cached credentials
|
sidecar can't call `Access`, and only sidecar-less pages get cached, so a
|
||||||
(there's no server-side session to invalidate). `Renderer` separately
|
gated page can never leak through the cache with no extra wiring needed.
|
||||||
exposes an `admin_auth_enabled` Twig global (true when a password hash is
|
|
||||||
set) so `admin/index.twig` can conditionally show the "Logout" link — this
|
## Reentrancy hazard: ContentIndexer
|
||||||
is a derived display flag, not the enforcement mechanism itself, which
|
|
||||||
never depends on Twig. `novaconium/pages/admin/password-hash/` is a
|
`ContentIndexer::reindex()` renders every routable page, including
|
||||||
built-in `password_hash()` form (no CLI needed) for generating
|
`/search` itself, which also calls `ContentIndexer::ensureFresh()`.
|
||||||
`admin_password_hash` — a normal admin page, so it's covered by the same
|
Guarded by a `private static bool $indexing` flag checked at the top of
|
||||||
gate: reachable while no password is set yet (to generate the first
|
both methods — don't remove it, any new consumer route inherits the same
|
||||||
one), then protected like everything else under `/admin/*` afterward. It
|
hazard automatically. `reindex()` also forces
|
||||||
computes and displays the hash per-request only; nothing is persisted or
|
`$_SERVER['REQUEST_METHOD']` to `'GET'` for the duration of the crawl
|
||||||
logged. This is a single-user HTTP Basic Auth stopgap, not
|
(restored in a `finally`) so a lazy reindex triggered from a POST can't
|
||||||
the full multi-user system tracked in `novaconium/ISSUES.md` ("Admin login & user
|
leak that POST into an unrelated page's sidecar.
|
||||||
management"); don't extend this class toward multi-user/session-based
|
|
||||||
auth — that's a separate, larger feature that
|
## Vendored dependency placement
|
||||||
will replace it.
|
|
||||||
|
**Server-side-only (PHP, autoloaded) → `novaconium/vendor/`. Anything a
|
||||||
|
browser fetches (`.js`, `.css`, images) → `public/vendor/`** — `novaconium/`
|
||||||
|
is never web-reachable. This matters beyond correctness: `public/` is
|
||||||
|
project-owned and untouched by a framework update, so a `public/vendor/`
|
||||||
|
dependency bump does **not** propagate automatically the way a
|
||||||
|
`novaconium/vendor/` bump would — re-vendoring is a manual step per
|
||||||
|
dependency (see `/admin/docs/upgrading-highlightjs`).
|
||||||
|
|
||||||
|
## Twig gotchas that will fatal without `mbstring`
|
||||||
|
|
||||||
|
Don't use `|slice` on a **string** (calls `mb_substr()` unconditionally) or
|
||||||
|
`|escape('js')`/`'js'` arg to `|e` (calls `mb_ord()`) — both hard-require
|
||||||
|
`mbstring` and fatal without it; this project deliberately avoids that
|
||||||
|
dependency. Truncate strings in PHP with an `mb_substr`/`substr` fallback
|
||||||
|
instead. For markup destined for inline `<script>`, render into a
|
||||||
|
`<template>` element and read `.innerHTML` in JS rather than
|
||||||
|
`|escape('js')`.
|
||||||
|
|
||||||
|
`class="nohighlight"` marks a `<pre><code>` block containing literal Twig
|
||||||
|
syntax (`{% %}`/`{{ }}`) — highlight.js has no Twig grammar and a
|
||||||
|
restricted auto-detect still always guesses wrong without this class. Any
|
||||||
|
new Twig-syntax code sample needs it; PHP/Bash samples don't.
|
||||||
|
|
||||||
|
## Sass override quirk
|
||||||
|
|
||||||
|
`novaconium/sass/main.sass` does `@use 'colors' as *` with **no**
|
||||||
|
`_colors.sass` sibling in `novaconium/sass/` — on purpose. Dart Sass
|
||||||
|
resolves a bare `@use` relative to the importing file's own directory
|
||||||
|
*before* `--load-path`, so a sibling file would always win and silently
|
||||||
|
defeat the `App/sass/_colors.sass` override. The framework default lives
|
||||||
|
at `novaconium/sass/defaults/_colors.sass` instead. Don't move it back.
|
||||||
|
|
||||||
|
Every color rule in `main.sass` reads a CSS custom property (`var(--bg)`,
|
||||||
|
etc.), never a Sass variable directly — required for the runtime dark/light
|
||||||
|
toggle. Adding a color means adding both the plain and `-light` variable in
|
||||||
|
**both** `_colors.sass` files and wiring it into both `:root` blocks.
|
||||||
|
|
||||||
|
## Input handling
|
||||||
|
|
||||||
|
Sidecars read request data via `Lib\Input::post()`/`::get()`, not
|
||||||
|
`$_POST`/`$_GET` directly (trims, strips tags/null bytes — XSS
|
||||||
|
defense-in-depth, **not** SQL-injection protection; use PDO prepared
|
||||||
|
statements via `Lib\Db::query()` for that, never string-interpolated SQL).
|
||||||
|
Exception: fields needing an exact unmodified value (e.g. a password about
|
||||||
|
to be hashed) read `$_POST` directly — see login/users sidecars.
|
||||||
|
`Lib\Csrf::verify()` is called directly by a sidecar, not wired into
|
||||||
|
`FormValidator`.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -138,103 +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) should read `$_POST` directly instead — see
|
|
||||||
`novaconium/pages/admin/password-hash/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's the first thing in the framework to start a native PHP session (only
|
|
||||||
lazily, when a form actually calls it), which is otherwise unrelated to
|
|
||||||
`AdminAuth`'s own session-free Basic Auth.
|
|
||||||
- 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.
|
|
||||||
|
|||||||
+45
-5
@@ -15,9 +15,49 @@ return [
|
|||||||
// 'matomo_url' => 'https://matomo.example.com/',
|
// 'matomo_url' => 'https://matomo.example.com/',
|
||||||
// 'matomo_site_id' => '1',
|
// 'matomo_site_id' => '1',
|
||||||
|
|
||||||
// Docs: /admin/docs/admin-auth — generate a hash with:
|
// Docs: /admin/docs/admin-auth — session login for /admin/* against
|
||||||
// php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"
|
// the SQLite-backed users table. After enabling, create the first user
|
||||||
// or use the built-in /admin/password-hash form.
|
// at /admin/users, or beforehand (safer) with:
|
||||||
// 'admin_username' => 'admin',
|
// php novaconium/bin/create-admin-user.php <username>
|
||||||
// 'admin_password_hash' => '$2y$10$...',
|
// 'admin_auth_enabled' => true,
|
||||||
|
|
||||||
|
// Docs: /admin/docs/database — adds (or overrides) named Lib\Db
|
||||||
|
// connections. This merges into db_connections by name rather than
|
||||||
|
// replacing the whole map, so adding 'legacy' here doesn't require
|
||||||
|
// repeating 'default' — see Lib\Db::config().
|
||||||
|
// 'db_connections' => [
|
||||||
|
// 'legacy' => [
|
||||||
|
// 'driver' => 'mysql',
|
||||||
|
// 'host' => 'localhost',
|
||||||
|
// 'port' => 3306,
|
||||||
|
// 'database' => 'legacy_app',
|
||||||
|
// 'username' => 'root',
|
||||||
|
// 'password' => '...',
|
||||||
|
// 'charset' => 'utf8mb4', // optional, defaults to utf8mb4
|
||||||
|
// 'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
|
||||||
|
// ],
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Docs: /admin/docs/drafts — requires admin_auth_enabled above (and at
|
||||||
|
// least one user) to actually gate anything; open access otherwise,
|
||||||
|
// same as the rest of /admin/*.
|
||||||
|
// 'draft_routes' => ['blog/upcoming-post'],
|
||||||
|
|
||||||
|
// Docs: /admin/docs/content-index — powers /sitemap.xml, /search, and
|
||||||
|
// blog tag browsing. Off by default (depends on SQLite); enable with:
|
||||||
|
// 'content_index_enabled' => true,
|
||||||
|
//
|
||||||
|
// Or enable but skip the automatic lazy reindex, relying only on
|
||||||
|
// `php novaconium/bin/index-content.php` (e.g. from a deploy step):
|
||||||
|
// 'content_index_enabled' => true,
|
||||||
|
// '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' => '...',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
|
position: relative;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -144,6 +145,44 @@ pre code {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
pre:hover .copy-code-button, pre .copy-code-button:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--muted-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.copy-code-button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.copy-code-button.copied {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code.hljs {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
border: none;
|
border: none;
|
||||||
@@ -163,6 +202,19 @@ small {
|
|||||||
color: var(--muted-color);
|
color: var(--muted-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hp-field {
|
.hp-field {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
@@ -372,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;
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
<p>Everything the framework itself ships — default pages, default library classes, the root layout — lives under <code>novaconium/</code>, and can be overridden by placing a same-named file under <code>App/</code>, the only directory a site author is expected to touch. The same override mechanism covers configuration, too: any key in <code>novaconium/config.php</code> can be replaced piecemeal from <code>App/config.php</code>.</p>
|
<p>Everything the framework itself ships — default pages, default library classes, the root layout — lives under <code>novaconium/</code>, and can be overridden by placing a same-named file under <code>App/</code>, the only directory a site author is expected to touch. The same override mechanism covers configuration, too: any key in <code>novaconium/config.php</code> can be replaced piecemeal from <code>App/config.php</code>.</p>
|
||||||
|
|
||||||
<p>Beyond routing and templating, Novaconium ships with SEO meta tags (canonical links, Open Graph, Twitter Card), optional Matomo analytics with automatic 404 tracking, and an HTTP Basic Auth gate reusable across every <code>/admin/*</code> page — all off or sensible by default, and all documented at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance rather than a separate website.</p>
|
<p>Beyond routing and templating, Novaconium ships with SEO meta tags (canonical links, Open Graph, Twitter Card), optional Matomo analytics with automatic 404 tracking, and a multi-user admin login (with browser-based user management) covering every <code>/admin/*</code> page — all off or sensible by default, and all documented at <a class="icon-link" href="/admin/docs">{{ icons.book() }}/admin/docs</a>, rendered live from this same running instance rather than a separate website.</p>
|
||||||
|
|
||||||
<p>It's a good fit for small marketing sites, blogs, and internal tools where a full framework would be overkill but a flat-file site generator alone falls short of real server-side logic. The source is on Git if you want to see how it's put together: <a class="icon-link" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}git.4lt.ca/4lt/novaconium</a>.</p>
|
<p>It's a good fit for small marketing sites, blogs, and internal tools where a full framework would be overkill but a flat-file site generator alone falls short of real server-side logic. The source is on Git if you want to see how it's put together: <a class="icon-link" href="https://git.4lt.ca/4lt/novaconium">{{ icons.git() }}git.4lt.ca/4lt/novaconium</a>.</p>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
{% import '_layout/icons.twig' as icons %}
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{# Feed auto-discovery — only shows up on /blog/* pages, since only this
|
||||||
|
layout overrides the root layout's empty head_extra block. See
|
||||||
|
App/pages/blog/feed/index.php. #}
|
||||||
|
{% block head_extra %}
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="blog-layout">
|
<div class="blog-layout">
|
||||||
<aside>
|
<aside>
|
||||||
@@ -10,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,90 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Code Highlighting{% endblock %}
|
||||||
|
{% block description %}How syntax highlighting works on this site, with worked examples in bash, HTML, CSS, YAML, Python, JavaScript, JSON, and INI/env.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}highlighting, reference{% 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>Code Highlighting</h1>
|
||||||
|
|
||||||
|
<p>Every <code><pre><code></code> block on this site gets colored automatically via vendored <a href="https://highlightjs.org/">highlight.js</a> — see <a class="icon-link" href="/admin/docs/styling">{{ icons.book() }}Styling</a> for the mechanism and <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for what's vendored. This post is a plain reference: how to write a code block, and one worked example in each language this site highlights.</p>
|
||||||
|
|
||||||
|
<h2>How to use it</h2>
|
||||||
|
|
||||||
|
<p>Write a normal code block — nothing extra required, the language is auto-detected:</p>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight"><pre><code>your code here</code></pre></code></pre>
|
||||||
|
|
||||||
|
<p>Auto-detection is restricted to the languages this site actually uses (see <code>hljs.configure(...)</code> in <code>novaconium/pages/_layout/syntax-highlight.twig</code>) so it doesn't misfire trying to match dozens of unrelated bundled languages against a short snippet. If a snippet is ambiguous, or ever gets detected as the wrong language, force it with an explicit <code>language-<name></code> class instead of relying on auto-detection:</p>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight"><pre><code class="language-yaml">your code here</code></pre></code></pre>
|
||||||
|
|
||||||
|
<p>A code block written in Twig template syntax (<code>{{ '{%' }} ... {{ '%}' }}</code>, <code>{{ '{{' }} ... {{ '}}' }}</code>) has no highlight.js grammar to match — mark those <code>class="nohighlight"</code> instead, the same way the two snippets above are marked (they're showing literal HTML as plain text, not being highlighted as HTML themselves). See <a class="icon-link" href="/blog/twig-syntax-guide">{{ icons.book() }}the Twig Syntax Guide</a> for Twig's own syntax, written the same way.</p>
|
||||||
|
|
||||||
|
<h2>Bash</h2>
|
||||||
|
<pre><code>#!/bin/bash
|
||||||
|
for f in App/pages/blog/*/index.twig; do
|
||||||
|
echo "Post: $f"
|
||||||
|
done</code></pre>
|
||||||
|
|
||||||
|
<h2>HTML</h2>
|
||||||
|
<pre><code><article class="post">
|
||||||
|
<h1>Hello, World!</h1>
|
||||||
|
<p>A short excerpt.</p>
|
||||||
|
</article></code></pre>
|
||||||
|
|
||||||
|
<h2>CSS</h2>
|
||||||
|
<pre><code>.post-list li {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h2>YAML</h2>
|
||||||
|
<pre><code>site:
|
||||||
|
name: Novaconium Website
|
||||||
|
theme: dark
|
||||||
|
tags:
|
||||||
|
- php
|
||||||
|
- twig
|
||||||
|
- highlighting</code></pre>
|
||||||
|
|
||||||
|
<h2>Python</h2>
|
||||||
|
<pre><code>def excerpt(text, length=140):
|
||||||
|
return text[:length].rsplit(" ", 1)[0] + "..."</code></pre>
|
||||||
|
|
||||||
|
<h2>JavaScript</h2>
|
||||||
|
<pre><code>function toggleTheme() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const next = html.dataset.theme === "light" ? "dark" : "light";
|
||||||
|
html.setAttribute("data-theme", next);
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h2>JSON</h2>
|
||||||
|
<pre><code>{
|
||||||
|
"title": "Code Highlighting",
|
||||||
|
"tags": ["highlighting", "reference"],
|
||||||
|
"published": true
|
||||||
|
}</code></pre>
|
||||||
|
|
||||||
|
<h2>INI / .env</h2>
|
||||||
|
<pre><code>; App/config.php equivalent, .ini-style
|
||||||
|
[matomo]
|
||||||
|
url = https://matomo.example.com/
|
||||||
|
site_id = 1</code></pre>
|
||||||
|
|
||||||
|
<p>YAML, JSON, and INI aren't part of highlight.js's core bundle the way PHP/Bash/CSS/Python/JavaScript/XML are — they're vendored as three separate files under <code>public/vendor/highlightjs/languages/</code>, loaded after the core bundle. See <a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> for how to add another language the same way.</p>
|
||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// /blog/feed — sidecar-only (no index.twig), like sitemap.xml/search: no
|
||||||
|
// point rendering Twig just to return XML. Project-owned (App/pages/),
|
||||||
|
// since this is blog content specifically, not generic framework
|
||||||
|
// machinery like sitemap.xml/search are. Deliberately has zero dependency
|
||||||
|
// on the (off-by-default) content index — it reads the exact same
|
||||||
|
// hand-written $posts array App/pages/blog/index.php itself renders from,
|
||||||
|
// so this feed works on a bare install with content_index_enabled left at
|
||||||
|
// its shipped default of false. Only the per-tag variant
|
||||||
|
// (App/pages/blog/tag/[tag]/feed/index.php) needs the content index, since
|
||||||
|
// tags only exist there.
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Rss;
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../../../../novaconium/config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = (require __DIR__ . '/../index.php')['posts'];
|
||||||
|
|
||||||
|
// Newest first, the RSS convention — the array itself (and therefore the
|
||||||
|
// /blog listing page, which isn't touched here) keeps its own order;
|
||||||
|
// sorting only affects this feed's output.
|
||||||
|
usort($posts, fn (array $a, array $b) => strcmp($b['published'], $a['published']));
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn (array $post) => [
|
||||||
|
'title' => $post['title'],
|
||||||
|
'link' => '/blog/' . $post['slug'],
|
||||||
|
'guid' => '/blog/' . $post['slug'],
|
||||||
|
'pubDateTimestamp' => strtotime($post['published']),
|
||||||
|
'description' => $post['excerpt'],
|
||||||
|
],
|
||||||
|
$posts
|
||||||
|
);
|
||||||
|
|
||||||
|
$xml = Rss::render(
|
||||||
|
$config['site_name'] . ' Blog',
|
||||||
|
'/blog',
|
||||||
|
'Posts from ' . $config['site_name'] . '.',
|
||||||
|
$items
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml($xml);
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}The first post on this blog — a plain sidecar-less page, like every other post here now.{% endblock %}
|
{% block description %}The first post on this blog — a plain sidecar-less page, like every other post here now.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}welcome, meta{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
|
|||||||
+38
-12
@@ -4,27 +4,53 @@
|
|||||||
// with its own index.twig — none of them are driven by a repository or
|
// with its own index.twig — none of them are driven by a repository or
|
||||||
// database, so this listing is just a hand-maintained array pointing at
|
// database, so this listing is just a hand-maintained array pointing at
|
||||||
// each one. Add a new entry here whenever a new post directory is added.
|
// each one. Add a new entry here whenever a new post directory is added.
|
||||||
|
// 'published' (YYYY-MM-DD) is used by App/pages/blog/feed/index.php to
|
||||||
|
// order and date entries in the RSS feed — illustrative dates here, not
|
||||||
|
// derived from real history (this repo's posts all arrived in one batch
|
||||||
|
// import, so there's no authentic per-post date to pull from).
|
||||||
return [
|
return [
|
||||||
'posts' => [
|
'posts' => [
|
||||||
[
|
[
|
||||||
'slug' => 'hello-world',
|
'slug' => 'hello-world',
|
||||||
'title' => 'Hello, World!',
|
'title' => 'Hello, World!',
|
||||||
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
|
'excerpt' => 'The first post on this blog — a plain sidecar-less page, like every other post here now.',
|
||||||
|
'published' => '2026-07-11',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'second-post',
|
'slug' => 'second-post',
|
||||||
'title' => 'A Second Post',
|
'title' => 'A Second Post',
|
||||||
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
|
'excerpt' => 'A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.',
|
||||||
|
'published' => '2026-07-11',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'twig-syntax-guide',
|
'slug' => 'twig-syntax-guide',
|
||||||
'title' => 'Twig Syntax Guide',
|
'title' => 'Twig Syntax Guide',
|
||||||
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
|
'excerpt' => 'A tour of the Twig syntax used throughout this site — output, filters, control structures, template inheritance, and a few gotchas worth knowing.',
|
||||||
|
'published' => '2026-07-13',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'style-guide',
|
'slug' => 'style-guide',
|
||||||
'title' => 'Style Guide',
|
'title' => 'Style Guide',
|
||||||
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
|
'excerpt' => "A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.",
|
||||||
|
'published' => '2026-07-13',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'slug' => 'code-highlighting',
|
||||||
|
'title' => 'Code Highlighting',
|
||||||
|
'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',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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 %}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.{% endblock %}
|
{% block description %}A second post at its own URL, showing that adding a new page under App/pages/blog/ needs nothing but a new directory.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}meta{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.{% endblock %}
|
{% block description %}A showcase of this theme's default styling for headings, lists, tables, code, and other common HTML elements.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}css, reference{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
|
|
||||||
<h2>Code</h2>
|
<h2>Code</h2>
|
||||||
<p>Inline: <code>(new Mailer())->send($old['name'], $old['email'], $old['message']);</code></p>
|
<p>Inline: <code>(new Mailer())->send($old['name'], $old['email'], $old['message']);</code></p>
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// blog/tag/[tag]/feed/ — same [param] capture as blog/tag/[tag]/index.php
|
||||||
|
// one level up ($params['tag'] is already populated by the time Router
|
||||||
|
// resolves this deeper path — see that file's comments for how the
|
||||||
|
// capture works). Project-owned, mirrors blog/tag/[tag]/index.php's
|
||||||
|
// query almost exactly, just rendered as RSS instead of an HTML list.
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Rss;
|
||||||
|
|
||||||
|
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||||
|
// isn't handed $config, so it loads its own copy to read
|
||||||
|
// content_index_enabled before touching Lib\Db at all.
|
||||||
|
$config = require __DIR__ . '/../../../../../../novaconium/config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content index is off by default (depends on SQLite) — see
|
||||||
|
// /admin/docs/content-index. Unlike App/pages/blog/feed/ (the main feed,
|
||||||
|
// which has zero content-index dependency), this per-tag feed reads
|
||||||
|
// content_tags/content_pages directly, so it 404s the same way
|
||||||
|
// blog/tag/[tag]/index.php does when the index is off, and never
|
||||||
|
// constructs a Lib\Db connection in that case.
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentIndexer::ensureFresh();
|
||||||
|
|
||||||
|
$tag = $params['tag'];
|
||||||
|
|
||||||
|
// Same query as blog/tag/[tag]/index.php, plus source_mtime — used below
|
||||||
|
// as pubDate. This is the page's own source-file mtime, not a true
|
||||||
|
// "published" date (the content index has no separate published concept
|
||||||
|
// the way the hand-written main feed's $posts array does) — an honest
|
||||||
|
// stand-in, not presented as more precise than it is.
|
||||||
|
$posts = Db::query(
|
||||||
|
'SELECT content_pages.route, content_pages.title, content_pages.description, content_pages.source_mtime ' .
|
||||||
|
'FROM content_tags ' .
|
||||||
|
'JOIN content_pages ON content_pages.route = content_tags.route ' .
|
||||||
|
'WHERE content_tags.tag = ? ' .
|
||||||
|
"AND content_pages.route LIKE '/blog/%' " .
|
||||||
|
'ORDER BY content_pages.source_mtime DESC',
|
||||||
|
[$tag]
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn (array $post) => [
|
||||||
|
'title' => $post['title'],
|
||||||
|
'link' => $post['route'],
|
||||||
|
'guid' => $post['route'],
|
||||||
|
'pubDateTimestamp' => (int) $post['source_mtime'],
|
||||||
|
'description' => $post['description'],
|
||||||
|
],
|
||||||
|
$posts
|
||||||
|
);
|
||||||
|
|
||||||
|
$xml = Rss::render(
|
||||||
|
$config['site_name'] . ' Blog — tagged "' . $tag . '"',
|
||||||
|
'/blog/tag/' . $tag,
|
||||||
|
'Posts tagged "' . $tag . '" from ' . $config['site_name'] . '.',
|
||||||
|
$items
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml($xml);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// blog/tag/[tag]/ — the [param] segment captures anything after
|
||||||
|
// /blog/tag/ into $params['tag'] (see /admin/docs/routing). This page is
|
||||||
|
// project-owned (unlike sitemap.xml/search, which are framework defaults
|
||||||
|
// under novaconium/pages/) because blog/ itself is project content, not
|
||||||
|
// framework machinery.
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Db;
|
||||||
|
|
||||||
|
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||||
|
// isn't handed $config, so it loads its own copy to read
|
||||||
|
// content_index_enabled before touching Lib\Db at all.
|
||||||
|
$config = require __DIR__ . '/../../../../../novaconium/config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content index is off by default (depends on SQLite) — see
|
||||||
|
// /admin/docs/content-index. When it's off, this route must 404 exactly
|
||||||
|
// like a page that doesn't exist, and never construct a Lib\Db connection
|
||||||
|
// (which would otherwise create data/novaconium.sqlite just because this
|
||||||
|
// file exists, even on a site that never opted in).
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy reindex-if-stale — a no-op on most requests (only actually
|
||||||
|
// reindexes when a page's source file changed since the last index). Also
|
||||||
|
// guards against reentrancy: ContentIndexer's own crawl renders every
|
||||||
|
// page, but it never renders *this* route, since blog/tag/[tag] is a
|
||||||
|
// wildcard directory and Overlay::listPageDirs() skips [param]-wildcard
|
||||||
|
// dirs entirely (concrete tag values aren't knowable without a data
|
||||||
|
// source — see ContentIndexer's docblock).
|
||||||
|
ContentIndexer::ensureFresh();
|
||||||
|
|
||||||
|
$tag = $params['tag'];
|
||||||
|
|
||||||
|
// content_tags is a derived index built from each post's own
|
||||||
|
// {% block tags %} (see /admin/docs/content-index) — it's populated
|
||||||
|
// entirely by ContentIndexer::reindex(), never written to directly here.
|
||||||
|
// The route LIKE '/blog/%' filter matters because content_tags isn't
|
||||||
|
// blog-specific — any page anywhere on the site can declare tags, so this
|
||||||
|
// scopes results to blog posts only, the same way App/pages/blog/index.php
|
||||||
|
// itself only ever lists blog posts.
|
||||||
|
$posts = Db::query(
|
||||||
|
'SELECT content_pages.route, content_pages.title, content_pages.description ' .
|
||||||
|
'FROM content_tags ' .
|
||||||
|
'JOIN content_pages ON content_pages.route = content_tags.route ' .
|
||||||
|
'WHERE content_tags.tag = ? ' .
|
||||||
|
"AND content_pages.route LIKE '/blog/%' " .
|
||||||
|
'ORDER BY content_pages.route',
|
||||||
|
[$tag]
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// $posts is [] (not an error) when nothing matches — the twig template
|
||||||
|
// renders a plain "no posts tagged ..." message for that case, same as
|
||||||
|
// /search does for a query with no results.
|
||||||
|
return [
|
||||||
|
'tag' => $tag,
|
||||||
|
'posts' => $posts,
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Posts tagged “{{ tag }}”{% endblock %}
|
||||||
|
{% block description %}Blog posts tagged {{ tag }}.{% endblock %}
|
||||||
|
{% block robots %}noindex, follow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.tag() }}Posts tagged “{{ tag }}”</h1>
|
||||||
|
|
||||||
|
{% if posts|length > 0 %}
|
||||||
|
<ul class="post-list">
|
||||||
|
{% for post in posts %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ post.route }}">{{ post.title }}</a>
|
||||||
|
{% if post.description %}<p>{{ post.description }}</p>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No posts tagged “{{ tag }}”.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
{% block description %}A tour of the Twig syntax used throughout this site — output, filters, control structures, inheritance, and a few gotchas.{% endblock %}
|
{% block description %}A tour of the Twig syntax used throughout this site — output, filters, control structures, inheritance, and a few gotchas.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}twig, reference{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}article{% endblock %}
|
{% block og_type %}article{% endblock %}
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
|
|
||||||
<h2>Output & variables</h2>
|
<h2>Output & variables</h2>
|
||||||
<p>Twig prints an expression with <code>{{ '{{ ... }}' }}</code>. Sidecar data, route params, and a handful of framework-provided variables (<code>request_path</code>, <code>layout</code>, <code>site_name</code>) are all just variables in scope:</p>
|
<p>Twig prints an expression with <code>{{ '{{ ... }}' }}</code>. Sidecar data, route params, and a handful of framework-provided variables (<code>request_path</code>, <code>layout</code>, <code>site_name</code>) are all just variables in scope:</p>
|
||||||
<pre><code>{% verbatim %}{{ title }}
|
<pre><code class="nohighlight">{% verbatim %}{{ title }}
|
||||||
{{ params.slug }}
|
{{ params.slug }}
|
||||||
{{ post.title }}{% endverbatim %}</code></pre>
|
{{ post.title }}{% endverbatim %}</code></pre>
|
||||||
<p>Dot notation (<code>post.title</code>) works whether <code>post</code> is an array key or an object property — Twig tries both, so templates don't need to care which.</p>
|
<p>Dot notation (<code>post.title</code>) works whether <code>post</code> is an array key or an object property — Twig tries both, so templates don't need to care which.</p>
|
||||||
@@ -45,29 +46,29 @@
|
|||||||
|
|
||||||
<h2>Control structures</h2>
|
<h2>Control structures</h2>
|
||||||
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
|
<p>The two workhorses are <code>{% verbatim %}{% if %}{% endverbatim %}</code> and <code>{% verbatim %}{% for %}{% endverbatim %}</code>:</p>
|
||||||
<pre><code>{% verbatim %}{% if sent %}
|
<pre><code class="nohighlight">{% verbatim %}{% if sent %}
|
||||||
<p>Thanks — your message has been sent.</p>
|
<p>Thanks — your message has been sent.</p>
|
||||||
{% endif %}{% endverbatim %}</code></pre>
|
{% endif %}{% endverbatim %}</code></pre>
|
||||||
<pre><code>{% verbatim %}{% for post in posts %}
|
<pre><code class="nohighlight">{% verbatim %}{% for post in posts %}
|
||||||
<li><a href="/blog/{{ post.slug }}">{{ post.title }}</a></li>
|
<li><a href="/blog/{{ post.slug }}">{{ post.title }}</a></li>
|
||||||
{% endfor %}{% endverbatim %}</code></pre>
|
{% endfor %}{% endverbatim %}</code></pre>
|
||||||
<p>This exact loop is what renders the <a href="/blog">blog listing page</a> you probably followed a link from to get here.</p>
|
<p>This exact loop is what renders the <a href="/blog">blog listing page</a> you probably followed a link from to get here.</p>
|
||||||
|
|
||||||
<h2>Comments</h2>
|
<h2>Comments</h2>
|
||||||
<p>Anything between <code>{% verbatim %}{# and #}{% endverbatim %}</code> is stripped entirely from the output — unlike an HTML comment, it never reaches the browser:</p>
|
<p>Anything between <code>{% verbatim %}{# and #}{% endverbatim %}</code> is stripped entirely from the output — unlike an HTML comment, it never reaches the browser:</p>
|
||||||
<pre><code>{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
|
<pre><code class="nohighlight">{% verbatim %}{# Open Graph / Facebook #}{% endverbatim %}</code></pre>
|
||||||
<p>That one's real — it's the comment sitting above the Open Graph block in <code>novaconium/pages/_layout/layout.twig</code>.</p>
|
<p>That one's real — it's the comment sitting above the Open Graph block in <code>novaconium/pages/_layout/layout.twig</code>.</p>
|
||||||
|
|
||||||
<h2>Template inheritance & includes</h2>
|
<h2>Template inheritance & includes</h2>
|
||||||
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code><html></code>/<code><head></code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
|
<p><code>{% verbatim %}{% extends %}{% endverbatim %}</code> is how every page on this site gets its <code><html></code>/<code><head></code>/nav/footer for free — a child template only fills in named <code>{% verbatim %}{% block %}{% endverbatim %}</code> slots the parent declares:</p>
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Blog{% endblock %}
|
{% block title %}Blog{% endblock %}
|
||||||
{% block blog_content %}
|
{% block blog_content %}
|
||||||
...
|
...
|
||||||
{% endblock %}{% endverbatim %}</code></pre>
|
{% endblock %}{% endverbatim %}</code></pre>
|
||||||
<p><code>{% verbatim %}{% include %}{% endverbatim %}</code> pulls in a whole template inline (used for <code>_layout/nav.twig</code> and <code>_layout/matomo.twig</code>), while <code>{% verbatim %}{% import %}{% endverbatim %}</code> pulls in reusable <strong>macros</strong> — parameterized snippets like the icons used throughout this page:</p>
|
<p><code>{% verbatim %}{% include %}{% endverbatim %}</code> pulls in a whole template inline (used for <code>_layout/nav.twig</code> and <code>_layout/matomo.twig</code>), while <code>{% verbatim %}{% import %}{% endverbatim %}</code> pulls in reusable <strong>macros</strong> — parameterized snippets like the icons used throughout this page:</p>
|
||||||
<pre><code>{% verbatim %}{% import '_layout/icons.twig' as icons %}
|
<pre><code class="nohighlight">{% verbatim %}{% import '_layout/icons.twig' as icons %}
|
||||||
{{ icons.book() }}{% endverbatim %}</code></pre>
|
{{ icons.book() }}{% endverbatim %}</code></pre>
|
||||||
<p>See <a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> for how <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution walks the <code>App/</code>-over-<code>novaconium/</code> override chain.</p>
|
<p>See <a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a> for how <code>{% verbatim %}{% extends %}{% endverbatim %}</code> resolution walks the <code>App/</code>-over-<code>novaconium/</code> override chain.</p>
|
||||||
|
|
||||||
|
|||||||
+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 an HTTP Basic Auth gate 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,84 +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 HTTP Basic Auth by setting `admin_username`/`admin_password_hash` in `App/config.php`; reusable for any admin page a project adds later, with a `/admin/logout` link to clear cached credentials and a built-in `/admin/password-hash` form so generating the hash doesn't require the CLI. Off by default.
|
|
||||||
- **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`, and `/admin/password-hash`.
|
|
||||||
- **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) and, for production, Apache with `mod_rewrite` and `AllowOverride All`.
|
### 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.
|
|
||||||
|
|
||||||
### 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
|
#docker buildx build --no-cache -t 4lights/novaconium:2.0.0-beta -t 4lights/corxn:latest --load .
|
||||||
|
#docker login -u <username>
|
||||||
|
#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
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
### Webmasters
|
||||||
|
|
||||||
```
|
- Clone this repo.
|
||||||
php novaconium/bin/create-static-page.php blog/my-new-post
|
- docker build: ``` docker build -t novaconium . ```
|
||||||
```
|
- docker compose up -d
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The full framework documentation — routing, sidecars, libraries, layouts, static caching, SEO, Matomo analytics, admin authentication, 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)
|
|
||||||
- [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)
|
|
||||||
- [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 "$@"
|
||||||
+264
-326
@@ -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.
|
||||||
@@ -51,360 +56,98 @@ expected vs. actual behavior. For features, include the motivating use case.>
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
Suggested build order (foundations first, since admin login builds on two
|
Suggested build order (foundations first):
|
||||||
of the others):
|
|
||||||
|
|
||||||
1. **SQLite groundwork** — no dependencies.
|
1. **Ecommerce: Lib\Money** — no dependencies, and the other two Ecommerce
|
||||||
2. **MySQL support** — builds directly on SQLite groundwork's `Db`
|
pieces below both need it.
|
||||||
abstraction; do right after so the abstraction is driver-agnostic from
|
2. **Ecommerce: Lib\Cart** — needs Lib\Money.
|
||||||
the start rather than retrofitted.
|
3. **Ecommerce: payment gateway helper** — needs Lib\Money; independent of
|
||||||
3. **Session handling** — no dependencies; can be built in parallel with 1.
|
Lib\Cart, so it could also go before it.
|
||||||
4. **Blog tags/categories** — no dependencies, but now needs a metadata
|
4. **Paywall functionality** — needs the payment gateway helper above for
|
||||||
source design decision first (`PostRepository` was removed when
|
its recurring-billing/payment plumbing; build after it rather than in
|
||||||
`hello-world`/`second-post` became plain Twig pages — see the entry
|
parallel — also now has a concrete precedent to follow for the "gated
|
||||||
below), so worth doing first or together with Blog RSS feed.
|
content must skip the static cache" part of its design (see Draft
|
||||||
5. **Blog RSS feed** — no hard dependency, but a per-tag feed is easiest
|
pages (admin-only preview) in Done, and the caching/auth standing rule
|
||||||
once tags/categories exist.
|
in `AGENTS.md`), which was still an open question when this entry was
|
||||||
6. **Internal search** — needs SQLite groundwork for storage.
|
originally written.
|
||||||
7. **XML sitemap** — no hard dependency, but shares crawling logic with
|
|
||||||
Internal search, so easiest right after (or alongside) it.
|
Session handling (with flash sessions), Draft pages (admin-only preview),
|
||||||
8. **Media/file manager** — no hard dependency; usable standalone, though
|
and Admin login & user management all shipped (see Done) — every open
|
||||||
best gated behind admin login once that exists.
|
entry above still depends on at least one of them. The original single
|
||||||
9. **Draft pages (admin-only preview)** — no hard dependency; the admin
|
"Ecommerce functionality" entry was scoped down into the three
|
||||||
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
|
Lib\-helper pieces above on 2026-07-15 — see Lib\Money's entry for why.
|
||||||
10. **Copy-to-clipboard button on code blocks** — no dependency; a
|
|
||||||
self-contained styling/JS feature, can be picked up any time.
|
|
||||||
11. **Syntax highlighting on code blocks** — no hard dependency on the
|
|
||||||
copy button above, but touches the same `<pre><code>` markup, so
|
|
||||||
worth sequencing together to avoid two separate passes over every
|
|
||||||
code block.
|
|
||||||
12. **Admin login & user management** — needs both SQLite groundwork (user
|
|
||||||
store) and session handling (logged-in state).
|
|
||||||
13. **Ecommerce functionality** — needs SQLite groundwork, session handling
|
|
||||||
(cart), and admin login & user management (order/product admin, and
|
|
||||||
customer accounts).
|
|
||||||
14. **Paywall functionality** — needs everything Ecommerce needs, plus
|
|
||||||
Ecommerce itself for the recurring-billing/payment-gateway plumbing;
|
|
||||||
build after it rather than in parallel.
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
### SQLite groundwork
|
### Ecommerce: Lib\Money
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** High
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Lay the groundwork for optional SQLite storage (a `Db` or similar `Lib\`
|
|
||||||
wrapper around `PDO`/`sqlite3`, a data directory outside `public/`, a
|
|
||||||
migration/schema convention) so features that need persistence — 404
|
|
||||||
tracking and admin login below, and anything future — have a common place
|
|
||||||
to store data instead of ad hoc flat files. No ORM; stay consistent with
|
|
||||||
the project's no-Composer, no-build-step philosophy. Foundational — nothing
|
|
||||||
else here depends on this being skipped, but 404 tracking and admin login
|
|
||||||
both depend on it existing. Design the `Db` wrapper's interface with MySQL
|
|
||||||
support (below) in mind from the start — PDO already abstracts most of the
|
|
||||||
driver difference, so the schema/migration convention should avoid
|
|
||||||
SQLite-only syntax where a MySQL-compatible equivalent exists, to avoid a
|
|
||||||
retrofit.
|
|
||||||
|
|
||||||
### MySQL support
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Let a project point the `Db` wrapper at MySQL instead of SQLite — via a
|
|
||||||
`db_driver` (or similar) `App/config.php` key plus connection settings
|
|
||||||
(host/user/password/database) — for projects that want a real MySQL
|
|
||||||
server rather than an embedded file, without maintaining two separate data
|
|
||||||
layers. PDO already supports both drivers under one API, so this should be
|
|
||||||
mostly a matter of: (1) not writing SQLite-only SQL in the groundwork
|
|
||||||
above, (2) a config-driven DSN builder, (3) a migration convention that
|
|
||||||
works on both (or per-driver migration files if syntax genuinely diverges).
|
|
||||||
No new persistence features depend on this — it's an alternate backend for
|
|
||||||
the same `Db` abstraction, not a separate feature surface.
|
|
||||||
|
|
||||||
### Session handling (with flash sessions)
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** High
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
A `Lib\`/framework-core wrapper around PHP's native session handling
|
|
||||||
(`session_start()` etc., not a custom session store) so sidecars and the
|
|
||||||
future admin login have a consistent way to read/write session data
|
|
||||||
instead of touching `$_SESSION` directly. Include CodeIgniter-style flash
|
|
||||||
data — a value set now that survives exactly one subsequent request (e.g.
|
|
||||||
`$session->flash('message', 'Saved.')` readable on the next request only,
|
|
||||||
via a set-now/expire-after-read scheme) — for post/redirect/GET flows like
|
|
||||||
`App/pages/contact/index.php` already does manually with `?sent=1`.
|
|
||||||
Foundational alongside SQLite groundwork — no dependencies of its own, but
|
|
||||||
admin login depends on it.
|
|
||||||
|
|
||||||
### Blog tags/categories
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Tag (or category) each blog post so posts can be browsed/filtered by
|
|
||||||
topic — e.g. `App/pages/blog/tag/[tag]/index.php` listing matching posts,
|
|
||||||
using the `[param]` route-capture mechanism (see `/admin/docs/routing`;
|
|
||||||
this project's own `App/pages/blog/` doesn't currently use `[param]` for
|
|
||||||
posts, every post is its own plain directory named after its slug).
|
|
||||||
`Lib\PostRepository` (which used to back `hello-world`/`second-post`) was
|
|
||||||
removed when those two posts became plain Twig pages, like
|
|
||||||
`twig-syntax-guide` and `style-guide` — so there's no shared metadata
|
|
||||||
store anymore. `App/pages/blog/index.php` now just hand-lists each
|
|
||||||
post's slug/title/excerpt in a plain array; adding tags means adding a
|
|
||||||
`tags` field to each entry there and a lookup by tag over that same
|
|
||||||
array. Fine at current scale (4 posts); if the array grows unwieldy or
|
|
||||||
tags need real querying, that's a SQLite-groundwork question — no need
|
|
||||||
to block on it now. Once a feed exists, consider a per-tag feed too
|
|
||||||
(e.g. `App/pages/blog/tag/[tag]/feed/index.php`).
|
|
||||||
|
|
||||||
### Blog RSS feed
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
An RSS (or Atom) feed for the blog, e.g. `App/pages/blog/feed/index.php`
|
|
||||||
returning `Response::xml(...)` — the sidecar contract already supports
|
|
||||||
this, no new mechanism needed. `App/pages/blog/index.php` already
|
|
||||||
hand-lists every post's slug/title/excerpt in a plain array (no
|
|
||||||
`PostRepository` anymore — see the Blog tags/categories entry above for
|
|
||||||
why); the remaining gap is a published-date field per entry so a feed
|
|
||||||
can sort them, regardless of whether that array stays hardcoded or moves
|
|
||||||
onto SQLite later. Should link from `<link rel="alternate"
|
|
||||||
type="application/rss+xml">` in the blog layout (or the root layout) for
|
|
||||||
feed auto-discovery, and probably wants its own `App/pages/blog/_layout/`
|
|
||||||
or a sidecar-only page — no `index.twig` needed since the sidecar returns
|
|
||||||
XML directly (see `/admin/docs/sidecars`'s JSON-only example for the same
|
|
||||||
pattern, just with `Response::json()` instead of `Response::xml()`).
|
|
||||||
|
|
||||||
### Internal search
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Crawl the site's own pages (likely via `App/pages/` + rendered output,
|
|
||||||
rather than an external HTTP crawl, to avoid hitting the static cache/
|
|
||||||
`.htaccess` layer) and index the content into SQLite once the groundwork
|
|
||||||
above exists, so a search box can query it — a `search` sidecar (e.g.
|
|
||||||
`App/pages/search/index.php`) that reads the index and returns matching
|
|
||||||
pages, no external search service. Needs a decision on crawl trigger
|
|
||||||
(on-demand via an admin action vs. a cron-like re-crawl) and on indexing
|
|
||||||
granularity (whole-page vs. per-section). Consider reusing the SQLite
|
|
||||||
FTS5 extension if available, rather than hand-rolling text search.
|
|
||||||
|
|
||||||
### XML sitemap
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
Generate a `/sitemap.xml` listing every routable page (`App/pages/` +
|
|
||||||
`novaconium/pages/` overlay, same resolution `Router`/`Overlay` already do)
|
|
||||||
for search engine discovery — one more piece of the SEO groundwork already
|
|
||||||
laid (`/admin/docs/seo`, canonical links, etc.). No hard dependency on
|
|
||||||
SQLite: a first version can be built by walking `pages_dirs` directly
|
|
||||||
(skipping reserved `_`/`404` segments, resolving `[param]` routes only if
|
|
||||||
their concrete values are knowable from some data source — moot for
|
|
||||||
`App/pages/blog/` today, since every post there is now a plain directory
|
|
||||||
rather than a `[param]` route) the same way `Router::resolve()` walks it,
|
|
||||||
with no separate crawl step.
|
|
||||||
That said, it may be worth sharing a crawler with Internal search rather
|
|
||||||
than building two separate page-enumeration mechanisms — if Internal
|
|
||||||
search's crawler already walks and records every real URL (including
|
|
||||||
resolved `[param]` values), the sitemap can just be a different
|
|
||||||
serialization of that same data instead of its own logic. Worth deciding
|
|
||||||
together when either is picked up. Should also exclude `/admin/docs/*` if
|
|
||||||
it isn't publicly reachable (see admin authentication) and the `noindex`
|
|
||||||
pages already marked via the `robots` block.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### Draft pages (admin-only preview)
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Added:** 2026-07-13
|
|
||||||
|
|
||||||
Let a page under `App/pages/` (or `novaconium/pages/`) be written and
|
|
||||||
previewed by an admin without being visible to the public — a draft blog
|
|
||||||
post, an in-progress redesign of a page, etc. Likely a config-driven list
|
|
||||||
(e.g. `'draft_routes' => ['blog/upcoming-post']` in `App/config.php`) or a
|
|
||||||
per-sidecar flag (`return ['draft' => true, ...]`), checked in
|
|
||||||
`novaconium/bootstrap.php` right alongside the existing `/admin/*` gate —
|
|
||||||
reusing `AdminAuth::requireLogin()` (see `/admin/docs/admin-auth`) rather
|
|
||||||
than inventing a second auth mechanism: not logged in → 404 (not a login
|
|
||||||
prompt, so a draft's existence isn't revealed to anyone poking at the
|
|
||||||
URL), logged in → renders normally.
|
|
||||||
|
|
||||||
**The gotcha to design around from day one:** sidecar-less pages get
|
|
||||||
written to the static HTML cache and served by `.htaccess` *before PHP
|
|
||||||
ever runs* (see `/admin/docs/caching`) — if a draft page took that path,
|
|
||||||
the cached file would be world-readable the moment an admin previewed it
|
|
||||||
once, completely bypassing the auth check for anyone hitting the same URL
|
|
||||||
afterward. So a draft page must either always go through a sidecar (never
|
|
||||||
cached, checked via the config/flag above) or `Renderer`/`Cache` need an
|
|
||||||
explicit "never write this route to the cache" exception. Whichever
|
|
||||||
approach ships, add a line to `/admin/docs/caching` and `AGENTS.md`
|
|
||||||
calling this out, the same way the `mb_substr`/`|slice` gotcha got a
|
|
||||||
standing-rule note — it's exactly the kind of interaction between two
|
|
||||||
independently-reasonable features that's easy to get wrong once and
|
|
||||||
should only need explaining once.
|
|
||||||
|
|
||||||
### Copy-to-clipboard button on code blocks
|
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Low
|
- **Priority:** Low
|
||||||
- **Added:** 2026-07-13
|
- **Added:** 2026-07-15
|
||||||
|
|
||||||
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
|
First and smallest piece of the former "Ecommerce functionality" entry —
|
||||||
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — add a
|
scoped down (2026-07-15) from a full catalog/cart/checkout/order-admin
|
||||||
small button on hover that copies the block's text via the
|
system to a handful of composable `Lib\` helpers, since a project's idea
|
||||||
[Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
|
of a "product" is too site-specific to standardize; the project builds
|
||||||
(`navigator.clipboard.writeText(...)`), consistent with this project's
|
its own catalog and admin UI on `Lib\Db` the same way it would for any
|
||||||
no-build-step philosophy: vanilla JS, no dependency, same pattern as the
|
other feature, the same split `Lib\Comments` already makes for what a
|
||||||
dark/light theme toggle (`novaconium/pages/_layout/theme-toggle.twig`) —
|
"page" is. `Lib\Money`: integer-cents arithmetic (add/subtract/multiply
|
||||||
a small inline script using event delegation rather than a script per
|
by a quantity) and formatting, avoiding the classic float-rounding bugs
|
||||||
button. Needs a new icon (a "copy"/clipboard glyph — see
|
of storing prices as floats. No dependencies — the foundation `Lib\Cart`
|
||||||
`novaconium/pages/_layout/icons.twig` for the existing set and its
|
and the payment gateway helper below both need a non-lossy way to
|
||||||
inline-SVG-not-icon-font convention) and matching Sass in
|
represent an amount before either can be built.
|
||||||
`novaconium/sass/main.sass`, plus a way to actually grab a code block's
|
|
||||||
raw text without the HTML entities used to keep literal tags/Twig syntax
|
|
||||||
from rendering inside `<pre><code>` (e.g. `<h1>` in the SEO starter
|
|
||||||
template) — reading `textContent` rather than `innerHTML` handles the
|
|
||||||
entity-decoding automatically, so this should be low-risk, but worth
|
|
||||||
calling out since it's the same class of escaping issue documented in
|
|
||||||
`AGENTS.md`/fixed across `/admin/docs/seo` and the Twig Syntax Guide.
|
|
||||||
Should show a brief "Copied!" confirmation (swap the button label/icon
|
|
||||||
for ~1–2 seconds) rather than a silent copy, so it's clear it worked.
|
|
||||||
|
|
||||||
### Syntax highlighting on code blocks
|
### Ecommerce: Lib\Cart
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Low
|
- **Priority:** Low
|
||||||
- **Added:** 2026-07-13
|
- **Depends on:** Ecommerce: Lib\Money, Session handling (with flash sessions) (Done)
|
||||||
|
- **Added:** 2026-07-15
|
||||||
|
|
||||||
Color the PHP/Twig/Bash/HTML snippets across `/admin/docs/*` and the
|
Second piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||||
blog's reference posts instead of the current flat, single-color
|
above for the scoping note). A session-based cart primitive — add/remove/
|
||||||
`<pre><code>` rendering. PHP has a built-in `highlight_string()`, but it
|
update line items, line and cart totals via `Lib\Money` — riding on
|
||||||
only understands PHP — everything else on this site's code blocks (Twig
|
`Lib\Session` the same way `Lib\Csrf`/`Lib\AdminAuth` lazily touch the
|
||||||
template syntax, Bash/Docker commands in `/admin/docs/styling`, plain
|
native session. Generic on purpose: the cart holds id/qty/price entries a
|
||||||
HTML) needs something else, so use [highlight.js](https://highlightjs.org/)
|
sidecar hands it, with no opinion on what a "product" is or where its
|
||||||
client-side for everything uniformly, with the **ir-black** theme (dark,
|
catalog data comes from.
|
||||||
high-contrast — fits this project's existing dark/teal default palette).
|
|
||||||
ir-black is a dark-only theme, though, and this site now has a light
|
|
||||||
theme too (see the dark/light toggle) — decide whether code blocks stay
|
|
||||||
ir-black regardless of site theme (simplest, and arguably fine since
|
|
||||||
code blocks are visually distinct boxes already), or whether a second,
|
|
||||||
light-appropriate highlight.js theme gets swapped in via the same
|
|
||||||
`data-theme` attribute the color-palette toggle already sets.
|
|
||||||
Staying consistent with the no-CDN, no-build-step philosophy (Twig itself
|
|
||||||
is vendored, not `npm install`ed) means vendoring highlight.js's built
|
|
||||||
`highlight.min.js` + the `ir-black.min.css` theme file directly under
|
|
||||||
`novaconium/vendor/` next to `twig/`, following the same
|
|
||||||
one-subdirectory-per-vendor convention established there — rather than
|
|
||||||
pulling from a CDN. highlight.js doesn't ship a Twig grammar out of the
|
|
||||||
box; either register a custom language definition for it (Twig's syntax
|
|
||||||
is close enough to Jinja2 that an existing community Jinja grammar may
|
|
||||||
mostly work) or leave Twig snippets using the plain/no-highlight class
|
|
||||||
and accept that only PHP/Bash/HTML/XML get colored initially.
|
|
||||||
|
|
||||||
No hard dependency on the copy-to-clipboard button above, but both touch
|
### Ecommerce: payment gateway helper
|
||||||
every `<pre><code>` block on the site, so doing them in the same pass
|
|
||||||
avoids visiting each doc page's code samples twice. If both ship, the
|
|
||||||
copy button must keep copying the plain, unhighlighted text (via
|
|
||||||
`textContent`, not `innerHTML` — see that entry) even after this adds
|
|
||||||
`<span>` wrappers around tokens, so a copied snippet doesn't come out
|
|
||||||
full of stray markup.
|
|
||||||
|
|
||||||
### Admin login & user management
|
|
||||||
|
|
||||||
- **Type:** Feature
|
|
||||||
- **Status:** Backlog
|
|
||||||
- **Priority:** Medium
|
|
||||||
- **Depends on:** SQLite groundwork, Session handling (with flash sessions)
|
|
||||||
- **Added:** 2026-07-12
|
|
||||||
|
|
||||||
A single-user HTTP Basic Auth stopgap now gates `/admin/*`
|
|
||||||
(`novaconium/src/AdminAuth.php`, `admin_username`/`admin_password_hash` in
|
|
||||||
`App/config.php` — see `/admin/docs/admin-auth`), so `/admin` is no longer
|
|
||||||
wide open by default choice. This entry is the real, larger replacement:
|
|
||||||
multiple accounts, a user store (the SQLite groundwork above — a `users`
|
|
||||||
table with hashed passwords via `password_hash()`/`password_verify()`, no
|
|
||||||
external auth library), proper sessions instead of Basic Auth (rides on
|
|
||||||
session handling above), and basic user management (create/disable a
|
|
||||||
user, change password). Ship this by replacing `AdminAuth::requireLogin()`
|
|
||||||
with the new mechanism, not layering on top of it.
|
|
||||||
|
|
||||||
### Ecommerce functionality
|
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
- **Status:** Backlog
|
- **Status:** Backlog
|
||||||
- **Priority:** Low
|
- **Priority:** Low
|
||||||
- **Depends on:** SQLite groundwork, Session handling (with flash sessions), Admin login & user management
|
- **Depends on:** Ecommerce: Lib\Money
|
||||||
- **Added:** 2026-07-12
|
- **Added:** 2026-07-15
|
||||||
|
|
||||||
Product catalog, cart, checkout, and order storage — a `products` /
|
Third piece of the former "Ecommerce functionality" entry (see Lib\Money
|
||||||
`orders` table in SQLite, a session-based cart (rides on the flash-session
|
above for the scoping note). A driver-dispatched `Lib\` class for taking
|
||||||
work above), and a payment gateway integration for actually taking money.
|
a payment, mirroring `Lib\Mailer`'s `mail_driver` config-key pattern —
|
||||||
Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling
|
Stripe first (one `charge()`-shaped call plus webhook signature
|
||||||
a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL)
|
verification), calling the provider's REST API directly via cURL rather
|
||||||
over vendoring a full SDK, same reasoning as vendoring only Twig's `src/`
|
than vendoring an SDK, same reasoning as vendoring only Twig's `src/`
|
||||||
rather than pulling in a package manager. Needs a decision on which
|
rather than pulling in a package manager. Adding a second provider later
|
||||||
provider(s) to support first. Order/product management rides on admin
|
means one more driver case, same as `Lib\Mailer::sendMail()`.
|
||||||
login above. Large feature — likely worth its own sub-breakdown (catalog,
|
|
||||||
cart, checkout, order admin) once it's actually picked up rather than
|
|
||||||
planning it all up front here.
|
|
||||||
|
|
||||||
### 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, Session handling (with flash sessions), Admin login & user management
|
- **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:
|
||||||
recurring billing tied to a user account, content (posts, pages, media)
|
recurring billing tied to a user account, content (posts, pages, media)
|
||||||
marked as gated behind an active subscription, and access checks in
|
marked as gated behind an active subscription, and access checks in
|
||||||
sidecars (`$_SESSION`'s logged-in user + subscription status, similar to
|
sidecars (`$_SESSION`'s logged-in user + subscription status) — the
|
||||||
how admin login gates `/admin/*`). Reuses Ecommerce's payment-gateway
|
access-check half of this now has a shipped foundation to build on:
|
||||||
|
`Lib\Access` (see User roles, groups & page access control in Done)
|
||||||
|
already handles login-gated/group-gated sidecar content; a paywall
|
||||||
|
mostly adds "does this account have an active subscription" as a rule
|
||||||
|
source on top of it. Reuses Ecommerce's payment-gateway
|
||||||
plumbing for the recurring-charge side rather than integrating a payment
|
plumbing for the recurring-charge side rather than integrating a payment
|
||||||
provider a second time — build after Ecommerce rather than in parallel.
|
provider a second time — build after Ecommerce rather than in parallel.
|
||||||
Also needs a decision on how gated content is authored (a `gated: true`
|
Also needs a decision on how gated content is authored (a `gated: true`
|
||||||
@@ -420,7 +163,202 @@ _Nothing yet._
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
_Nothing yet._
|
### User roles, groups & page access control
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Depends on:** Admin login & user management (Done)
|
||||||
|
- **Added:** 2026-07-14
|
||||||
|
- **Shipped:** 2026-07-14 (b882c30)
|
||||||
|
|
||||||
|
Follow-up to Admin login & user management (below), shipped the same day
|
||||||
|
before any of it was committed — so the `users` schema change went into
|
||||||
|
the existing `0002_create_users.sql` rather than a third migration. Two
|
||||||
|
roles (`users.role`): the first user created is `'admin'`, everyone
|
||||||
|
after is `'registered'` with an optional single group
|
||||||
|
(`users.user_group`, a plain text label matched exactly — deliberately
|
||||||
|
no groups table). `/admin/*` and draft preview are admin-only now — a
|
||||||
|
logged-in registered user gets a plain 404 there (not a login redirect;
|
||||||
|
they're authenticated, what they lack is the role) — and the
|
||||||
|
last-active-user lockout guard became a last-active-*admin* guard,
|
||||||
|
applied to both the disable and the new demote action (`/admin/users`
|
||||||
|
also grew group-assignment and promote/demote).
|
||||||
|
|
||||||
|
`Lib\Access` (`novaconium/lib/Access.php`) is the sidecar-level content
|
||||||
|
gate, per the "assign a page/section to a user or group" spec:
|
||||||
|
`Access::require('group:members', 'user:bob')` at the top of a sidecar
|
||||||
|
returns `null` or a ready-made `Response` (anonymous → login redirect
|
||||||
|
carrying a `?return=` path, validated against open redirects;
|
||||||
|
wrong-account → 404, drafts' hide-don't-tease posture). No rules = any
|
||||||
|
logged-in user; admins pass everything. Public is the default twice
|
||||||
|
over: sidecars that never call it are untouched, and static
|
||||||
|
(sidecar-less) pages *can't* call it — always public, which also means
|
||||||
|
a gated page necessarily has a sidecar and is therefore never written
|
||||||
|
to the static HTML cache: the caching/auth standing rule satisfied by
|
||||||
|
construction, no bootstrap exclusion needed. Sections are gated by
|
||||||
|
composition (a shared `_access.php` file `require`'d by each sidecar in
|
||||||
|
the section), not per-directory config. `Access::require()` has no side
|
||||||
|
effects on deny, so the content-index crawl (anonymous GET per sidecar)
|
||||||
|
both drops gated pages from `/search`/`/sitemap.xml` automatically and
|
||||||
|
can't touch the visiting user's session. Verified end-to-end with three
|
||||||
|
real accounts (admin / registered-with-group / registered-without) and
|
||||||
|
a cookie jar each: rule matrix, return-path round trip,
|
||||||
|
`//evil.com`-style return rejection, registered-user 404s on `/admin/*`
|
||||||
|
and drafts, promote/demote + guards, group reassignment taking effect
|
||||||
|
immediately, crawl exclusion, and flag-off zero-footprint posture.
|
||||||
|
Documented at `/admin/docs/access-control` (new topic, linked from the
|
||||||
|
docs nav/index), with supporting updates to `admin-auth`, `drafts`,
|
||||||
|
`sidecars`, `config`, and `libraries`.
|
||||||
|
|
||||||
|
### Admin login & user management
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done)
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14 (b882c30)
|
||||||
|
|
||||||
|
Shipped as specified: the single-user HTTP Basic Auth stopgap that gated
|
||||||
|
`/admin/*` was **replaced, not layered on** — `AdminAuth` keeps its name
|
||||||
|
and call sites (`bootstrap.php`'s admin gate and draft gate) but is now a
|
||||||
|
session login (`Lib\Session`, with a new `Session::regenerate()` against
|
||||||
|
session fixation) against a `users` table
|
||||||
|
(`novaconium/migrations/0002_create_users.sql`, the second
|
||||||
|
framework-shipped migration) with `password_hash()`/`password_verify()`
|
||||||
|
and no external auth library. The `admin_username`/`admin_password_hash`
|
||||||
|
config keys and the `/admin/password-hash` page are gone, superseded by a
|
||||||
|
single `admin_auth_enabled` flag (default `false`) plus new
|
||||||
|
`/admin/login`, `/admin/logout` (a real page now — the pre-router special
|
||||||
|
case in `bootstrap.php` is gone too — and POST-only with a GET confirm
|
||||||
|
form, because the content-index crawl runs every sidecar as a GET and a
|
||||||
|
logout-on-GET would have ended the crawling admin's own session the first
|
||||||
|
time a lazy reindex rendered it), and `/admin/users`
|
||||||
|
(create/disable/enable/change-password) pages, and a
|
||||||
|
`novaconium/bin/create-admin-user.php` CLI (password via stdin, for
|
||||||
|
deploy scripts and lockout recovery). Documented at
|
||||||
|
`/admin/docs/admin-auth`.
|
||||||
|
|
||||||
|
Decisions worth recording: same zero-footprint posture as the content
|
||||||
|
index (flag off → the three auth routes 404 and `Lib\Db` is never
|
||||||
|
touched, so no `data/novaconium.sqlite` appears — the route sidecars
|
||||||
|
self-load config the same way `/search` does); first-user bootstrap keeps
|
||||||
|
the gate open only while the `users` table is empty (creating the first
|
||||||
|
user at `/admin/users` auto-logs you in as it and closes the gate —
|
||||||
|
running the CLI *before* enabling the flag avoids the window entirely);
|
||||||
|
`admin/login` is the one `/admin/*` route exempted from
|
||||||
|
`requireLogin()`, or its redirect would loop; disabling a user kills any
|
||||||
|
live session on its next request (`currentUser()` re-checks the row per
|
||||||
|
request), and disabling the last active user is refused since the
|
||||||
|
empty-table window never reopens — that would be a permanent lockout.
|
||||||
|
Login/user passwords read `$_POST` directly, extending the documented
|
||||||
|
`Lib\Input` exact-value exception (verified with a password containing
|
||||||
|
`<>` end-to-end). Verified route-by-route with real HTTP requests and a
|
||||||
|
cookie jar: flag off (404s, no DB file), setup window, first-user
|
||||||
|
auto-login, fresh-client redirect, wrong/right password, logout,
|
||||||
|
enable/disable/change-password, live-session lockout on disable,
|
||||||
|
last-user guard, CSRF failure paths, CLI creation, and draft gating via
|
||||||
|
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
|
||||||
|
is cleared — the documented cache-clear step, not a new bug).
|
||||||
|
|
||||||
|
### Draft pages (admin-only preview)
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Added:** 2026-07-13
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Let a page under `App/pages/` be written and previewed by an admin without
|
||||||
|
being visible to the public — a config-driven list, `draft_routes` in
|
||||||
|
`App/config.php` (`Route::$dir`-format entries, e.g. `'blog/upcoming-post'`),
|
||||||
|
checked in `novaconium/bootstrap.php` right alongside the existing
|
||||||
|
`/admin/*` gate. Reuses `AdminAuth::isAuthenticated()` (a new method,
|
||||||
|
extracted out of `requireLogin()` so the credential check could be reused
|
||||||
|
with a different failure response) rather than a second auth mechanism:
|
||||||
|
not authenticated → the same plain 404 an unmatched route gets (not a
|
||||||
|
login prompt, so a draft's existence isn't revealed to anyone poking at
|
||||||
|
the URL), authenticated → renders normally. No separate login flow needed
|
||||||
|
— an admin authenticates once at `/admin`, and the browser then resends
|
||||||
|
those same Basic Auth credentials to draft URLs automatically, since
|
||||||
|
they're scoped to the whole origin/realm.
|
||||||
|
|
||||||
|
The gotcha flagged when this entry was written was designed around
|
||||||
|
correctly: sidecar-less pages get written to the static HTML cache and
|
||||||
|
served by `.htaccess` before PHP ever runs, so a draft without its own
|
||||||
|
sidecar needed an explicit exclusion, not just the auth gate —
|
||||||
|
`Renderer::render()` gained an `$excludeFromCache` param for this.
|
||||||
|
**A second, real instance of the same bug was found while testing this
|
||||||
|
feature, pre-dating it entirely:** `/admin` itself
|
||||||
|
(`novaconium/pages/admin/index.twig`) has no sidecar, so it was already
|
||||||
|
being written to the static cache — meaning once any admin visited
|
||||||
|
`/admin` once, the admin panel was served to every subsequent visitor,
|
||||||
|
unauthenticated, straight from `public/cache/admin/`, completely
|
||||||
|
bypassing `AdminAuth`. Fixed in the same change by passing
|
||||||
|
`$excludeFromCache = true` for every `/admin/*` route too, not just
|
||||||
|
drafts. Documented as a standing rule in `AGENTS.md` (next to the
|
||||||
|
`mb_substr`/`|slice` and `|escape('js')` notes) and at
|
||||||
|
`/admin/docs/drafts`/`/admin/docs/caching`: any future mechanism that
|
||||||
|
conditionally hides page content from the public has to make the same
|
||||||
|
check, not just gate the initial request.
|
||||||
|
|
||||||
|
### Session handling (with flash sessions)
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** High
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
A `Lib\` wrapper around PHP's native session handling (`session_start()`,
|
||||||
|
`$_SESSION`, not a custom session store) — `Lib\Session`
|
||||||
|
(`novaconium/lib/Session.php`), all-static and lazy-start, same shape as
|
||||||
|
the already-shipped `Lib\Csrf` (which also touches the native session; the
|
||||||
|
two coexist in the same request without conflict). Ships
|
||||||
|
`get()`/`set()`/`has()`/`remove()` plus CodeIgniter-style flash data
|
||||||
|
(`flash()`/`getFlash()`) — a value set now that's readable on exactly the
|
||||||
|
next request, then gone, for post/redirect/GET flows like
|
||||||
|
`App/pages/contact/index.php`'s hand-rolled `?sent=1` (not refactored to
|
||||||
|
use it in this change — cited in the original spec as the motivating
|
||||||
|
example, not a mandate to touch working demo code). The flash mechanism is
|
||||||
|
a single per-request swap (snapshot last request's flash bucket into an
|
||||||
|
in-memory static on first touch, then clear the stored bucket so this
|
||||||
|
request's `flash()` calls fill a fresh one for the request after), not a
|
||||||
|
separate expiry/sweep pass — verified end-to-end across three real,
|
||||||
|
separate HTTP requests sharing a cookie jar (not three calls in one PHP
|
||||||
|
process), confirming a flashed value appears on exactly the next request
|
||||||
|
and is gone on the one after. Foundational alongside SQLite groundwork —
|
||||||
|
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`
|
||||||
|
sections.
|
||||||
|
|
||||||
|
### SQLite groundwork
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** High
|
||||||
|
- **Added:** 2026-07-12
|
||||||
|
- **Shipped:** 2026-07-14
|
||||||
|
|
||||||
|
Laid the groundwork for optional SQLite storage: `Lib\Db`
|
||||||
|
(`novaconium/lib/Db.php`) is a thin, no-ORM PDO wrapper (prepared
|
||||||
|
statements only, no string-interpolation helper ever, per `Lib\Input`'s
|
||||||
|
existing documented security stance) so features that need persistence —
|
||||||
|
404 tracking (see Won't Do; superseded by Matomo before this shipped),
|
||||||
|
admin login, blog tags, internal search, and anything future — have a
|
||||||
|
common place to store data instead of ad hoc flat files. Data lives in a
|
||||||
|
new top-level `data/` directory — deliberately outside both `public/`
|
||||||
|
(would be web-accessible) and `novaconium/` (gets wholesale-replaced by the
|
||||||
|
"Updating the framework" workflow documented at
|
||||||
|
`/admin/docs/getting-started`, so anything persisted there would be
|
||||||
|
destroyed by the next update) — gitignored per-file, with a tracked
|
||||||
|
`.gitkeep`. Designed driver-agnostic (no SQLite-only SQL in the mechanism
|
||||||
|
itself) specifically so MySQL support wouldn't need a retrofit — see that
|
||||||
|
entry below (shipped 2026-07-14) for the connection/config/migration API,
|
||||||
|
which superseded the single-connection shape (`db_driver`/`db_path`/
|
||||||
|
`db_migrations_dir` config keys) this entry originally shipped with.
|
||||||
|
|
||||||
## Won't Do
|
## Won't Do
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Validate;
|
||||||
|
|
||||||
|
require __DIR__ . '/../autoload.php';
|
||||||
|
|
||||||
|
// Creates an admin user from the command line (e.g. from a deploy script,
|
||||||
|
// or to fix a lockout) — the CLI counterpart to /admin/users, and the way
|
||||||
|
// to create the first user *before* flipping admin_auth_enabled on, which
|
||||||
|
// avoids the brief open-access setup window /admin/users otherwise relies
|
||||||
|
// on (see /admin/docs/admin-auth).
|
||||||
|
//
|
||||||
|
// php novaconium/bin/create-admin-user.php <username> <email>
|
||||||
|
//
|
||||||
|
// The password is read from stdin — typed at the prompt (echo suppressed
|
||||||
|
// where the terminal supports it), or piped:
|
||||||
|
//
|
||||||
|
// echo 'the-password' | php novaconium/bin/create-admin-user.php admin admin@example.com
|
||||||
|
//
|
||||||
|
// Deliberately not gated on admin_auth_enabled: creating the row is
|
||||||
|
// harmless while the gate is off, and doing it first is the safer order.
|
||||||
|
|
||||||
|
$username = trim((string) ($argv[1] ?? ''));
|
||||||
|
$email = Validate::isEmail((string) ($argv[2] ?? ''));
|
||||||
|
if ($username === '' || strlen($username) > 64 || $email === false) {
|
||||||
|
fwrite(STDERR, "Usage: php novaconium/bin/create-admin-user.php <username> <email>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Db::connection() runs pending migrations on first touch, so the users
|
||||||
|
// table exists after this even on a fresh clone.
|
||||||
|
$exists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)', [$username])->fetchColumn();
|
||||||
|
if ($exists) {
|
||||||
|
fwrite(STDERR, "A user named '{$username}' already exists.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$emailExists = (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)', [$email])->fetchColumn();
|
||||||
|
if ($emailExists) {
|
||||||
|
fwrite(STDERR, "A user with the email '{$email}' already exists.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$interactive = stream_isatty(STDIN);
|
||||||
|
if ($interactive) {
|
||||||
|
fwrite(STDOUT, "Password for '{$username}': ");
|
||||||
|
// Suppress echo while the password is typed; restore afterwards.
|
||||||
|
// shell_exec() may be unavailable/no-op on some setups — then the
|
||||||
|
// password just echoes, same as any basic CLI prompt.
|
||||||
|
shell_exec('stty -echo 2> /dev/null');
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = rtrim((string) fgets(STDIN), "\r\n");
|
||||||
|
|
||||||
|
if ($interactive) {
|
||||||
|
shell_exec('stty echo 2> /dev/null');
|
||||||
|
fwrite(STDOUT, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
fwrite(STDERR, "Use a password of at least 8 characters.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always role 'admin', as the script name says — /admin/users is the
|
||||||
|
// place to create registered users; this exists for first-user setup and
|
||||||
|
// 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(
|
||||||
|
"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), $now, $now]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "User '{$username}' created.\n";
|
||||||
|
echo "If admin auth isn't enabled yet, set 'admin_auth_enabled' => true in App/config.php.\n";
|
||||||
@@ -72,6 +72,10 @@ $template = <<<TWIG
|
|||||||
{% block description %}One or two sentences describing this page.{% endblock %}
|
{% block description %}One or two sentences describing this page.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block keywords %}{% endblock %}
|
||||||
|
{% block tags %}{% endblock %}
|
||||||
|
{% block changefreq %}monthly{% endblock %}
|
||||||
|
{% block priority %}0.5{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}website{% endblock %}
|
{% block og_type %}website{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
|
||||||
|
require __DIR__ . '/../autoload.php';
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
echo "content_index_enabled is false — nothing to do. See /admin/docs/content-index.\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignores content_index_auto (the lazy-vs-CLI-only toggle) — this script
|
||||||
|
// is the explicit-trigger path, so it always reindexes when run.
|
||||||
|
ContentIndexer::reindex();
|
||||||
|
|
||||||
|
echo "Content index rebuilt.\n";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Lib\Db;
|
||||||
|
|
||||||
|
require __DIR__ . '/../autoload.php';
|
||||||
|
|
||||||
|
// Db::connection() applies any pending migrations for that connection as a
|
||||||
|
// side effect of opening it (see Lib\Db::migrate()) — this script triggers
|
||||||
|
// that explicitly for every configured connection, e.g. from a deploy
|
||||||
|
// script, without serving a request first.
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($config['db_connections']) as $name) {
|
||||||
|
Db::connection($name);
|
||||||
|
echo "Migrated connection '{$name}'.\n";
|
||||||
|
}
|
||||||
+50
-33
@@ -4,11 +4,9 @@
|
|||||||
* Front-controller wiring. Every request — via public/index.php (Apache) or
|
* Front-controller wiring. Every request — via public/index.php (Apache) or
|
||||||
* public/router.php (php -S) — ends up require'ing this one file, which:
|
* public/router.php (php -S) — ends up require'ing this one file, which:
|
||||||
* 1. loads config (framework defaults + optional App/ override)
|
* 1. loads config (framework defaults + optional App/ override)
|
||||||
* 2. handles the one hardcoded route (/admin/logout) that exists outside
|
* 2. resolves the URL to a page directory (Router)
|
||||||
* the normal page tree
|
* 3. gates /admin/* behind session login, if enabled (AdminAuth)
|
||||||
* 3. resolves the URL to a page directory (Router)
|
* 4. renders the matched page, or a 404 (Renderer)
|
||||||
* 4. gates /admin/* behind Basic Auth, if configured (AdminAuth)
|
|
||||||
* 5. renders the matched page, or a 404 (Renderer)
|
|
||||||
* There's no framework "kernel" class doing this — it's just a plain
|
* There's no framework "kernel" class doing this — it's just a plain
|
||||||
* top-to-bottom script, deliberately, so a dev can read the whole
|
* top-to-bottom script, deliberately, so a dev can read the whole
|
||||||
* request lifecycle in one file without chasing an abstraction.
|
* request lifecycle in one file without chasing an abstraction.
|
||||||
@@ -37,20 +35,7 @@ if ($config['debug']) {
|
|||||||
ini_set('display_errors', '1');
|
ini_set('display_errors', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim the query string and any trailing slash so /admin/logout/ and
|
|
||||||
// /admin/logout?x=1 both match the check below the same way $router does
|
|
||||||
// internally for real page routes.
|
|
||||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
$requestPath = rtrim(parse_url($requestUri, PHP_URL_PATH) ?: '/', '/');
|
|
||||||
|
|
||||||
// /admin/logout isn't a real page under App/pages/ or novaconium/pages/ —
|
|
||||||
// there's nothing to route to, so it's special-cased here before the
|
|
||||||
// router even runs. AdminAuth::logout() always sends a fresh 401 challenge
|
|
||||||
// (the standard trick for "logging out" of HTTP Basic Auth, which has no
|
|
||||||
// real server-side session to invalidate) and exits immediately.
|
|
||||||
if ($requestPath === '/admin/logout') {
|
|
||||||
AdminAuth::logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Router::resolve() only answers "does a page exist at this URL, and if
|
// Router::resolve() only answers "does a page exist at this URL, and if
|
||||||
// so which directory / what params?" — it never touches Twig, sidecars, or
|
// so which directory / what params?" — it never touches Twig, sidecars, or
|
||||||
@@ -58,35 +43,67 @@ if ($requestPath === '/admin/logout') {
|
|||||||
$router = new Router($config['pages_dirs']);
|
$router = new Router($config['pages_dirs']);
|
||||||
$route = $router->resolve($requestUri);
|
$route = $router->resolve($requestUri);
|
||||||
|
|
||||||
// Every route under /admin/* — clear-cache, docs, password-hash, and any
|
|
||||||
// admin page a project adds later — is gated here, once, rather than in
|
|
||||||
// each page individually. A new admin page is automatically protected the
|
|
||||||
// moment it exists; nothing to remember to wire up. No-op (open access)
|
|
||||||
// when admin_password_hash is empty, which is the default. See
|
|
||||||
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
|
||||||
if ($route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'))) {
|
|
||||||
AdminAuth::requireLogin($config['admin_username'], $config['admin_password_hash']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both of these are derived, request-independent config values that get
|
// Both of these are derived, request-independent config values that get
|
||||||
// handed to the Renderer so it can expose them to every Twig template as
|
// handed to the Renderer so it can expose them to every Twig template as
|
||||||
// globals (matomo_url/matomo_site_id/admin_auth_enabled) — see
|
// globals (matomo_url/matomo_site_id/admin_auth_enabled) — see
|
||||||
// Renderer::__construct(). Normalizing the trailing slash here means every
|
// Renderer::__construct(). Normalizing the trailing slash here means every
|
||||||
// template can safely do `matomo_url + 'matomo.php'` without checking.
|
// template can safely do `matomo_url + 'matomo.php'` without checking.
|
||||||
$matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') . '/' : '';
|
$matomoUrl = $config['matomo_url'] !== '' ? rtrim($config['matomo_url'], '/') . '/' : '';
|
||||||
$adminAuthEnabled = $config['admin_password_hash'] !== '';
|
$adminAuthEnabled = (bool) $config['admin_auth_enabled'];
|
||||||
|
|
||||||
$cache = new Cache($config['cache_dir']);
|
$cache = new Cache($config['cache_dir']);
|
||||||
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name']);
|
$renderer = new Renderer($config['pages_dirs'], $cache, $adminAuthEnabled, $matomoUrl, $config['matomo_site_id'], $config['site_name'], $config['content_index_enabled']);
|
||||||
|
|
||||||
|
// Every route under /admin/* — clear-cache, docs, users, and any admin
|
||||||
|
// page a project adds later — is gated here, once, rather than in each
|
||||||
|
// page individually. A new admin page is automatically protected the
|
||||||
|
// moment it exists; nothing to remember to wire up. Two steps: nobody
|
||||||
|
// logged in → redirect to the login form (requireLogin() exits); logged
|
||||||
|
// in but not an admin (a 'registered' user — see /admin/docs/admin-auth)
|
||||||
|
// → the same plain 404 an unmatched route gets, since bouncing an
|
||||||
|
// already-authenticated user back to the login form would be a lie (what
|
||||||
|
// they lack is the admin role, not a session). The one exemption is the
|
||||||
|
// login form itself, which has to stay reachable logged-out or
|
||||||
|
// requireLogin()'s redirect to it would loop forever. No-op (open access)
|
||||||
|
// when admin_auth_enabled is false (the default), or while no users exist
|
||||||
|
// yet (so the first user can be created at /admin/users). See
|
||||||
|
// novaconium/src/AdminAuth.php and /admin/docs/admin-auth.
|
||||||
|
$isAdminRoute = $route->found && ($route->dir === 'admin' || str_starts_with((string) $route->dir, 'admin/'));
|
||||||
|
if ($isAdminRoute && $route->dir !== 'admin/login') {
|
||||||
|
AdminAuth::requireLogin($config['admin_auth_enabled']);
|
||||||
|
|
||||||
|
if (!AdminAuth::isAdmin($config['admin_auth_enabled'])) {
|
||||||
|
$renderer->renderNotFound($requestUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A route listed in draft_routes is only visible to a logged-in admin —
|
||||||
|
// anyone else (including logged-in registered users) gets treated exactly
|
||||||
|
// like a route that doesn't exist at all (a plain 404, not a login
|
||||||
|
// prompt), so a draft's existence isn't revealed to anyone poking at the
|
||||||
|
// URL. See /admin/docs/drafts. Reuses the same access check /admin/* uses
|
||||||
|
// (AdminAuth::isAdmin()) — an admin logs in once at /admin/login, and the
|
||||||
|
// session cookie covers draft URLs too, since it's scoped to the whole
|
||||||
|
// origin.
|
||||||
|
$isDraftRoute = $route->found && in_array($route->dir, $config['draft_routes'], true);
|
||||||
|
|
||||||
// $route->found is false for anything Router couldn't match to a real page
|
// $route->found is false for anything Router couldn't match to a real page
|
||||||
// (no index.twig or index.php at the resolved directory) — render the 404
|
// (no index.twig or index.php at the resolved directory) — render the 404
|
||||||
// page and stop. Otherwise render the matched page: runs its sidecar (if
|
// page and stop. Otherwise render the matched page: runs its sidecar (if
|
||||||
// any), resolves the nearest layout, renders Twig, and writes the static
|
// any), resolves the nearest layout, renders Twig, and writes the static
|
||||||
// cache for sidecar-less pages. See novaconium/src/Renderer.php.
|
// cache for sidecar-less pages — except for drafts and $isAdminRoute (see
|
||||||
if (!$route->found) {
|
// Renderer::render()'s $excludeFromCache param). Every /admin/* route is
|
||||||
|
// excluded from the cache for the same reason a draft is: a sidecar-less
|
||||||
|
// admin page (e.g. novaconium/pages/admin/index.twig) would otherwise get
|
||||||
|
// written to public/cache/ as plain HTML the first time an authenticated
|
||||||
|
// admin visited it, and .htaccess serves a cached file before PHP (and
|
||||||
|
// therefore AdminAuth::requireLogin()) ever runs again — permanently
|
||||||
|
// serving the admin panel to anyone, unauthenticated, straight from the
|
||||||
|
// static cache. See novaconium/src/Renderer.php.
|
||||||
|
if (!$route->found || ($isDraftRoute && !AdminAuth::isAdmin($config['admin_auth_enabled']))) {
|
||||||
$renderer->renderNotFound($requestUri);
|
$renderer->renderNotFound($requestUri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$renderer->render($route, $requestUri);
|
$renderer->render($route, $requestUri, $isDraftRoute || $isAdminRoute);
|
||||||
|
|||||||
+111
-10
@@ -28,14 +28,115 @@ return [
|
|||||||
'matomo_url' => '',
|
'matomo_url' => '',
|
||||||
'matomo_site_id' => '',
|
'matomo_site_id' => '',
|
||||||
|
|
||||||
// Gates every /admin/* route (clear-cache, docs, and any future admin
|
// Gates every /admin/* route (clear-cache, docs, users, and any future
|
||||||
// page) behind HTTP Basic Auth. Leave admin_password_hash empty (the
|
// admin page) behind a session login against the `users` table on
|
||||||
// default) to disable the gate entirely — matches this project's
|
// Lib\Db's default connection — see /admin/docs/admin-auth. The first
|
||||||
// existing wide-open behavior until a project opts in. Generate a hash
|
// user created is the admin; users after that are 'registered', each
|
||||||
// with: php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"
|
// with an optional group, and see whatever content sidecars grant via
|
||||||
// and set both via App/config.php, e.g.:
|
// Lib\Access (see /admin/docs/access-control) — /admin/* itself 404s
|
||||||
// 'admin_username' => 'admin',
|
// for them. Off by default because it depends on SQLite (same
|
||||||
// 'admin_password_hash' => '$2y$10$...',
|
// reasoning as content_index_enabled below): when false, /admin/* is
|
||||||
'admin_username' => 'admin',
|
// wide open, /admin/login, /admin/logout, and /admin/users 404,
|
||||||
'admin_password_hash' => '',
|
// Access::require() allows everything, and nothing ever touches
|
||||||
|
// Lib\Db because of this feature. After enabling it via
|
||||||
|
// App/config.php, create the first user at /admin/users (open access
|
||||||
|
// until at least one user exists) or with:
|
||||||
|
// php novaconium/bin/create-admin-user.php <username>
|
||||||
|
'admin_auth_enabled' => false,
|
||||||
|
|
||||||
|
// Lib\Db (see /admin/docs/database) — named, simultaneously-usable
|
||||||
|
// connections, keyed by name; 'default' is the only one required. A
|
||||||
|
// sidecar can use more than one at once, e.g. Db::query(...) (default)
|
||||||
|
// alongside Db::query(..., 'legacy'). Supported drivers: 'sqlite',
|
||||||
|
// 'mysql'. The default connection's path deliberately lives outside
|
||||||
|
// both public/ (must never be web-accessible) and novaconium/ (gets
|
||||||
|
// wholly replaced on a framework update — see
|
||||||
|
// /admin/docs/getting-started's "Updating the framework" section) — a
|
||||||
|
// top-level data/ directory, project-owned like App/, is the only safe
|
||||||
|
// place for it. migrations_dir is optional per connection (omit it to
|
||||||
|
// never run migrations against that connection, e.g. a read-only
|
||||||
|
// legacy database) and accepts either one path or an ordered list of
|
||||||
|
// roots — the default connection lists novaconium/migrations/ (framework
|
||||||
|
// -shipped schema, e.g. the content index — see /admin/docs/content-index)
|
||||||
|
// before App/migrations/ (project migrations), so framework migrations
|
||||||
|
// always apply first. NOTE: unlike every other key here, App/config.php
|
||||||
|
// merges into db_connections one level deeper than a normal shallow
|
||||||
|
// override — see the comment on Lib\Db::config() — so adding a second
|
||||||
|
// connection there doesn't require repeating 'default'.
|
||||||
|
'db_connections' => [
|
||||||
|
'default' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||||
|
'migrations_dir' => [
|
||||||
|
__DIR__ . '/migrations',
|
||||||
|
__DIR__ . '/../App/migrations',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Routes an admin can preview before the public can see them (see
|
||||||
|
// /admin/docs/drafts) — a list of Route::$dir-format paths, no leading
|
||||||
|
// slash, e.g. 'blog/upcoming-post'. Not authenticated as admin (per
|
||||||
|
// AdminAuth::isAuthenticated()) → 404, same as a route that doesn't
|
||||||
|
// exist at all, so a draft's existence isn't revealed to anyone
|
||||||
|
// poking at the URL. Authenticated → renders normally, and — critically
|
||||||
|
// — is never written to the static HTML cache regardless of whether
|
||||||
|
// the page has a sidecar (see Renderer::render()'s $isDraft param),
|
||||||
|
// since a world-readable cached copy would otherwise permanently leak
|
||||||
|
// the draft the first time an admin previewed it.
|
||||||
|
'draft_routes' => [],
|
||||||
|
|
||||||
|
// Content index (see /admin/docs/content-index) — backs /sitemap.xml,
|
||||||
|
// /search, and blog tag browsing. Off by default: all three depend on
|
||||||
|
// SQLite (Lib\Db), a real dependency plenty of sites built on this
|
||||||
|
// framework won't want at all, the same reasoning that keeps Matomo
|
||||||
|
// and admin auth off by default above. When false, all three routes
|
||||||
|
// 404 exactly as if they didn't exist, and nothing ever touches
|
||||||
|
// Lib\Db because of this feature — no data/novaconium.sqlite gets
|
||||||
|
// created just because the code exists. content_index_auto only
|
||||||
|
// matters once enabled: true (the default) reindexes lazily,
|
||||||
|
// on-demand, the first time a stale index is actually needed (never on
|
||||||
|
// a normal page view); false disables that and leaves indexing
|
||||||
|
// entirely to `php novaconium/bin/index-content.php`, e.g. from a
|
||||||
|
// deploy step.
|
||||||
|
'content_index_enabled' => false,
|
||||||
|
'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,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
use App\AdminAuth;
|
||||||
|
use App\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidecar-level access control for page content — the way a page (or a
|
||||||
|
* whole section, one line per page) is assigned to a user, a group, or
|
||||||
|
* just "anyone logged in". See /admin/docs/access-control. Usage, at the
|
||||||
|
* top of a sidecar:
|
||||||
|
*
|
||||||
|
* if ($denied = Access::require('group:members')) {
|
||||||
|
* return $denied;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Rules: 'group:<name>' (the user's users.user_group matches), or
|
||||||
|
* 'user:<username>' (that exact account); several rules mean "any of
|
||||||
|
* these". No rules at all means any logged-in user. Admins always pass
|
||||||
|
* every rule. Returns null when the request may proceed, or a Response
|
||||||
|
* for the sidecar to return: a 303 to /admin/login (with a ?return= path
|
||||||
|
* back here) when nobody is logged in, or a plain 404 when someone *is*
|
||||||
|
* logged in but isn't allowed — same hide-don't-tease posture as draft
|
||||||
|
* pages, and the same plain-text 404 /search returns when disabled.
|
||||||
|
*
|
||||||
|
* Public is the default, twice over: a sidecar that never calls this is
|
||||||
|
* untouched, and a page with no sidecar at all *can't* call it — static
|
||||||
|
* (cached) pages are always public. That's load-bearing, not incidental:
|
||||||
|
* only sidecar-less pages are ever written to the static HTML cache
|
||||||
|
* (which .htaccess serves before PHP runs — see /admin/docs/caching), so
|
||||||
|
* a gated page, necessarily having a sidecar, is never cached. This
|
||||||
|
* satisfies the caching/auth standing rule in AGENTS.md by construction
|
||||||
|
* rather than by a bootstrap.php exclusion like drafts/admin need.
|
||||||
|
*
|
||||||
|
* Same open-until-configured posture as the rest of admin auth: with
|
||||||
|
* admin_auth_enabled off, or while no users exist yet, require() allows
|
||||||
|
* everything (there'd be nothing to log in as) — and never touches
|
||||||
|
* Lib\Db, so a site that never opted in never gets a database file.
|
||||||
|
*
|
||||||
|
* The content-index crawl runs every sidecar as an anonymous GET, so a
|
||||||
|
* gated page's sidecar short-circuits to the login redirect during a
|
||||||
|
* crawl — Renderer::renderForIndex() discards Responses, meaning gated
|
||||||
|
* pages are automatically absent from /search and /sitemap.xml, with no
|
||||||
|
* extra wiring. require() has no side effects on deny (the return path
|
||||||
|
* travels in the redirect URL, not the session) for the same reason: a
|
||||||
|
* crawl must not scribble on the visitor's session.
|
||||||
|
*/
|
||||||
|
final class Access
|
||||||
|
{
|
||||||
|
private static ?bool $enabled = null;
|
||||||
|
|
||||||
|
public static function require(string ...$rules): ?Response
|
||||||
|
{
|
||||||
|
if (!self::enabled() || !AdminAuth::hasUsers()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = AdminAuth::currentUser();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
$path = (string) (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
||||||
|
|
||||||
|
return Response::redirect('/admin/login?return=' . rawurlencode($path), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user['role'] === 'admin' || $rules === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
if (str_starts_with($rule, 'user:') && substr($rule, 5) === $user['username']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($rule, 'group:') && $user['user_group'] !== '' && substr($rule, 6) === $user['user_group']) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same two-step config load bootstrap.php/bin scripts and the
|
||||||
|
* /search sidecar use — Lib\ classes aren't handed $config, so this
|
||||||
|
* loads its own copy to read admin_auth_enabled (memoized per
|
||||||
|
* request; static state never survives across requests).
|
||||||
|
*/
|
||||||
|
private static function enabled(): bool
|
||||||
|
{
|
||||||
|
if (self::$enabled === null) {
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$enabled = (bool) $config['admin_auth_enabled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,12 @@ namespace Lib;
|
|||||||
*
|
*
|
||||||
* <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
* <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
*
|
*
|
||||||
* This is the first thing in the framework that starts a native PHP session
|
* This was the first thing in the framework to start a native PHP session
|
||||||
* — but only lazily, the moment token()/verify() is actually called. A page
|
* — but only lazily, the moment token()/verify() is actually called. A page
|
||||||
* that never touches Csrf never gets a session cookie. This is unrelated to
|
* that never touches Csrf never gets a session cookie. Lib\Session and
|
||||||
* Lib\AdminAuth, which stays fully stateless (HTTP Basic Auth, no sessions
|
* App\AdminAuth (session-based admin login) now touch the same native
|
||||||
* for login state) — see novaconium/src/AdminAuth.php.
|
* session the same lazy way — safe in any order, since ensureSession()
|
||||||
|
* no-ops when a session is already active.
|
||||||
*/
|
*/
|
||||||
final class Csrf
|
final class Csrf
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin PDO wrapper — the SQLite/MySQL groundwork tracked in
|
||||||
|
* novaconium/ISSUES.md. No ORM, no query builder, consistent with this
|
||||||
|
* project's no-Composer, no-build-step philosophy: just lazily-opened PDO
|
||||||
|
* connections plus a minimal per-connection migration runner.
|
||||||
|
*
|
||||||
|
* Supports multiple, simultaneously-open, independently-configured 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 may legitimately need more than one
|
||||||
|
* database at once (e.g. this site's own SQLite data plus a MySQL
|
||||||
|
* connection to a legacy/external database). The common single-database
|
||||||
|
* case still reads the same as a single-connection API would:
|
||||||
|
* Db::query('SELECT ...', [...]) always targets the 'default' connection
|
||||||
|
* unless a different connection name is passed explicitly.
|
||||||
|
*
|
||||||
|
* Db::query() is the only query-running helper, and it only ever accepts a
|
||||||
|
* SQL string plus a params array for PDO to bind — there is deliberately no
|
||||||
|
* string-interpolation convenience method. See Lib\Input's doc-comment: the
|
||||||
|
* only real defense against SQL injection is parameterized queries, never
|
||||||
|
* string concatenation or sanitize-then-interpolate, however "cleaned" input
|
||||||
|
* looks. Call Db::connection() directly for anything Db::query() doesn't
|
||||||
|
* cover (transactions, lastInsertId(), etc.) — it returns the raw PDO
|
||||||
|
* instance for the named connection.
|
||||||
|
*
|
||||||
|
* Lazy-connect, same shape as Lib\Csrf's lazy session start: nothing opens
|
||||||
|
* a database connection or runs a migration until the first real call to a
|
||||||
|
* given connection name, so a request that never touches a particular
|
||||||
|
* database never pays for it.
|
||||||
|
*/
|
||||||
|
final class Db
|
||||||
|
{
|
||||||
|
/** @var array<string, PDO> */
|
||||||
|
private static array $connections = [];
|
||||||
|
|
||||||
|
public static function connection(string $name = 'default'): PDO
|
||||||
|
{
|
||||||
|
return self::$connections[$name] ??= self::connect($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int|string,mixed> $params
|
||||||
|
*/
|
||||||
|
public static function query(string $sql, array $params = [], string $connection = 'default'): \PDOStatement
|
||||||
|
{
|
||||||
|
$statement = self::connection($connection)->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
|
||||||
|
return $statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function connect(string $name): PDO
|
||||||
|
{
|
||||||
|
$connections = self::config()['db_connections'];
|
||||||
|
|
||||||
|
if (!isset($connections[$name])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"No db_connections entry named '{$name}' in config — configured connections: " .
|
||||||
|
(empty($connections) ? '(none)' : implode(', ', array_keys($connections)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$connectionConfig = $connections[$name];
|
||||||
|
$driver = $connectionConfig['driver'] ?? null;
|
||||||
|
|
||||||
|
$pdo = match ($driver) {
|
||||||
|
'sqlite' => self::connectSqlite($connectionConfig),
|
||||||
|
'mysql' => self::connectMysql($connectionConfig),
|
||||||
|
default => throw new RuntimeException(
|
||||||
|
"Connection '{$name}' has unsupported driver " .
|
||||||
|
(is_string($driver) ? "'{$driver}'" : 'null') . " — only 'sqlite' and 'mysql' are implemented."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
self::migrate($pdo, $connectionConfig['migrations_dir'] ?? null);
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function connectSqlite(array $config): PDO
|
||||||
|
{
|
||||||
|
self::requireDriver('sqlite', 'pdo_sqlite',
|
||||||
|
'bundled with PHP but sometimes not enabled — Debian/Ubuntu: `apt install php-sqlite3`; Arch: uncomment `extension=pdo_sqlite` in php.ini');
|
||||||
|
|
||||||
|
$path = $config['path'];
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = new PDO('sqlite:' . $path, options: [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function connectMysql(array $config): PDO
|
||||||
|
{
|
||||||
|
self::requireDriver('mysql', 'pdo_mysql',
|
||||||
|
'Debian/Ubuntu: `apt install php-mysql`; Arch: uncomment `extension=pdo_mysql` in php.ini');
|
||||||
|
|
||||||
|
$charset = $config['charset'] ?? 'utf8mb4';
|
||||||
|
$dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$charset}";
|
||||||
|
|
||||||
|
return new PDO($dsn, $config['username'], $config['password'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A missing PDO driver otherwise surfaces as a bare PDOException
|
||||||
|
* ("could not find driver") from deep inside a connect call — hit for
|
||||||
|
* real the first time this ran on a PHP install without pdo_sqlite
|
||||||
|
* enabled. Checking PDO::getAvailableDrivers() up front turns that
|
||||||
|
* into an error that names the extension and how to install it.
|
||||||
|
*/
|
||||||
|
private static function requireDriver(string $driver, string $extension, string $installHint): void
|
||||||
|
{
|
||||||
|
if (!in_array($driver, PDO::getAvailableDrivers(), true)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"PHP is missing the {$extension} extension, which this connection's '{$driver}' driver needs ({$installHint}). " .
|
||||||
|
'Verify with `php -m`, and restart PHP after enabling it. See /admin/docs/database.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies any *.sql file under $migrationsDirs not yet recorded in this
|
||||||
|
* connection's own schema_migrations table. $migrationsDirs is an
|
||||||
|
* ordered list of roots (a single string is accepted too, wrapped
|
||||||
|
* internally) — each root's own files are applied in filename order
|
||||||
|
* within that root (numeric prefixes, e.g. 0001_create_x.sql,
|
||||||
|
* 0002_add_y.sql, control order within a root), and roots are fully
|
||||||
|
* processed one at a time in the order given, not interleaved by
|
||||||
|
* filename across roots. This lets framework-shipped migrations (e.g.
|
||||||
|
* novaconium/migrations/) always apply before a project's own
|
||||||
|
* (App/migrations/) on the same connection — see
|
||||||
|
* novaconium/config.php's db_connections.default.migrations_dir and
|
||||||
|
* AGENTS.md.
|
||||||
|
*
|
||||||
|
* Each file is tracked once applied and never re-run, keyed by its path
|
||||||
|
* relative to the repo root (e.g. novaconium/migrations/0001_x.sql) —
|
||||||
|
* not bare filename, because two roots can each contain a same-named
|
||||||
|
* file (a framework migration and an unrelated project migration both
|
||||||
|
* numbered 0001_...); tracking by bare filename would make the second
|
||||||
|
* one seen look "already applied" and silently skip it. A 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.
|
||||||
|
*
|
||||||
|
* A connection with no migrations_dir set skips this entirely — e.g. a
|
||||||
|
* connection to a legacy database this project shouldn't manage schema
|
||||||
|
* for. Runs automatically on every first connection() call per
|
||||||
|
* process, per connection name — cheap (one query plus a directory
|
||||||
|
* glob per root), so no separate "migrate" step is required, matching
|
||||||
|
* the framework's zero-config philosophy elsewhere (e.g. static
|
||||||
|
* caching). novaconium/bin/migrate.php exists to run it explicitly for
|
||||||
|
* every configured connection (e.g. from a deploy script) without
|
||||||
|
* serving a request first.
|
||||||
|
*
|
||||||
|
* @param string|string[]|null $migrationsDirs
|
||||||
|
*/
|
||||||
|
private static function migrate(PDO $pdo, string|array|null $migrationsDirs): void
|
||||||
|
{
|
||||||
|
if ($migrationsDirs === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS schema_migrations (' .
|
||||||
|
'filename VARCHAR(255) PRIMARY KEY, ' .
|
||||||
|
'applied_at VARCHAR(32) NOT NULL' .
|
||||||
|
')'
|
||||||
|
);
|
||||||
|
|
||||||
|
$applied = $pdo->query('SELECT filename FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$applied = array_flip($applied);
|
||||||
|
|
||||||
|
// novaconium/lib/ -> novaconium/ -> repo root, two levels up.
|
||||||
|
$repoRoot = rtrim(realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2), '/') . '/';
|
||||||
|
|
||||||
|
foreach ((array) $migrationsDirs as $dir) {
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = glob(rtrim($dir, '/') . '/*.sql') ?: [];
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
// realpath() resolves any ".." left over from a
|
||||||
|
// migrations_dir like __DIR__ . '/../App/migrations' (glob()
|
||||||
|
// doesn't normalize the paths it returns), so the tracked
|
||||||
|
// name is clean, e.g. "App/migrations/0001_x.sql" rather
|
||||||
|
// than "novaconium/../App/migrations/0001_x.sql".
|
||||||
|
$resolved = realpath($file) ?: $file;
|
||||||
|
$trackedName = str_starts_with($resolved, $repoRoot) ? substr($resolved, strlen($repoRoot)) : basename($file);
|
||||||
|
|
||||||
|
if (isset($applied[$trackedName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec((string) file_get_contents($file));
|
||||||
|
|
||||||
|
$insert = $pdo->prepare('INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)');
|
||||||
|
$insert->execute([$trackedName, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads config the same way bootstrap.php/bin scripts do (framework
|
||||||
|
* defaults shallow-merged with App/config.php), except for
|
||||||
|
* db_connections specifically: a shallow array_merge would let a
|
||||||
|
* project's App/config.php silently drop the framework's 'default'
|
||||||
|
* connection just by adding a second named connection (array_merge
|
||||||
|
* replaces the whole key, it doesn't merge inside it). db_connections
|
||||||
|
* is therefore merged one level deeper, by connection name, so adding
|
||||||
|
* e.g. 'legacy' in App/config.php doesn't require repeating 'default'.
|
||||||
|
* This is the one config key in the project that isn't plain
|
||||||
|
* shallow-merge — see AGENTS.md.
|
||||||
|
*
|
||||||
|
* @return array{db_connections: array<string, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private static function config(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,20 +13,22 @@ namespace Lib;
|
|||||||
* novaconium/src/Renderer.php), so this cleaning is a second layer, not the
|
* novaconium/src/Renderer.php), so this cleaning is a second layer, not the
|
||||||
* only one. The real defense against SQL injection is parameterized queries
|
* only one. The real defense against SQL injection is parameterized queries
|
||||||
* (PDO prepared statements) — never string concatenation or
|
* (PDO prepared statements) — never string concatenation or
|
||||||
* sanitize-then-interpolate, however "cleaned" the input looks. There's no
|
* sanitize-then-interpolate, however "cleaned" the input looks. Lib\Db (see
|
||||||
* database layer in this framework yet (SQLite groundwork is tracked as
|
* /admin/docs/database) is the database layer — its query() method uses
|
||||||
* Backlog in novaconium/ISSUES.md); when one lands, use PDO prepared statements
|
* PDO prepared statements exclusively for this reason. This class
|
||||||
* exclusively. This class deliberately does not (and will not) expose an
|
* deliberately does not (and will not) expose an
|
||||||
* "sqlSafe()"-style method — no string transform makes arbitrary input safe
|
* "sqlSafe()"-style method — no string transform makes arbitrary input safe
|
||||||
* to concatenate into SQL, and a method implying otherwise would be actively
|
* to concatenate into SQL, and a method implying otherwise would be actively
|
||||||
* dangerous.
|
* dangerous.
|
||||||
*
|
*
|
||||||
* One documented exception: a field that needs an exact, unmodified value
|
* One documented exception: a field that needs an exact, unmodified value
|
||||||
* (e.g. a password about to be hashed) should read $_POST directly instead —
|
* (e.g. a password about to be hashed or verified) should read $_POST
|
||||||
* cleaning would silently strip characters like < and > before hashing,
|
* directly instead — cleaning would silently strip characters like < and >
|
||||||
* producing a hash that doesn't match what the user actually typed. See
|
* before hashing, producing a hash that doesn't match what the user
|
||||||
* novaconium/pages/admin/password-hash/index.php for the one place this
|
* actually typed (or failing a login whose password actually matches). See
|
||||||
* framework does that on purpose.
|
* the password fields in novaconium/pages/admin/users/index.php and
|
||||||
|
* novaconium/pages/admin/login/index.php for the places this framework
|
||||||
|
* does that on purpose.
|
||||||
*/
|
*/
|
||||||
final class Input
|
final class Input
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal RSS 2.0 envelope builder — plain string concatenation, no
|
||||||
|
* DOMDocument, same style as novaconium/pages/sitemap.xml/index.php.
|
||||||
|
* Generic on purpose (title/link/description/items in, XML string out) —
|
||||||
|
* it doesn't know about blog posts specifically; App/pages/blog/feed/ and
|
||||||
|
* App/pages/blog/tag/[tag]/feed/ are the two call sites that supply
|
||||||
|
* blog-shaped data to it.
|
||||||
|
*
|
||||||
|
* Links/guids are expected to be site-relative paths (e.g.
|
||||||
|
* "/blog/hello-world"), consistent with how this framework already
|
||||||
|
* handles canonical/og:url (see /admin/docs/seo) — there's no site-wide
|
||||||
|
* base-URL config to build absolute URLs from. Every <guid> is emitted
|
||||||
|
* with isPermaLink="false" for exactly this reason: it's a stable
|
||||||
|
* identifier, not a real absolute permalink.
|
||||||
|
*/
|
||||||
|
final class Rss
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, array{title: string, link: string, guid: string, pubDateTimestamp: int, description: string}> $items
|
||||||
|
*/
|
||||||
|
public static function render(string $channelTitle, string $channelLink, string $channelDescription, array $items): string
|
||||||
|
{
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
$xml .= '<rss version="2.0">' . "\n";
|
||||||
|
$xml .= ' <channel>' . "\n";
|
||||||
|
$xml .= ' <title>' . htmlspecialchars($channelTitle, ENT_XML1) . '</title>' . "\n";
|
||||||
|
$xml .= ' <link>' . htmlspecialchars($channelLink, ENT_XML1) . '</link>' . "\n";
|
||||||
|
$xml .= ' <description>' . htmlspecialchars($channelDescription, ENT_XML1) . '</description>' . "\n";
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$xml .= ' <item>' . "\n";
|
||||||
|
$xml .= ' <title>' . htmlspecialchars($item['title'], ENT_XML1) . '</title>' . "\n";
|
||||||
|
$xml .= ' <link>' . htmlspecialchars($item['link'], ENT_XML1) . '</link>' . "\n";
|
||||||
|
$xml .= ' <guid isPermaLink="false">' . htmlspecialchars($item['guid'], ENT_XML1) . '</guid>' . "\n";
|
||||||
|
$xml .= ' <pubDate>' . date(DATE_RSS, $item['pubDateTimestamp']) . '</pubDate>' . "\n";
|
||||||
|
$xml .= ' <description>' . htmlspecialchars($item['description'], ENT_XML1) . '</description>' . "\n";
|
||||||
|
$xml .= ' </item>' . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml .= ' </channel>' . "\n";
|
||||||
|
$xml .= '</rss>' . "\n";
|
||||||
|
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Lib;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thin wrapper around PHP's native session handling — session_start()
|
||||||
|
* etc., not a custom session store — so sidecars have a consistent
|
||||||
|
* get/set/flash API instead of touching $_SESSION directly. All-static,
|
||||||
|
* lazy-start like Lib\Csrf: nothing calls session_start() until the first
|
||||||
|
* real call to any method here, so a page that never touches Session (or
|
||||||
|
* Csrf, which starts a session the same way) never gets a session cookie.
|
||||||
|
*
|
||||||
|
* ensureSession()'s body is deliberately duplicated from Csrf::ensureSession()
|
||||||
|
* rather than extracted into a shared helper — keeps Csrf standalone with
|
||||||
|
* zero new dependencies rather than coupling it to a class that didn't
|
||||||
|
* exist when it shipped, consistent with this project's tolerance for
|
||||||
|
* small duplication over premature coupling (see 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 — session_status() guards against a double session_start().
|
||||||
|
*
|
||||||
|
* Flash data: a value set now via flash() is readable via getFlash() on
|
||||||
|
* exactly the next request, then gone — for post/redirect/GET flows like
|
||||||
|
* "message sent" banners, without a query-string flag. See
|
||||||
|
* /admin/docs/session for the mechanism and a worked example.
|
||||||
|
*/
|
||||||
|
final class Session
|
||||||
|
{
|
||||||
|
private const FLASH_KEY = '_flash';
|
||||||
|
|
||||||
|
private static bool $flashLoaded = false;
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private static array $currentFlash = [];
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
return $_SESSION[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function has(string $key): bool
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
return isset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function remove(string $key): void
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
unset($_SESSION[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps the session id for a fresh one, keeping the session's data.
|
||||||
|
* Call on any privilege change — after a successful login, and on
|
||||||
|
* logout — so a session id an attacker planted or observed before the
|
||||||
|
* change is worthless after it (session fixation). App\AdminAuth does
|
||||||
|
* exactly this.
|
||||||
|
*/
|
||||||
|
public static function regenerate(): void
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores $value so it's readable via getFlash($key) on the next
|
||||||
|
* request only, then gone — regardless of whether getFlash() was
|
||||||
|
* actually called on that next request.
|
||||||
|
*/
|
||||||
|
public static function flash(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
$_SESSION[self::FLASH_KEY][$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a value flashed on the previous request. Never reflects a
|
||||||
|
* value flashed during this same request — that value will be
|
||||||
|
* readable on the next request instead.
|
||||||
|
*/
|
||||||
|
public static function getFlash(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
self::ensureSession();
|
||||||
|
|
||||||
|
return self::$currentFlash[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureSession(): void
|
||||||
|
{
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
// Must be called before session_start() — after is a silent no-op.
|
||||||
|
session_set_cookie_params([
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs once per request, on whichever Session method is called
|
||||||
|
// first: snapshot last request's flash bucket for this request's
|
||||||
|
// getFlash() reads, then immediately reset the session's bucket so
|
||||||
|
// flash() calls made during this request go to a fresh bucket —
|
||||||
|
// the one the *next* request will snapshot. This single swap is
|
||||||
|
// the entire flash mechanism; no separate expiry/sweep step needed,
|
||||||
|
// since static properties don't persist across requests.
|
||||||
|
if (!self::$flashLoaded) {
|
||||||
|
self::$currentFlash = $_SESSION[self::FLASH_KEY] ?? [];
|
||||||
|
$_SESSION[self::FLASH_KEY] = [];
|
||||||
|
self::$flashLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS content_pages (
|
||||||
|
route TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
keywords TEXT NOT NULL DEFAULT '',
|
||||||
|
changefreq TEXT NOT NULL DEFAULT '',
|
||||||
|
priority TEXT NOT NULL DEFAULT '',
|
||||||
|
source_mtime INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_tags (
|
||||||
|
route TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (route, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_tags_tag ON content_tags(tag);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS content_search USING fts5(route UNINDEXED, title, body);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_index_meta (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
newest_source_mtime INTEGER NOT NULL,
|
||||||
|
source_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
indexed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'registered',
|
||||||
|
user_group TEXT NOT NULL DEFAULT '',
|
||||||
|
is_disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
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);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
{# Adds a hover-revealed copy button to every <pre><code> block on the
|
||||||
|
page, without touching any individual doc/blog page's markup. Reads
|
||||||
|
textContent (not innerHTML) when copying, so HTML-entity-escaped
|
||||||
|
samples (e.g. <h1> in the SEO starter template) come out as their
|
||||||
|
literal, unescaped characters rather than the escaped markup.
|
||||||
|
|
||||||
|
The icon markup is passed to JS via <template> elements (plain HTML
|
||||||
|
output, default autoescaping) rather than Twig's `|escape('js')`
|
||||||
|
filter — that filter calls Twig\Runtime\mb_ord() under the hood, which
|
||||||
|
hard-requires the mbstring extension and fatals
|
||||||
|
(`Call to undefined function Twig\Runtime\mb_ord()`) without it, the
|
||||||
|
same class of mbstring gotcha documented in AGENTS.md for `|slice` on
|
||||||
|
strings. #}
|
||||||
|
<template id="copy-code-icon-copy">{{ icons.copy() }}<span class="copy-code-label">Copy</span></template>
|
||||||
|
<template id="copy-code-icon-copied">{{ icons.check() }}<span class="copy-code-label">Copied!</span></template>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var copyIconHtml = document.getElementById('copy-code-icon-copy').innerHTML;
|
||||||
|
var checkIconHtml = document.getElementById('copy-code-icon-copied').innerHTML;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('pre').forEach(function (pre) {
|
||||||
|
if (!pre.querySelector('code')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'copy-code-button icon-link';
|
||||||
|
button.setAttribute('aria-label', 'Copy code to clipboard');
|
||||||
|
button.innerHTML = copyIconHtml + '<span class="copy-code-label">Copy</span>';
|
||||||
|
pre.appendChild(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var button = event.target.closest('.copy-code-button');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = button.closest('pre').querySelector('code');
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(function () {
|
||||||
|
var originalHtml = button.innerHTML;
|
||||||
|
|
||||||
|
button.innerHTML = checkIconHtml + '<span class="copy-code-label">Copied!</span>';
|
||||||
|
button.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
button.innerHTML = originalHtml;
|
||||||
|
button.classList.remove('copied');
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -6,9 +6,9 @@
|
|||||||
surrounding link/text color, including on :hover, with no extra CSS.
|
surrounding link/text color, including on :hover, with no extra CSS.
|
||||||
|
|
||||||
Available: home, git, book, link, sitemap, email, search, rss, tag,
|
Available: home, git, book, link, sitemap, email, search, rss, tag,
|
||||||
lock, trash, external_link, menu, back_to_top, sun, moon. Some
|
lock, trash, external_link, menu, back_to_top, sun, moon, copy, check.
|
||||||
(search, rss, tag) are ahead of the features that will use them (see
|
Some (search, rss, tag) are ahead of the features that will use them
|
||||||
novaconium/ISSUES.md) — added now so those features don't need an
|
(see novaconium/ISSUES.md) — added now so those features don't need an
|
||||||
icons.twig change later. #}
|
icons.twig change later. #}
|
||||||
|
|
||||||
{% macro home(class) %}
|
{% macro home(class) %}
|
||||||
@@ -92,6 +92,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro users(class) %}
|
||||||
|
<svg class="icon icon-users {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro trash(class) %}
|
{% macro trash(class) %}
|
||||||
<svg class="icon icon-trash {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
<svg class="icon icon-trash {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
<path d="M4 7h16" />
|
<path d="M4 7h16" />
|
||||||
@@ -144,3 +153,16 @@
|
|||||||
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro copy(class) %}
|
||||||
|
<svg class="icon icon-copy {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
<rect x="9" y="9" width="11" height="11" rx="1.5" />
|
||||||
|
<path d="M5.5 15H4.5A1.5 1.5 0 0 1 3 13.5v-9A1.5 1.5 0 0 1 4.5 3h9A1.5 1.5 0 0 1 15 4.5v1" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro check(class) %}
|
||||||
|
<svg class="icon icon-check {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M4.5 12.5 9.5 17.5 19.5 6.5" />
|
||||||
|
</svg>
|
||||||
|
{% endmacro %}
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
{% include '_layout/theme-init.twig' %}
|
{% include '_layout/theme-init.twig' %}
|
||||||
|
{% include '_layout/syntax-highlight-init.twig' %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}{{ site_name }}{% endblock %}</title>
|
<title>{% block title %}{{ site_name }}{% endblock %}</title>
|
||||||
<meta name="description" content="{% block description %}A tiny, Hugo-flavored PHP micro-framework site.{% endblock %}">
|
<meta name="description" content="{% block description %}A tiny, Hugo-flavored PHP micro-framework site.{% endblock %}">
|
||||||
<meta name="robots" content="{% block robots %}index, follow{% endblock %}">
|
<meta name="robots" content="{% block robots %}index, follow{% 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
|
||||||
|
visible on the page. A block tag always emits its content wherever
|
||||||
|
it's declared, though — and a Twig comment tag can't wrap a block
|
||||||
|
tag (Twig comments are stripped before parsing, so a nested block
|
||||||
|
tag inside one would never compile; don't put literal Twig
|
||||||
|
delimiter syntax inside a Twig comment's text either, for the same
|
||||||
|
reason — it terminates the comment early). So these three are
|
||||||
|
wrapped in a real HTML comment instead: invisible to a
|
||||||
|
reader/browser, but still genuine Twig blocks, overridable per-page
|
||||||
|
and harvestable by ContentIndexer via Twig's renderBlock() API
|
||||||
|
exactly like every SEO block above. See /admin/docs/content-index. #}
|
||||||
|
<!--
|
||||||
|
{% block tags %}{% endblock %}
|
||||||
|
{% block changefreq %}monthly{% endblock %}
|
||||||
|
{% block priority %}0.5{% endblock %}
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
{# Open Graph / Facebook #}
|
{# Open Graph / Facebook #}
|
||||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||||
@@ -25,6 +47,15 @@
|
|||||||
<link rel="stylesheet" href="/css/main.css">
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
|
||||||
{% include '_layout/matomo.twig' %}
|
{% include '_layout/matomo.twig' %}
|
||||||
|
|
||||||
|
{# Open-ended extension point for anything a subtree's own layout
|
||||||
|
needs in <head> that doesn't fit an existing named block — e.g.
|
||||||
|
App/pages/blog/_layout/layout.twig overrides this with a
|
||||||
|
<link rel="alternate" type="application/rss+xml"> for feed
|
||||||
|
auto-discovery, scoped to /blog/* only since only that layout
|
||||||
|
overrides it. Empty by default, so nothing changes for a page that
|
||||||
|
doesn't need it. #}
|
||||||
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -35,6 +66,12 @@
|
|||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
<small>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
<small>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
||||||
|
<nav class="footer-menu">
|
||||||
|
{% if content_index_enabled %}<a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}Sitemap</a>{% endif %}
|
||||||
|
<a class="icon-link" href="/blog/feed">{{ icons.rss() }}RSS Feed</a>
|
||||||
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% include '_layout/code-copy.twig' %}
|
||||||
|
{% include '_layout/syntax-highlight.twig' %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{# Creates the highlight.js theme <link> with the correct href before
|
||||||
|
paint, so switching to light doesn't flash the dark (ir-black) code
|
||||||
|
theme first — same FOUC-avoidance trick theme-init.twig uses for the
|
||||||
|
main palette. Must run after theme-init.twig (data-theme needs to
|
||||||
|
already be set on <html>) and before the stylesheet link — see
|
||||||
|
novaconium/pages/_layout/layout.twig. The live swap (when the toggle
|
||||||
|
button is clicked after page load) is handled separately by
|
||||||
|
novaconium/pages/_layout/syntax-highlight.twig's MutationObserver. #}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||||
|
var link = document.createElement('link');
|
||||||
|
link.id = 'hljs-theme';
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = isLight ? '/vendor/highlightjs/styles/github.min.css' : '/vendor/highlightjs/styles/ir-black.min.css';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{# Colors <pre><code> blocks site-wide via vendored highlight.js
|
||||||
|
(public/vendor/highlightjs/ — see /admin/docs/upgrading-highlightjs),
|
||||||
|
auto-detected and restricted to only the languages this site actually
|
||||||
|
uses (hljs.configure below) so it doesn't waste cycles or misfire
|
||||||
|
trying to match ~40 bundled languages against a handful of short
|
||||||
|
snippets. php/bash/css/python/javascript/xml ship in the core
|
||||||
|
highlight.min.js bundle; yaml/json/ini don't (checked, not assumed —
|
||||||
|
see /admin/docs/upgrading-highlightjs) and are vendored as separate
|
||||||
|
per-language files under languages/, loaded after the core bundle so
|
||||||
|
their hljs.registerLanguage(...) self-registration calls have a global
|
||||||
|
hljs to register against. Twig-syntax code blocks have no highlight.js
|
||||||
|
grammar and are NOT auto-detected against — forcing one through the
|
||||||
|
restricted candidate set above would still force-match it to whichever
|
||||||
|
configured language scores highest, coloring it *wrong* rather than
|
||||||
|
leaving it plain. Those blocks are marked class="nohighlight" by hand
|
||||||
|
at the source (a real highlight.js convention meaning "skip this block
|
||||||
|
entirely") — see AGENTS.md for which files have them and why.
|
||||||
|
|
||||||
|
The copy-to-clipboard button (code-copy.twig) needs no changes for
|
||||||
|
this: it already reads code.textContent, not innerHTML, which stays
|
||||||
|
the original plain text regardless of the <span> wrapping
|
||||||
|
highlightAll() adds.
|
||||||
|
|
||||||
|
hljs.highlightAll() does NOT defer itself if called while the document
|
||||||
|
is still parsing (document.readyState === "loading") — it just silently
|
||||||
|
no-ops, permanently, rather than waiting and retrying. Confirmed this
|
||||||
|
with a real DOM test, not assumed: calling it immediately (unwrapped)
|
||||||
|
produced zero highlighted blocks even though this script tag sits near
|
||||||
|
the end of <body>, since the document can still be mid-parse at that
|
||||||
|
exact point. So this is wrapped in the same DOMContentLoaded pattern
|
||||||
|
code-copy.twig already uses for its own button injection, rather than
|
||||||
|
called directly. #}
|
||||||
|
<script src="/vendor/highlightjs/highlight.min.js"></script>
|
||||||
|
<script src="/vendor/highlightjs/languages/yaml.min.js"></script>
|
||||||
|
<script src="/vendor/highlightjs/languages/json.min.js"></script>
|
||||||
|
<script src="/vendor/highlightjs/languages/ini.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
hljs.configure({ languages: ['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json', 'ini'] });
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
hljs.highlightAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
var themeLink = document.getElementById('hljs-theme');
|
||||||
|
|
||||||
|
new MutationObserver(function () {
|
||||||
|
var isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
||||||
|
themeLink.href = isLight ? '/vendor/highlightjs/styles/github.min.css' : '/vendor/highlightjs/styles/ir-black.min.css';
|
||||||
|
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -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,12 +8,22 @@
|
|||||||
<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>
|
||||||
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a></li>
|
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a></li>
|
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</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/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/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>
|
||||||
@@ -23,6 +33,7 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a></li>
|
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a></li>
|
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a></li>
|
||||||
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a></li>
|
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a></li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="docs-content">
|
<div class="docs-content">
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Access control{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Assign a page or section to a user or group from its sidecar with Lib\Access — public by default, static pages always public.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Access control</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Access</code> (<code>novaconium/lib/Access.php</code>) assigns a page to a user, a group, or just "anyone logged in" — from the page's own sidecar, using the accounts <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> manages at <a href="/admin/users">/admin/users</a>. One call at the top of <code>index.php</code>:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
|
||||||
|
use Lib\Access;
|
||||||
|
|
||||||
|
if ($denied = Access::require('group:members')) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ...normal sidecar context...
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p><code>Access::require()</code> returns <code>null</code> when the request may proceed, or a <code>Response</code> the sidecar returns as-is: nobody logged in → a <code>303</code> to <a href="/admin/login">/admin/login</a> carrying a <code>?return=</code> path so a successful login lands right back on the page they wanted; logged in but not allowed → a plain <code>404</code>, the same hide-don't-tease posture as <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft pages</a>.</p>
|
||||||
|
|
||||||
|
<h2>Rules</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>Access::require()</code> — no rules: any logged-in user (admin or registered).</li>
|
||||||
|
<li><code>Access::require('group:members')</code> — users whose group (assigned at <a href="/admin/users">/admin/users</a>) is <code>members</code>. Each user has at most one group; a group is just a text label, matched exactly — there's no groups table to manage.</li>
|
||||||
|
<li><code>Access::require('user:bob')</code> — exactly that account.</li>
|
||||||
|
<li><code>Access::require('group:members', 'user:bob')</code> — several rules mean <em>any</em> of them grants access.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>Admins always pass every rule</strong> — the admin role exists to run the site, so there's no way to write a rule that locks an admin out of content.</p>
|
||||||
|
|
||||||
|
<h2>Public is the default — and static pages are always public</h2>
|
||||||
|
|
||||||
|
<p>A sidecar that never calls <code>Access::require()</code> is completely untouched by all of this, and a page with no sidecar at all <em>can't</em> call it — so sidecar-less pages are always public. That's load-bearing rather than incidental: only sidecar-less pages are ever written to the <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}static HTML cache</a>, which Apache serves before PHP (and therefore any access check) runs. Because a gated page necessarily has a sidecar, it's never statically cached, so there's no way to leak a gated page through the cache — the caching/auth rule that drafts and <code>/admin/*</code> need explicit cache exclusions for is satisfied here by construction.</p>
|
||||||
|
|
||||||
|
<h2>Gating a section</h2>
|
||||||
|
|
||||||
|
<p>There's no per-directory config for this — a "section" is gated by giving each page in it a sidecar with the same check, which stays visible and greppable at the page level. To keep the rule itself in one place, put a <code>_access.php</code> file in the section directory (any file that isn't <code>index.php</code>/<code>index.twig</code> is invisible to the router) and <code>require</code> it from each sidecar — a PHP file can return a value, so it composes exactly like a direct call:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/pages/members/_access.php — the section's one shared rule
|
||||||
|
use Lib\Access;
|
||||||
|
|
||||||
|
return Access::require('group:members');</code></pre>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/pages/members/anything/index.php — each page in the section
|
||||||
|
if ($denied = require dirname(__DIR__) . '/_access.php') {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];</code></pre>
|
||||||
|
|
||||||
|
<h2>Interactions worth knowing</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Off means open.</strong> With <code>admin_auth_enabled</code> off (or while no users exist yet), <code>Access::require()</code> allows everything — there'd be nothing to log in as. Same open-until-configured posture as the rest of admin auth, and the same zero-footprint guarantee: it never touches <code>Lib\Db</code> in that state.</li>
|
||||||
|
<li><strong>Gated pages stay out of <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}search and the sitemap</a> automatically.</strong> The content-index crawl runs every sidecar as an anonymous GET, so a gated sidecar short-circuits to the login redirect and the crawler skips the page — nothing to configure, verified for real. <code>require()</code> also has no side effects on deny (the return path travels in the redirect URL, not the session), so a crawl can't scribble on the visiting user's session.</li>
|
||||||
|
<li><strong>Who's logged in?</strong> A sidecar that wants to greet the user (or vary content by account) can read <code>App\AdminAuth::currentUser()</code> — <code>id</code>/<code>username</code>/<code>email</code>/<code>role</code>/<code>user_group</code>, or <code>null</code> — and pass what it needs into its template context.</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,47 +4,80 @@
|
|||||||
|
|
||||||
{% block title %}Admin authentication{% endblock %}
|
{% block title %}Admin authentication{% endblock %}
|
||||||
|
|
||||||
{% block description %}Gating /admin/* behind HTTP Basic Auth, reusable for any future admin page.{% endblock %}
|
{% block description %}Session-based login with admin/registered roles, groups, and user management, gating /admin/* and draft pages.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}noindex, nofollow{% endblock %}
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>Admin authentication</h1>
|
<h1>Admin authentication</h1>
|
||||||
|
|
||||||
<p>Every route under <code>/admin/*</code> — <code>/admin</code>, <code>/admin/clear-cache</code>, the docs section, and any admin page a project adds later — can be gated behind HTTP Basic Auth with two <code>App/config.php</code> keys. It's off by default (same posture as Matomo): an empty <code>admin_password_hash</code> means no gate at all, matching this project's original wide-open behavior.</p>
|
<p>Every route under <code>/admin/*</code> — <code>/admin</code>, <code>/admin/clear-cache</code>, the docs section, and any admin page a project adds later — can be gated behind a session-based login against a <code>users</code> table in the <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}database</a>'s <code>default</code> connection. It's off by default (same posture as the <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}content index</a>, and for the same reason: it depends on SQLite, a real dependency plenty of sites won't want): with <code>admin_auth_enabled</code> left <code>false</code>, <code>/admin/*</code> is wide open, <code>/admin/login</code>, <code>/admin/logout</code>, and <code>/admin/users</code> all <code>404</code> as if they didn't exist, and nothing ever touches <code>Lib\Db</code> because of this feature — no <code>data/novaconium.sqlite</code> gets created just because the code exists.</p>
|
||||||
|
|
||||||
<p>This replaced the old <code>docs_enabled</code> config flag, which only hid the docs section specifically. Since this gate covers all of <code>/admin/*</code> — including docs — there's no need for a separate docs-only toggle anymore; set a password and the whole admin area (docs included) requires login.</p>
|
<p>This replaced the original single-user HTTP Basic Auth stopgap (an <code>admin_username</code>/<code>admin_password_hash</code> config pair — both keys, and the <code>/admin/password-hash</code> hash-generator page, are gone). Same call sites in <code>bootstrap.php</code>, new mechanism: multiple accounts, real server-side login state (riding on <a class="icon-link" href="/admin/docs/session">{{ icons.book() }}<code>Lib\Session</code></a>), and user management in the browser.</p>
|
||||||
|
|
||||||
|
<h2>Two roles: admin and registered</h2>
|
||||||
|
|
||||||
|
<p>The <strong>first user ever created is the admin</strong>; every user created after that is <strong>registered</strong>. An admin runs the site: full <code>/admin/*</code> access, sees <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft pages</a>, and passes every <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> rule. A registered user can log in at the same <a href="/admin/login">/admin/login</a> and sees whatever content <code>Lib\Access</code> assigns to their account or their group — but <code>/admin/*</code> returns a plain <code>404</code> for them, not a login prompt (they <em>are</em> logged in; what they lack is the role). Each registered user can be assigned at most one <strong>group</strong> — a plain text label (e.g. <code>members</code>) that <code>Access::require('group:members')</code> matches exactly; there's no groups table to manage.</p>
|
||||||
|
|
||||||
<h2>Enabling it</h2>
|
<h2>Enabling it</h2>
|
||||||
|
|
||||||
<p>Generate a password hash once — either on the command line:</p>
|
<p>Turn it on in <code>App/config.php</code>:</p>
|
||||||
|
|
||||||
<pre><code>php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT), PHP_EOL;"</code></pre>
|
|
||||||
|
|
||||||
<p>...or, if you'd rather not touch a terminal, at <a class="icon-link" href="/admin/password-hash">{{ icons.lock() }}/admin/password-hash</a> — a small built-in form that does the same <code>password_hash()</code> call and hands back a ready-to-paste config snippet. Nothing typed there is stored or logged. Since it lives under <code>/admin/*</code> like everything else here, it's automatically covered by this same gate once a password is set — reachable while <code>admin_password_hash</code> is still empty (so you can generate your first one), then protected like any other admin page afterward.</p>
|
|
||||||
|
|
||||||
<p>Then set both keys in <code>App/config.php</code>:</p>
|
|
||||||
|
|
||||||
<pre><code><?php
|
<pre><code><?php
|
||||||
// App/config.php
|
// App/config.php
|
||||||
return [
|
return [
|
||||||
'admin_username' => 'admin',
|
'admin_auth_enabled' => true,
|
||||||
'admin_password_hash' => '$2y$10$...',
|
|
||||||
];</code></pre>
|
];</code></pre>
|
||||||
|
|
||||||
<p>Every request into <code>/admin/*</code> now requires that username/password via the browser's built-in Basic Auth prompt; anything outside <code>/admin</code> is unaffected.</p>
|
<p>Since this is the first SQLite-backed feature most sites turn on, make sure PHP has the <code>pdo_sqlite</code> extension enabled first (<code>php -m | grep -i sqlite</code>) — it's bundled with PHP but not always enabled; Debian/Ubuntu package it as <code>php-sqlite3</code>. Without it, the first request into <code>/admin/*</code> fails naming the missing extension. See <a href="/admin/docs">Overview</a>'s requirements list.</p>
|
||||||
|
|
||||||
|
<p>Enabling the flag alone protects nothing yet — <strong>while zero users exist, the whole admin area stays open</strong>, precisely so the first user (the admin) can be created. Do that either in the browser at <a href="/admin/users">/admin/users</a> (you're logged in as the first user automatically the moment it's created, and the gate closes), or from the command line:</p>
|
||||||
|
|
||||||
|
<pre><code>php novaconium/bin/create-admin-user.php admin admin@example.com</code></pre>
|
||||||
|
|
||||||
|
<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), 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>
|
||||||
|
|
||||||
|
<p><a href="/admin/users">/admin/users</a> lists every account and handles the rest: create a user (registered, with an email address and an optional group — only the very first is the admin), disable/enable one, assign or change a group, promote/demote between admin and registered, change an email address, change a password, and delete an account outright. Behaviors worth knowing:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Disabling is immediate.</strong> A disabled user can't log in, and any session they already had is re-checked against the table on its next request — there's no "still logged in until the session expires" window. The same immediate re-check applies to a role or group change.</li>
|
||||||
|
<li><strong>The last active admin can't be disabled, demoted, or deleted.</strong> Any of the three would lock everyone out of <code>/admin/*</code> permanently (the zero-users setup window doesn't reopen — the table isn't empty), leaving only the CLI or hand-editing the database as recovery. The form refuses instead. Registered users carry no such guard.</li>
|
||||||
|
<li><strong>Delete is a hard delete</strong> — the row is gone, the username and email become reusable, and any live session dies on its next request, same as disabling. Disable is the right tool for "shut this account out but keep it"; delete is for accounts that shouldn't exist at all.</li>
|
||||||
|
<li><strong>More admins are allowed</strong> — "first user is the admin" is the default, not a cap; promote a registered user any time.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>How it's wired</h2>
|
<h2>How it's wired</h2>
|
||||||
|
|
||||||
<p><code>novaconium/src/AdminAuth.php</code> is a single reusable check — <code>AdminAuth::requireLogin($username, $passwordHash)</code> — called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no login prompt on an unrelated 404). Because the check lives in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists.</p>
|
<p><code>novaconium/src/AdminAuth.php</code> is a pair of reusable checks called once from <code>novaconium/bootstrap.php</code> for any resolved route whose path is <code>admin</code> or starts with <code>admin/</code>, and only for routes that actually resolved (no redirect on an unrelated 404): <code>AdminAuth::requireLogin($enabled)</code> redirects anyone not logged in to the login form, then <code>AdminAuth::isAdmin($enabled)</code> 404s anyone who <em>is</em> logged in but isn't an admin — a registered user is authenticated, so bouncing them back to the login form would be a lie. Because the checks live in <code>bootstrap.php</code> rather than on each page, <strong>a new admin page needs zero extra wiring</strong> to be protected — dropping a new directory under <code>App/pages/admin/</code> or <code>novaconium/pages/admin/</code> is automatically gated the moment it exists. The one exemption is <code>admin/login</code> itself, which has to stay reachable logged-out or the redirect to it would loop.</p>
|
||||||
|
|
||||||
|
<p>The login form is a normal page with a normal form: CSRF-protected like every other form here (see <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a>), reading the username via <code>Lib\Input</code> and the password straight from <code>$_POST</code> (the documented exact-value exception — cleaning would strip characters like <code><</code>/<code>></code> and fail a login whose password actually matches). A successful login regenerates the session id (<code>Session::regenerate()</code>) before storing the user id, so a session id planted or observed pre-login never becomes an authenticated one — then lands on <code>?return=</code> (how <code>Lib\Access</code> sends someone back to the gated page they wanted; validated to be a local path, never another site), or, with no return path, on <code>/admin</code> for admins and the homepage for registered users.</p>
|
||||||
|
|
||||||
|
<p><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> reuse <code>isAdmin()</code> directly with a different failure response (a plain <code>404</code> for <em>everyone</em> unauthorized, never a login redirect), rather than duplicating the logic. The session cookie is scoped to the whole origin, so logging in once at <code>/admin/login</code> covers draft URLs and <code>Lib\Access</code>-gated pages too.</p>
|
||||||
|
|
||||||
|
<p>Templates can read the <code>admin_auth_enabled</code> Twig global (it mirrors the config flag) — <code>admin/index.twig</code> uses it to show the "Admin users" and "Logout" links only when the feature is on. It's a display flag only; enforcement never depends on Twig.</p>
|
||||||
|
|
||||||
<h2>Logging out</h2>
|
<h2>Logging out</h2>
|
||||||
|
|
||||||
<p>HTTP Basic Auth has no real server-side logout — the browser just keeps resending the same cached credentials on every request to that realm. Visiting <code>/admin/logout</code> works around this: <code>AdminAuth::logout()</code> always issues a fresh <code>401</code> challenge, regardless of what credentials were sent, which makes the browser discard what it had cached and prompt again the next time <code>/admin</code> is visited. Credentials themselves aren't invalidated server-side (there's nothing to invalidate — it's just a password check on every request), so this is a client-side-only logout, same as any Basic Auth site. The "Logout" link only appears on <code>/admin</code> when <code>admin_auth_enabled</code> is true (i.e. a password is actually set).</p>
|
<p><a href="/admin/logout">/admin/logout</a> is a real server-side logout now (its Basic Auth predecessor could only trick the browser into forgetting cached credentials with a fresh <code>401</code>): a POST — with a small GET confirm form, same shape as <code>/admin/clear-cache</code> — that drops the logged-in user id from the session, regenerates the session id, and redirects to the login form. It's deliberately not logout-on-GET: sidecars must stay side-effect-free on GET, both as ordinary HTTP hygiene and because the <a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}content index</a>'s crawl invokes every page's sidecar the way a real GET would — a logout-on-GET would end the crawling admin's own session the moment a lazy reindex rendered this page.</p>
|
||||||
|
|
||||||
<h2>What this is (and isn't)</h2>
|
<h2>What this is (and isn't)</h2>
|
||||||
|
|
||||||
<p>This is HTTP Basic Auth against a single username/password pair in config — no sessions, no user table, no password reset, no multiple accounts. It's a deliberate stopgap: see <code>novaconium/ISSUES.md</code>'s "Admin login & user management" entry for the planned real multi-user system (backed by SQLite, with proper sessions). That feature will <em>replace</em> this mechanism, not layer on top of it. Until then, this is enough to keep the general public out of <code>/admin</code> on a production site.</p>
|
<p>This is authentication plus a deliberately small authorization model: two roles and one group label per user, matched by <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> rules in sidecars — no permissions matrix, no role hierarchy, no per-admin capability flags. Registration is admin-driven only; there's no self-serve sign-up form. Sessions are PHP's native ones via <code>Lib\Session</code>, with the same cookie hardening it always applies (<code>httponly</code>, <code>SameSite=Lax</code>, <code>secure</code> on HTTPS). As with any password form, serve <code>/admin</code> over HTTPS in production.</p>
|
||||||
|
|
||||||
<p>Basic Auth credentials are sent base64-encoded on every request (not encrypted) — always serve <code>/admin</code> over HTTPS in production, same as any password-protected page.</p>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{% extends 'admin/docs/_layout/layout.twig' %}
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
{% block title %}Static caching{% endblock %}
|
{% block title %}Static caching{% endblock %}
|
||||||
|
|
||||||
{% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %}
|
{% block description %}How sidecar-less pages are pre-rendered and served as static HTML.{% endblock %}
|
||||||
@@ -11,11 +13,13 @@
|
|||||||
|
|
||||||
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
|
<p>If a page has <strong>no</strong> sidecar, its rendered HTML is written to <code>public/cache/<path>/index.html</code> after the first request. <code>.htaccess</code> checks for that file before PHP ever runs, so repeat visits are served straight by Apache with zero PHP/Twig overhead. Pages with a sidecar are never cached this way, since their output can vary per request.</p>
|
||||||
|
|
||||||
|
<p>Two kinds of route are excluded from the cache unconditionally, regardless of whether they have a sidecar: every <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}draft page</a> and every <code>/admin/*</code> route. Both are gated by the <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin login</a>, and a cached copy would bypass that check entirely — <code>.htaccess</code> serves a cached file before PHP (and therefore any auth check) ever runs, so a cached admin or draft page would be served to anyone, unauthenticated, forever after the first authenticated view. See <a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> for the full write-up.</p>
|
||||||
|
|
||||||
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
|
<p>To force a single page to re-render, delete its file under <code>public/cache/</code>. To clear everything at once, there are two equivalent options:</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>CLI:</strong> <code>php novaconium/bin/clear-cache.php</code> — a standalone script for deploys, cron jobs, or anywhere you'd rather not go through a browser. Prints <code>Cache cleared.</code> and exits.</li>
|
<li><strong>CLI:</strong> <code>php novaconium/bin/clear-cache.php</code> — a standalone script for deploys, cron jobs, or anywhere you'd rather not go through a browser. Prints <code>Cache cleared.</code> and exits.</li>
|
||||||
<li><strong>Web:</strong> <a href="/admin/clear-cache">/admin/clear-cache</a> — a POST form under <code>/admin</code>, covered by the same <a href="/admin/docs/admin-auth">HTTP Basic Auth gate</a> as the rest of <code>/admin/*</code> once a password is set.</li>
|
<li><strong>Web:</strong> <a href="/admin/clear-cache">/admin/clear-cache</a> — a POST form under <code>/admin</code>, covered by the same <a href="/admin/docs/admin-auth">admin login gate</a> as the rest of <code>/admin/*</code> once admin auth is enabled.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>Both end up calling the same underlying <code>Cache::clear()</code> — see <code>/admin/docs/config</code>'s "For developers: using <code>Cache.php</code> directly" section for how each entry point constructs it. Clearing the cache only deletes the generated static HTML; it doesn't affect <code>App/pages/</code> or any other source. Any project change that should show up on an already-cached page — a new <code>site_name</code>, a new Sass color, a new admin toggle — needs a cache clear before it's visible, since the old <code>index.html</code> would otherwise keep being served as-is.</p>
|
<p>Both end up calling the same underlying <code>Cache::clear()</code> — see <code>/admin/docs/config</code>'s "For developers: using <code>Cache.php</code> directly" section for how each entry point constructs it. Clearing the cache only deletes the generated static HTML; it doesn't affect <code>App/pages/</code> or any other source. Any project change that should show up on an already-cached page — a new <code>site_name</code>, a new Sass color, a new admin toggle — needs a cache clear before it's visible, since the old <code>index.html</code> would otherwise keep being served as-is.</p>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>Configuration</h1>
|
<h1>Configuration</h1>
|
||||||
|
|
||||||
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_username</code>, <code>admin_password_hash</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
|
<p><code>novaconium/config.php</code> holds the framework defaults — <code>pages_dirs</code>, <code>cache_dir</code>, <code>debug</code>, <code>site_name</code>, <code>matomo_url</code>, <code>matomo_site_id</code>, <code>admin_auth_enabled</code>, <code>db_connections</code>, <code>draft_routes</code> — and is not meant to be edited per-project, same as everything else under <code>novaconium/</code>.</p>
|
||||||
|
|
||||||
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
|
<p><code>App/config.php</code> ships with the skeleton as an empty, commented placeholder — uncomment (or add) whichever keys you want to change, returning an array of just those:</p>
|
||||||
|
|
||||||
@@ -37,7 +37,11 @@ return [
|
|||||||
|
|
||||||
<h2>Admin authentication</h2>
|
<h2>Admin authentication</h2>
|
||||||
|
|
||||||
<p><code>admin_username</code> / <code>admin_password_hash</code> gate every <code>/admin/*</code> route behind HTTP Basic Auth — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up. Both are set via <code>App/config.php</code>; leaving <code>admin_password_hash</code> empty (the default) disables the gate.</p>
|
<p><code>admin_auth_enabled</code> (default <code>false</code>) gates every <code>/admin/*</code> route behind a session login against the <code>users</code> table — see <a href="/admin/docs/admin-auth">Admin authentication</a> for the full write-up, including roles (the first user is the admin; the rest are registered) and how the first user gets created. The same flag powers <a href="/admin/docs/access-control">Access control</a> (<code>Lib\Access</code>) for member/group-gated content. When left off, <code>/admin/*</code> is open, the login/users routes <code>404</code>, and <code>Access::require()</code> allows everything.</p>
|
||||||
|
|
||||||
|
<h2>Draft pages</h2>
|
||||||
|
|
||||||
|
<p><code>draft_routes</code> (default <code>[]</code>) is a list of routes only an authenticated admin can see — everyone else gets a plain <code>404</code>. Requires <code>admin_auth_enabled</code> above (and at least one user) to actually gate anything. See <a href="/admin/docs/drafts">Draft pages</a> for the full write-up, including why a cached draft page would be a security problem and how that's avoided.</p>
|
||||||
|
|
||||||
<h2>For developers: using <code>Cache.php</code> directly</h2>
|
<h2>For developers: using <code>Cache.php</code> directly</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Content index{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}The shared crawler behind /sitemap.xml, /search, and blog tag browsing.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Content index</h1>
|
||||||
|
|
||||||
|
<p>Three features — <a class="icon-link" href="/sitemap.xml">{{ icons.sitemap() }}/sitemap.xml</a>, <a class="icon-link" href="/search">{{ icons.search() }}/search</a>, and blog <a class="icon-link" href="/admin/docs/seo">{{ icons.tag() }}tag browsing</a> — share one underlying mechanism rather than three separate ones: a crawler (<code>App\ContentIndexer</code>, <code>novaconium/src/ContentIndexer.php</code>) that renders every routable page, pulls its metadata, and stores it in SQLite.</p>
|
||||||
|
|
||||||
|
<p>Content itself stays in files — plain Twig pages, same as everywhere else in this framework. Nothing here moves a page's body into a database; only metadata is extracted and indexed.</p>
|
||||||
|
|
||||||
|
<h2>Off by default</h2>
|
||||||
|
|
||||||
|
<p>All three features depend on <a class="icon-link" href="/admin/docs/database">{{ icons.book() }}SQLite</a> — a real dependency plenty of sites built on this framework won't want at all, the same reasoning that keeps Matomo and admin authentication off until a project opts in. <code>content_index_enabled</code> (default <code>false</code>) gates the whole subsystem:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/config.php
|
||||||
|
return [
|
||||||
|
'content_index_enabled' => true,
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p>When it's off, <code>/sitemap.xml</code>, <code>/search</code>, and every <code>/blog/tag/<tag></code> route return a plain <code>404</code> — exactly as if they didn't exist — and nothing ever touches <code>Lib\Db</code> because of this feature. <code>data/novaconium.sqlite</code> isn't created just because the code is present in the codebase; each consumer checks the flag before constructing anything that would open a connection.</p>
|
||||||
|
|
||||||
|
<h2>Declaring metadata on a page</h2>
|
||||||
|
|
||||||
|
<p>Four Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> next to the rest of the <a href="/admin/docs/seo">SEO blocks</a> — same override mechanism, just harvested rather than always rendered:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Block</th><th>Default</th><th>Used for</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>keywords</code></td><td>empty</td><td>rendered as <code><meta name="keywords"></code> on every page (see <a href="/admin/docs/seo">SEO</a>)</td></tr>
|
||||||
|
<tr><td><code>tags</code></td><td>empty</td><td>comma-separated; indexed into <code>content_tags</code> for tag browsing</td></tr>
|
||||||
|
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||||
|
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight">{% verbatim %}{% block tags %}yellow, the best, summer, hot{% endblock %}
|
||||||
|
{% block changefreq %}weekly{% endblock %}
|
||||||
|
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>See any post under <code>App/pages/blog/</code> for a working <code>tags</code> example.</p>
|
||||||
|
|
||||||
|
<h2>How the crawl works</h2>
|
||||||
|
|
||||||
|
<p><code>ContentIndexer::reindex()</code> walks every page under both page roots (<code>Overlay::listPageDirs()</code>, skipping <code>_</code>-prefixed, <code>404</code>, and <code>[param]</code>-wildcard directories — a wildcard route's concrete values aren't knowable without a data source, so dynamic routes aren't crawled yet), renders each one via <code>Renderer::renderForIndex()</code> (the same sidecar-then-Twig path a real request takes, minus HTTP output and the static cache write), and pulls each metadata block via Twig's own <code>renderBlock()</code> API — not by parsing <code>.twig</code> source — so App-over-novaconium overrides and layout inheritance resolve exactly the way they do for a real visit.</p>
|
||||||
|
|
||||||
|
<p>A page is skipped entirely (not stored) if it's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a>, or if its resolved <code>robots</code> block contains <code>noindex</code> — the same convention <a href="/admin/docs/seo">SEO</a> already documents for admin/internal pages. The rendered HTML is stripped with <code>strip_tags()</code> for a plain-text copy stored in a SQLite <a href="https://sqlite.org/fts5.html">FTS5</a> virtual table for search.</p>
|
||||||
|
|
||||||
|
<p>Every reindex is a full rebuild, not incremental — all three tables are truncated and repopulated inside one transaction, simple and correct at this scale rather than trying to diff what changed. Because the crawl renders every page's sidecar as a plain <code>GET</code> (forcing <code>$_SERVER['REQUEST_METHOD']</code> to <code>'GET'</code> for the duration, regardless of what triggered the reindex, and restoring it afterward), a POST-guarded sidecar action is never accidentally triggered by indexing — the same HTTP-safe-method hygiene any GET handler is already expected to have.</p>
|
||||||
|
|
||||||
|
<h2>When it runs</h2>
|
||||||
|
|
||||||
|
<p>Two paths, both calling the same <code>reindex()</code>:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Lazy (default):</strong> <code>ContentIndexer::ensureFresh()</code>, called from the <code>/sitemap.xml</code>, <code>/search</code>, and tag-browsing sidecars themselves — never from a normal page view, so browsing the rest of the site never pays any indexing cost. It compares the newest source-file mtime across every page (a cheap stat pass, no rendering) against the last indexed time, and only reindexes if something actually changed. Set <code>content_index_auto</code> to <code>false</code> to disable this and rely only on the CLI below.</li>
|
||||||
|
<li><strong>Explicit:</strong> <code>php novaconium/bin/index-content.php</code> — same shape as <code>bin/migrate.php</code>, for a deploy step. Always reindexes (ignores <code>content_index_auto</code>); exits immediately if <code>content_index_enabled</code> is <code>false</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Search</h2>
|
||||||
|
|
||||||
|
<p><code>/search?q=...</code> runs an FTS5 <code>MATCH</code> query. The search term is wrapped as a quoted phrase (embedded <code>"</code> doubled) before binding — parameter binding stops SQL injection, but the bound value is still parsed as its own FTS5 query-language expression, so an unescaped <code>"</code> or FTS operator in user input could otherwise throw a syntax error or search for something unintended.</p>
|
||||||
|
|
||||||
|
<h2>Sitemap</h2>
|
||||||
|
|
||||||
|
<p>See <a href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> for the full write-up — per-page <code>changefreq</code>/<code>priority</code>, what's included/excluded, and a known limitation around relative <code><loc></code> URLs.</p>
|
||||||
|
|
||||||
|
<h2>Blog tag browsing</h2>
|
||||||
|
|
||||||
|
<p><code>App/pages/blog/tag/[tag]/index.php</code> — project-owned, since <code>blog/</code> itself is project content — uses the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture to read the tag from the URL and queries <code>content_tags</code> joined to <code>content_pages</code>. <code>App/pages/blog/index.php</code>'s own hand-written post list is untouched by any of this — it stays the source of truth for the main blog listing; <code>content_tags</code> is a derived index built from each post's own <code>tags</code> block, not a replacement for it.</p>
|
||||||
|
|
||||||
|
<p><code>App/pages/blog/tag/[tag]/feed/index.php</code> is the same query rendered as an RSS feed instead of an HTML list, one directory deeper — <code>/blog/tag/<tag>/feed</code>. Unlike the main blog feed, this one depends on the content index (gated the same way <code>blog/tag/[tag]/index.php</code> itself is), since tags only exist once it's enabled. See <a href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> for the full write-up — <code>Lib\Rss</code>, the main blog feed, and how to add more feeds for other content collections.</p>
|
||||||
|
|
||||||
|
<h2>Migrations, and the two-root scan</h2>
|
||||||
|
|
||||||
|
<p>The schema (<code>content_pages</code>, <code>content_tags</code>, <code>content_search</code>, <code>content_index_meta</code>) ships as a framework migration, <code>novaconium/migrations/0001_create_content_index.sql</code> — the first framework-owned migration, and the reason <a href="/admin/docs/database">{{ icons.book() }}<code>migrations_dir</code></a> now accepts an ordered list of roots instead of a single path: the default connection's <code>migrations_dir</code> is <code>[novaconium/migrations, App/migrations]</code>, so framework migrations always apply before a project's own on the same connection.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Database{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Lib\Db — a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Database</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Db</code> (<code>novaconium/lib/Db.php</code>) is the SQLite/MySQL groundwork tracked in <code>novaconium/ISSUES.md</code> — a thin PDO wrapper plus a minimal migration runner, no ORM and no query builder, consistent with this project's no-Composer, no-build-step philosophy. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Db.php</code>.</p>
|
||||||
|
|
||||||
|
<p>It supports multiple, independently-configured, <strong>simultaneously open</strong> named connections rather than a single global one — because a sidecar is plain PHP with full access to any <code>Lib\</code> 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 or external database.</p>
|
||||||
|
|
||||||
|
<h2>Using it</h2>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Db;
|
||||||
|
|
||||||
|
// Targets the 'default' connection — reads exactly like a single-database API.
|
||||||
|
$rows = Db::query('SELECT * FROM posts WHERE published = ?', [1])->fetchAll();
|
||||||
|
|
||||||
|
// A third argument targets any other configured connection by name, and can
|
||||||
|
// be used in the same request/script as the default connection above.
|
||||||
|
$legacyRows = Db::query('SELECT * FROM widgets', [], 'legacy')->fetchAll();</code></pre>
|
||||||
|
|
||||||
|
<p><code>Db::query(string $sql, array $params = [], string $connection = 'default')</code> prepares and executes against the named connection in one call, returning the <code>PDOStatement</code>. It's the only query-running helper this class exposes — there is deliberately no string-interpolation convenience method. <code>Db::connection(string $name = 'default')</code> returns the raw <code>PDO</code> instance for anything <code>query()</code> doesn't cover (transactions, <code>lastInsertId()</code>, etc.).</p>
|
||||||
|
|
||||||
|
<p><strong>Always use parameter binding, never string-concatenate values into SQL</strong> — the same rule <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Input</a>'s own documentation already commits to: cleaning input is defense-in-depth against HTML/script injection, not SQL injection, and no string transform makes arbitrary input safe to concatenate into a query. Parameterized queries are the only real defense, so <code>Db</code> never grows an <code>sqlSafe()</code>-style shortcut.</p>
|
||||||
|
|
||||||
|
<p>Each connection is opened lazily and independently — nothing touches a given database or runs its migrations until the first real call naming that connection, so a request that only ever uses <code>default</code> never pays to open <code>legacy</code>.</p>
|
||||||
|
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
|
||||||
|
<p>Connections are a named map under a single <code>db_connections</code> key. The framework default defines only <code>default</code> (SQLite):</p>
|
||||||
|
|
||||||
|
<pre><code>// novaconium/config.php (framework default)
|
||||||
|
'db_connections' => [
|
||||||
|
'default' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'path' => __DIR__ . '/../data/novaconium.sqlite',
|
||||||
|
'migrations_dir' => [
|
||||||
|
__DIR__ . '/migrations',
|
||||||
|
__DIR__ . '/../App/migrations',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],</code></pre>
|
||||||
|
|
||||||
|
<p>Add a MySQL connection alongside it from <code>App/config.php</code>:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/config.php
|
||||||
|
return [
|
||||||
|
'db_connections' => [
|
||||||
|
'legacy' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 3306,
|
||||||
|
'database' => 'legacy_app',
|
||||||
|
'username' => 'root',
|
||||||
|
'password' => '...',
|
||||||
|
'charset' => 'utf8mb4', // optional, defaults to utf8mb4
|
||||||
|
'migrations_dir' => __DIR__ . '/migrations/legacy', // optional
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p><strong>This is the one config key in the project that doesn't follow the usual shallow-merge rule.</strong> Every other <code>App/config.php</code> key replaces the framework default outright (see <a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a>) — but a plain shallow merge on <code>db_connections</code> would let the snippet above silently delete the framework's <code>default</code> connection just by adding <code>legacy</code>. So <code>Lib\Db</code> merges <code>db_connections</code> one level deeper, by connection name: the example above ends up with both <code>default</code> (SQLite, from the framework) and <code>legacy</code> (MySQL, from <code>App/config.php</code>) configured at once. To actually replace <code>default</code>, redeclare a <code>default</code> key yourself.</p>
|
||||||
|
|
||||||
|
<p>Only <code>'sqlite'</code> and <code>'mysql'</code> are implemented as <code>driver</code> values. <code>migrations_dir</code> is optional per connection — omit it to never run migrations against that connection (e.g. a legacy database this project shouldn't manage schema for) — and accepts either a single path (<code>legacy</code>'s example above) or an ordered list of roots (the default connection's example above), each scanned for its own <code>*.sql</code> files.</p>
|
||||||
|
|
||||||
|
<p>The default connection's <code>path</code> lives in a top-level <code>data/</code> directory — a sibling of <code>App/</code>, <code>novaconium/</code>, and <code>public/</code>, not nested inside any of them. This is deliberate: it can't live under <code>public/</code> (would be directly web-accessible), and it can't live under <code>novaconium/</code> either, since <a class="icon-link" href="/admin/docs/getting-started">{{ icons.book() }}updating the framework</a> means overwriting that whole directory — anything persisted there would be destroyed by the next update. <code>data/</code> is project-owned, like <code>App/</code>, and untouched by a framework update. Its contents (<code>*.sqlite</code> and the SQLite journal/WAL/SHM sidecar files) are gitignored; only a <code>.gitkeep</code> is tracked so the directory exists in a fresh clone.</p>
|
||||||
|
|
||||||
|
<h2>Migrations</h2>
|
||||||
|
|
||||||
|
<p>Plain <code>.sql</code> files under each connection's own <code>migrations_dir</code> root(s), applied in filename order within each root — name them with a numeric prefix to control ordering:</p>
|
||||||
|
|
||||||
|
<pre><code>-- App/migrations/0001_create_posts.sql
|
||||||
|
CREATE TABLE posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL
|
||||||
|
);</code></pre>
|
||||||
|
|
||||||
|
<p>Each file is tracked by its path relative to the repo root (e.g. <code>novaconium/migrations/0001_x.sql</code>) in that connection's own <code>schema_migrations</code> table (created automatically in that connection's database) and only ever run once — <code>default</code> and <code>legacy</code> each track their own applied migrations independently. Tracking the repo-relative path rather than the bare filename matters once a connection has more than one <code>migrations_dir</code> root: two roots can each contain a same-named file (e.g. a framework <code>0001_...sql</code> and an unrelated project <code>0001_...sql</code>) without one being mistaken for the other already having run. Migrations for a given connection apply automatically the first time it's used in a process — zero-config, the same "just works" philosophy as static caching — or explicitly for every configured connection at once, without serving a request first:</p>
|
||||||
|
|
||||||
|
<pre><code>php novaconium/bin/migrate.php</code></pre>
|
||||||
|
|
||||||
|
<p>When a connection has multiple <code>migrations_dir</code> roots, each root is fully processed in the order given — the default connection's own framework root (<code>novaconium/migrations/</code>) always applies completely before its project root (<code>App/migrations/</code>), not interleaved by filename across the two. <code>novaconium/migrations/0001_create_content_index.sql</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>) is the first framework-shipped migration — the reason this two-root support exists at all, extending the same App-over-novaconium override pattern used for pages and lib to migrations too. Point two connections' <code>migrations_dir</code> at different directories (e.g. <code>App/migrations/</code> for <code>default</code>, <code>App/migrations/legacy/</code> for <code>legacy</code>) if their SQL genuinely diverges between drivers; otherwise the same directory works for both as long as the SQL in it is portable.</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 %}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Draft pages{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Let an admin preview a page before the public can see it, without a second login mechanism.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Draft pages</h1>
|
||||||
|
|
||||||
|
<p>List a page's 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 <code>404</code>, exactly as if the page didn't exist at all:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
// App/config.php
|
||||||
|
return [
|
||||||
|
'admin_auth_enabled' => true,
|
||||||
|
'draft_routes' => ['blog/upcoming-post'],
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p>Each entry matches the same path format <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a> resolves internally — no leading slash, directory segments joined with <code>/</code> (e.g. <code>App/pages/blog/upcoming-post/</code> is listed as <code>'blog/upcoming-post'</code>).</p>
|
||||||
|
|
||||||
|
<h2>Not a login prompt</h2>
|
||||||
|
|
||||||
|
<p>An unauthenticated visitor to a draft route gets the site's normal 404 page — not a redirect to the login form like <code>/admin/*</code> gives. This is deliberate: bouncing to a login would itself reveal that something is gated at that URL. A draft is indistinguishable from a URL that was never routable in the first place.</p>
|
||||||
|
|
||||||
|
<p>There's no separate login flow for drafts, and none is needed — <code>AdminAuth::isAdmin()</code> (the same check <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a>'s admin gate uses) is reused directly, so drafts are admin-only: a logged-in <em>registered</em> user gets the same 404 as everyone else (previewing unpublished work is a site-running privilege, not a membership perk — use <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}<code>Lib\Access</code></a> for members-only content). In practice, an admin logs in once at <code>/admin/login</code>; the session cookie is scoped to the whole origin, not a single path, so it covers later requests to a draft URL too. With <code>admin_auth_enabled</code> left off (or while no users exist yet), drafts are open to everyone, consistent with the rest of <code>/admin/*</code>.</p>
|
||||||
|
|
||||||
|
<h2>The caching interaction</h2>
|
||||||
|
|
||||||
|
<p>Sidecar-less pages normally get pre-rendered once and served as static HTML straight from <code>public/cache/</code> on every later request (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — <code>.htaccess</code> checks for that cached file <strong>before PHP, and therefore any auth check, ever runs</strong>. A draft page without its own sidecar would otherwise take that exact path: the moment an authenticated admin previewed it, the rendered HTML would be written to the cache as a plain file, and every subsequent visitor — authenticated or not — would be served it directly by Apache, permanently bypassing the draft gate.</p>
|
||||||
|
|
||||||
|
<p>Draft routes are therefore excluded from the static cache unconditionally, regardless of whether the page has a sidecar — <code>novaconium/bootstrap.php</code> passes this down to <code>Renderer::render()</code>'s <code>$excludeFromCache</code> parameter. Every <code>/admin/*</code> route gets the same exclusion, for the identical reason (most admin pages have no sidecar either).</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -87,7 +87,7 @@ return [
|
|||||||
|
|
||||||
<p>The honeypot field and the hidden timestamp are the two pieces every form needs regardless of its actual fields — copy them as-is:</p>
|
<p>The honeypot field and the hidden timestamp are the two pieces every form needs regardless of its actual fields — copy them as-is:</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Newsletter{% endblock %}
|
{% block title %}Newsletter{% endblock %}
|
||||||
{% block description %}Sign up for occasional updates.{% endblock %}
|
{% block description %}Sign up for occasional updates.{% endblock %}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>Getting started</h1>
|
<h1>Getting started</h1>
|
||||||
|
|
||||||
<p><strong>Requirements:</strong> PHP 8.1+ and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>.</p>
|
<p><strong>Requirements:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code>. The <a href="/admin/docs/database">Database</a>/<a href="/admin/docs/content-index">Content index</a>/<a href="/admin/docs/admin-auth">Admin authentication</a> features are optional and off by default — see <a href="/admin/docs">Overview</a> for the extensions they need (<code>pdo_sqlite</code>, optionally <code>pdo_mysql</code>/FTS5) if you turn them on.</p>
|
||||||
|
|
||||||
<h2>Run it locally (no Apache needed)</h2>
|
<h2>Run it locally (no Apache needed)</h2>
|
||||||
|
|
||||||
@@ -26,6 +26,32 @@
|
|||||||
|
|
||||||
<p>Point the vhost's document root at <code>public/</code>, make sure <code>mod_rewrite</code> is enabled and <code>AllowOverride All</code> is set for that directory so <code>public/.htaccess</code> takes effect, and it just works — no build step required.</p>
|
<p>Point the vhost's document root at <code>public/</code>, make sure <code>mod_rewrite</code> is enabled and <code>AllowOverride All</code> is set for that directory so <code>public/.htaccess</code> takes effect, and it just works — no build step required.</p>
|
||||||
|
|
||||||
|
<h2>Starting a new project</h2>
|
||||||
|
|
||||||
|
<p>Clone this repo and drop its Git history — that's it, there's no Composer scaffold or installer:</p>
|
||||||
|
|
||||||
|
<pre><code>git clone --depth 1 <novaconium-repo-url> my-new-project
|
||||||
|
cd my-new-project
|
||||||
|
rm -rf .git
|
||||||
|
git init
|
||||||
|
git add -A
|
||||||
|
git commit -m "Initial commit from novaconium template"</code></pre>
|
||||||
|
|
||||||
|
<p>Then replace the example content that ships under <code>App/pages/</code> (the <code>about</code>/<code>blog</code>/<code>contact</code> sample pages) with your own pages, lib classes, and config. Leave <code>novaconium/</code> and <code>public/</code> as-is.</p>
|
||||||
|
|
||||||
|
<h2>Updating the framework</h2>
|
||||||
|
|
||||||
|
<p>Because the framework core lives entirely under <code>novaconium/</code> — separate from your project's <code>App/</code> — picking up a new release is a matter of overwriting that one directory and committing the diff:</p>
|
||||||
|
|
||||||
|
<pre><code>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>"</code></pre>
|
||||||
|
|
||||||
|
<p>This is safe by construction: the override-by-path design means <code>App/</code> always wins over <code>novaconium/</code> for pages, lib classes, and Sass colors (see <a href="/admin/docs/project-layout">Project layout</a>), so an update can't clobber your project's customizations. Diff before committing to see what changed, and run <code>php novaconium/bin/clear-cache.php</code> afterward since a framework update can change rendered output.</p>
|
||||||
|
|
||||||
<h2>Adding a new page</h2>
|
<h2>Adding a new page</h2>
|
||||||
|
|
||||||
<p>Create a directory under <code>App/pages/</code> with an <code>index.twig</code> — the directory path <em>is</em> the URL (see <a href="/admin/docs/routing">Routing</a>). <a href="/admin/docs/seo">SEO</a> has a ready-to-paste starter template with every overridable block (title, description, Open Graph, Twitter Card) plus a content stub — copy it in and fill in the blanks.</p>
|
<p>Create a directory under <code>App/pages/</code> with an <code>index.twig</code> — the directory path <em>is</em> the URL (see <a href="/admin/docs/routing">Routing</a>). <a href="/admin/docs/seo">SEO</a> has a ready-to-paste starter template with every overridable block (title, description, Open Graph, Twitter Card) plus a content stub — copy it in and fill in the blanks.</p>
|
||||||
|
|||||||
@@ -4,21 +4,46 @@
|
|||||||
|
|
||||||
{% block title %}Docs{% endblock %}
|
{% block title %}Docs{% endblock %}
|
||||||
|
|
||||||
{% block description %}Framework documentation: routing, sidecars, forms, libraries, 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 %}
|
||||||
|
|
||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
|
<h1 class="icon-heading">{{ icons.book() }}Project documentation</h1>
|
||||||
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p>
|
<p>Framework docs, rendered as plain Twig pages — no internet connection needed.</p>
|
||||||
|
|
||||||
|
<h2>Requirements</h2>
|
||||||
|
|
||||||
|
<p><strong>Minimum:</strong> PHP 8.1+ (uses <code>readonly</code> constructor-promoted properties) and, for production, Apache with <code>mod_rewrite</code> and <code>AllowOverride All</code> — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
||||||
|
|
||||||
|
<p><strong>Optional, for the database/content-index features</strong> (<a href="/admin/docs/database">Database</a>, <a href="/admin/docs/content-index">Content index</a> — both off by default):</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>The <code>pdo_sqlite</code> extension — bundled with PHP, just needs to be enabled, no separate install. Required for <code>Lib\Db</code>'s default (SQLite) connection.</li>
|
||||||
|
<li><code>pdo_mysql</code> — only if using a MySQL <code>db_connections</code> entry alongside or instead of SQLite.</li>
|
||||||
|
<li>SQLite's FTS5 extension — bundled with <code>pdo_sqlite</code> on virtually every modern PHP build. Only needed for <code>/search</code>, i.e. only relevant if <code>content_index_enabled</code> is turned on.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
|
||||||
<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>
|
||||||
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> — plain PHP classes under <code>Lib\</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Libraries</a> — plain PHP classes under <code>Lib\</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a> — override framework settings from <code>App/config.php</code>.</li>
|
<li><a class="icon-link" href="/admin/docs/config">{{ icons.book() }}Configuration</a> — override framework settings from <code>App/config.php</code>.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> — gate <code>/admin/*</code> behind HTTP Basic Auth, reusable for any future admin page.</li>
|
<li><a class="icon-link" href="/admin/docs/database">{{ icons.book() }}Database</a> — <code>Lib\Db</code>, a thin PDO wrapper (SQLite and MySQL) with named, simultaneous connections and a plain-SQL migration convention.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/session">{{ icons.book() }}Session</a> — <code>Lib\Session</code>, a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/content-index">{{ icons.search() }}Content index</a> — the shared crawler behind <code>/sitemap.xml</code>, <code>/search</code>, and blog tag browsing. Off by default.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/sitemap">{{ icons.sitemap() }}XML sitemap</a> — per-page <code>changefreq</code>/<code>priority</code>, what's included, and submitting it to search engines.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/rss">{{ icons.rss() }}RSS feeds</a> — <code>Lib\Rss</code>, building one feed or several for any content collection.</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/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>
|
||||||
@@ -28,5 +53,6 @@
|
|||||||
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a> — vendored Twig and its license.</li>
|
<li><a class="icon-link" href="/admin/docs/third-party">{{ icons.external_link() }}Third-party</a> — vendored Twig and its license.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a> — the original design rationale.</li>
|
<li><a class="icon-link" href="/admin/docs/design-notes">{{ icons.book() }}Design notes</a> — the original design rationale.</li>
|
||||||
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a> — how to bump the vendored copy.</li>
|
<li><a class="icon-link" href="/admin/docs/upgrading-twig">{{ icons.book() }}Upgrading Twig</a> — how to bump the vendored copy.</li>
|
||||||
|
<li><a class="icon-link" href="/admin/docs/upgrading-highlightjs">{{ icons.book() }}Upgrading highlight.js</a> — how to bump the vendored copy, and why it's not automatic on a framework update.</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ App/pages/blog/_layout/layout.twig <- overrides it for everything under
|
|||||||
|
|
||||||
<p>A nested layout can extend the parent one (paths are resolved against the page roots, not relative to the current file):</p>
|
<p>A nested layout can extend the parent one (paths are resolved against the page roots, not relative to the current file):</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends 'admin/docs/_layout/layout.twig' %}{% endverbatim %}</code></pre>
|
<pre><code class="nohighlight">{% verbatim %}{% extends 'admin/docs/_layout/layout.twig' %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
<p>Every <code>index.twig</code> extends whichever layout was resolved for it, via the <code>layout</code> variable that's always injected into the context:</p>
|
<p>Every <code>index.twig</code> extends whichever layout was resolved for it, via the <code>layout</code> variable that's always injected into the context:</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
{% block content %}...{% endblock %}{% endverbatim %}</code></pre>
|
{% block content %}...{% endblock %}{% endverbatim %}</code></pre>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -26,5 +26,9 @@
|
|||||||
<li><code>Lib\Validate</code> — the lower-level validation primitives <code>FormValidator</code> calls into (<code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code>, <code>isPhone()</code>, <code>isPostalCode()</code>/<code>isZipCode()</code>) — call these directly from a sidecar when you just need a validated/normalized value back rather than an accumulated field-error. See <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section.</li>
|
<li><code>Lib\Validate</code> — the lower-level validation primitives <code>FormValidator</code> calls into (<code>isEmail()</code>, <code>minLength()</code>/<code>maxLength()</code>, <code>isMatch()</code>, <code>isPhone()</code>, <code>isPostalCode()</code>/<code>isZipCode()</code>) — call these directly from a sidecar when you just need a validated/normalized value back rather than an accumulated field-error. See <a href="/admin/docs/sidecars">Sidecars</a>' "Spam prevention" section.</li>
|
||||||
<li><code>Lib\Input</code> — a cleaning accessor for <code>$_POST</code>/<code>$_GET</code> (<code>Input::post()</code>/<code>Input::get()</code>), used by every sidecar instead of the superglobals directly. Defense-in-depth against HTML/script injection, <strong>not</strong> a defense against SQL injection — see <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section for the full caveat.</li>
|
<li><code>Lib\Input</code> — a cleaning accessor for <code>$_POST</code>/<code>$_GET</code> (<code>Input::post()</code>/<code>Input::get()</code>), used by every sidecar instead of the superglobals directly. Defense-in-depth against HTML/script injection, <strong>not</strong> a defense against SQL injection — see <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section for the full caveat.</li>
|
||||||
<li><code>Lib\Csrf</code> — standalone session-token CSRF protection (<code>Csrf::token()</code>/<code>::verify()</code>), called directly from a sidecar rather than through <code>FormValidator</code>. See <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section.</li>
|
<li><code>Lib\Csrf</code> — standalone session-token CSRF protection (<code>Csrf::token()</code>/<code>::verify()</code>), called directly from a sidecar rather than through <code>FormValidator</code>. See <a href="/admin/docs/sidecars">Sidecars</a>' "Form security" section.</li>
|
||||||
|
<li><code>Lib\Db</code> — the thin no-ORM PDO wrapper behind everything database-backed here. See <a href="/admin/docs/database">Database</a>.</li>
|
||||||
|
<li><code>Lib\Session</code> — native PHP sessions with a consistent get/set/flash API. See <a href="/admin/docs/session">Session</a>.</li>
|
||||||
|
<li><code>Lib\Access</code> — sidecar-level access control: assign a page to a user or group (<code>Access::require('group:members')</code>). See <a href="/admin/docs/access-control">Access control</a>.</li>
|
||||||
|
<li><code>Lib\Rss</code> — a generic RSS 2.0 envelope builder. See <a href="/admin/docs/rss">RSS feeds</a>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -44,9 +44,8 @@ novaconium/ the framework itself — boilerplate, not meant to be edite
|
|||||||
<ol>
|
<ol>
|
||||||
<li>Requires <strong><code>novaconium/autoload.php</code></strong>, registering the <code>Twig\</code>/<code>App\</code>/<code>Lib\</code> class autoloader (see <a href="/admin/docs/libraries">Libraries</a>) before anything below tries to instantiate a class.</li>
|
<li>Requires <strong><code>novaconium/autoload.php</code></strong>, registering the <code>Twig\</code>/<code>App\</code>/<code>Lib\</code> class autoloader (see <a href="/admin/docs/libraries">Libraries</a>) before anything below tries to instantiate a class.</li>
|
||||||
<li>Requires <strong><code>novaconium/config.php</code></strong>, then shallow-merges <strong><code>App/config.php</code></strong> over it if that file exists — see <a href="/admin/docs/config">Configuration</a>.</li>
|
<li>Requires <strong><code>novaconium/config.php</code></strong>, then shallow-merges <strong><code>App/config.php</code></strong> over it if that file exists — see <a href="/admin/docs/config">Configuration</a>.</li>
|
||||||
<li>Special-cases <code>/admin/logout</code> directly against the request path — see <a href="/admin/docs/admin-auth">Admin authentication</a> — since it isn't a real page for the router to find.</li>
|
|
||||||
<li>Constructs a <strong><code>Router</code></strong> (<code>novaconium/src/Router.php</code>) and calls <code>resolve()</code> to turn the URL into a <strong><code>Route</code></strong> (<code>novaconium/src/Route.php</code>) — see <a href="/admin/docs/routing">Routing</a>.</li>
|
<li>Constructs a <strong><code>Router</code></strong> (<code>novaconium/src/Router.php</code>) and calls <code>resolve()</code> to turn the URL into a <strong><code>Route</code></strong> (<code>novaconium/src/Route.php</code>) — see <a href="/admin/docs/routing">Routing</a>.</li>
|
||||||
<li>If the resolved route is under <code>admin</code>/<code>admin/*</code>, calls <strong><code>AdminAuth::requireLogin()</code></strong> (<code>novaconium/src/AdminAuth.php</code>), reading that same <code>Route</code>.</li>
|
<li>If the resolved route is under <code>admin</code>/<code>admin/*</code> (except <code>admin/login</code> itself), calls <strong><code>AdminAuth::requireLogin()</code></strong> (<code>novaconium/src/AdminAuth.php</code>), reading that same <code>Route</code>.</li>
|
||||||
<li>Constructs a <strong><code>Cache</code></strong> (<code>novaconium/src/Cache.php</code>) and a <strong><code>Renderer</code></strong> (<code>novaconium/src/Renderer.php</code>), then calls <code>renderNotFound()</code> or <code>render($route, ...)</code> depending on <code>$route->found</code> — see <a href="/admin/docs/sidecars">Sidecars</a> for what happens inside <code>Renderer</code> itself (running the matched directory's <code>index.php</code>, if any; resolving the nearest layout; rendering <code>index.twig</code>; writing the static cache for sidecar-less pages).</li>
|
<li>Constructs a <strong><code>Cache</code></strong> (<code>novaconium/src/Cache.php</code>) and a <strong><code>Renderer</code></strong> (<code>novaconium/src/Renderer.php</code>), then calls <code>renderNotFound()</code> or <code>render($route, ...)</code> depending on <code>$route->found</code> — see <a href="/admin/docs/sidecars">Sidecars</a> for what happens inside <code>Renderer</code> itself (running the matched directory's <code>index.php</code>, if any; resolving the nearest layout; rendering <code>index.twig</code>; writing the static cache for sidecar-less pages).</li>
|
||||||
</ol>
|
</ol>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -37,9 +37,8 @@
|
|||||||
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>Config loads (framework defaults + optional <code>App/config.php</code> override).</li>
|
<li>Config loads (framework defaults + optional <code>App/config.php</code> override).</li>
|
||||||
<li><code>/admin/logout</code> is special-cased directly against the raw request path, before routing even runs — it isn't a real page, so there'd be no <code>Route</code> for it anyway.</li>
|
|
||||||
<li><strong><code>Router::resolve()</code> runs</strong> and returns a <code>Route</code> — this is the only place a <code>Route</code> gets created.</li>
|
<li><strong><code>Router::resolve()</code> runs</strong> and returns a <code>Route</code> — this is the only place a <code>Route</code> gets created.</li>
|
||||||
<li>If <code>$route->dir</code> is under <code>admin</code>/<code>admin/*</code>, <code>AdminAuth::requireLogin()</code> gates it — reading <code>$route->dir</code> directly off the value object <code>Router</code> handed back.</li>
|
<li>If <code>$route->dir</code> is under <code>admin</code>/<code>admin/*</code> (except <code>admin/login</code>, which must stay reachable logged-out), <code>AdminAuth::requireLogin()</code> gates it — reading <code>$route->dir</code> directly off the value object <code>Router</code> handed back.</li>
|
||||||
<li><code>Renderer</code> takes over, also just reading the same <code>Route</code>: <code>renderNotFound()</code> if <code>$route->found</code> is <code>false</code>, otherwise <code>render($route, ...)</code> — using <code>$route->dir</code> to find the sidecar/layout/template and <code>$route->params</code> as template context.</li>
|
<li><code>Renderer</code> takes over, also just reading the same <code>Route</code>: <code>renderNotFound()</code> if <code>$route->found</code> is <code>false</code>, otherwise <code>render($route, ...)</code> — using <code>$route->dir</code> to find the sidecar/layout/template and <code>$route->params</code> as template context.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}RSS feeds{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Lib\Rss — building one feed, or several, for any content collection.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.rss() }}RSS feeds</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Rss</code> (<code>novaconium/lib/Rss.php</code>) is a small, generic RSS 2.0 envelope builder — plain string concatenation, no <code>DOMDocument</code>, the same style as <code>/sitemap.xml</code> (see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>). It doesn't know anything about blog posts, or content in general — it just turns a channel title/link/description plus a list of items into an XML string. Every feed on this site, and any feed a project adds, is a plain sidecar-only page (like <code>sitemap.xml</code>/<code>search</code>) that gathers its own items from wherever they live and hands them to it.</p>
|
||||||
|
|
||||||
|
<h2><code>Lib\Rss::render()</code></h2>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Rss;
|
||||||
|
|
||||||
|
$xml = Rss::render(
|
||||||
|
'My Site Blog', // channel title
|
||||||
|
'/blog', // channel link
|
||||||
|
'Posts from My Site.', // channel description
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'title' => 'Post title',
|
||||||
|
'link' => '/blog/post-slug',
|
||||||
|
'guid' => '/blog/post-slug',
|
||||||
|
'pubDateTimestamp' => strtotime('2026-07-11'),
|
||||||
|
'description' => 'A short excerpt or summary.',
|
||||||
|
],
|
||||||
|
// ...one array per item
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml($xml);</code></pre>
|
||||||
|
|
||||||
|
<p><code>link</code>/<code>guid</code> are expected to be site-relative paths (e.g. <code>/blog/post-slug</code>), consistent with how this framework already handles <code>canonical</code>/<code>og:url</code> (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>) — there's no site-wide base-URL config to build absolute URLs from. Every <code><guid></code> is emitted with <code>isPermaLink="false"</code> for exactly this reason: it's a stable identifier, not a real absolute permalink.</p>
|
||||||
|
|
||||||
|
<h2>The two feeds shipped with this site</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>/blog/feed</code> (<code>App/pages/blog/feed/index.php</code>) — the main blog feed. Reads the same hand-written <code>$posts</code> array <code>App/pages/blog/index.php</code> itself renders from, so it has <strong>zero dependency on the content index</strong> — it works even with <code>content_index_enabled</code> left at its default <code>false</code>.</li>
|
||||||
|
<li><code>/blog/tag/<tag>/feed</code> (<code>App/pages/blog/tag/[tag]/feed/index.php</code>) — one feed per tag, generated dynamically via the <a href="/admin/docs/routing">{{ icons.link() }}<code>[param]</code></a> capture rather than a static file per tag. This one <em>does</em> need the content index (same gate as <code>blog/tag/[tag]/index.php</code>), since tags only exist there — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>. Its <code><pubDate></code> is each page's <code>source_mtime</code>, a stand-in for a real publish date the content index doesn't separately track.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Adding another feed</h2>
|
||||||
|
|
||||||
|
<p>Nothing is registered or limited to "one feed" — any sidecar-only page that builds an item list and calls <code>Rss::render()</code> is a feed. For a second content collection (say, <code>App/pages/products/</code>, with its own hand-written array like <code>App/pages/blog/index.php</code>'s), a new <code>App/pages/products/feed/index.php</code> looks almost identical to <code>blog/feed</code>:</p>
|
||||||
|
|
||||||
|
<pre><code><?php
|
||||||
|
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Rss;
|
||||||
|
|
||||||
|
$config = require __DIR__ . '/../../../../novaconium/config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = (require __DIR__ . '/../index.php')['products'];
|
||||||
|
|
||||||
|
$items = array_map(
|
||||||
|
fn (array $p) => [
|
||||||
|
'title' => $p['title'],
|
||||||
|
'link' => '/products/' . $p['slug'],
|
||||||
|
'guid' => '/products/' . $p['slug'],
|
||||||
|
'pubDateTimestamp' => strtotime($p['published']),
|
||||||
|
'description' => $p['excerpt'],
|
||||||
|
],
|
||||||
|
$products
|
||||||
|
);
|
||||||
|
|
||||||
|
return Response::xml(Rss::render(
|
||||||
|
$config['site_name'] . ' Products',
|
||||||
|
'/products',
|
||||||
|
'New products from ' . $config['site_name'] . '.',
|
||||||
|
$items
|
||||||
|
));</code></pre>
|
||||||
|
|
||||||
|
<p>Two independent feeds now exist side by side — <code>/blog/feed</code> and <code>/products/feed</code> — with no shared state, registry, or configuration between them. A project can have as many as it has content collections.</p>
|
||||||
|
|
||||||
|
<h2>Auto-discovery for more than one feed</h2>
|
||||||
|
|
||||||
|
<p><a href="/admin/docs/seo">{{ icons.book() }}SEO</a>'s <code>head_extra</code> block is how a feed gets a <code><link rel="alternate" type="application/rss+xml"></code> tag in <code><head></code> so browsers/feed readers can discover it — <code>App/pages/blog/_layout/layout.twig</code> overrides it with exactly one such tag, scoped to <code>/blog/*</code> pages only since only that layout overrides the block. A layout isn't limited to one <code><link></code> in that override — list several, each with its own <code>title</code> attribute so a feed reader can tell them apart:</p>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight">{% verbatim %}{% block head_extra %}
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Blog" href="/blog/feed">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ site_name }} Products" href="/products/feed">
|
||||||
|
{% endblock %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>Where that override lives determines which pages advertise which feeds — put it in the root layout (<code>novaconium/pages/_layout/layout.twig</code>) for a feed that should be discoverable site-wide, or in a subtree's own <code>_layout/layout.twig</code> (like <code>App/pages/blog/_layout/layout.twig</code> does today) to scope it to just that subtree. A page can also declare a feed link that isn't a listing of that page's own subtree at all — there's no rule tying a <code>head_extra</code> override to the feed(s) "belonging" to that directory, it's just the natural place to put it for the common case.</p>
|
||||||
|
|
||||||
|
<h2>Verifying a feed</h2>
|
||||||
|
|
||||||
|
<p>No test suite — check a feed is well-formed XML with matching item counts before trusting it, the same way this site's own feeds were verified while being built:</p>
|
||||||
|
|
||||||
|
<pre><code>curl -s http://127.0.0.1:8000/blog/feed | php -r '
|
||||||
|
$xml = stream_get_contents(STDIN);
|
||||||
|
$parsed = simplexml_load_string($xml);
|
||||||
|
echo $parsed === false ? "INVALID XML\n" : "valid, " . count($parsed->channel->item) . " items\n";
|
||||||
|
'</code></pre>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
{% block docs_content %}
|
{% block docs_content %}
|
||||||
<h1>SEO boilerplate</h1>
|
<h1>SEO boilerplate</h1>
|
||||||
|
|
||||||
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code><head></code>: viewport, description, robots, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code><head></code>.</p>
|
<p><code>novaconium/pages/_layout/layout.twig</code> (the root layout every page extends, directly or via a nested layout) renders a full set of SEO meta tags in <code><head></code>: viewport, description, robots, keywords, canonical link, Open Graph, and Twitter Card. Each piece is a named Twig block with a sensible default, so any page can override just the piece it needs without touching the rest of <code><head></code>. Three more blocks — <code>tags</code>, <code>changefreq</code>, <code>priority</code> — are declared the same way but never rendered into the page at all; see <a href="/admin/docs/content-index">Content index</a> for what reads them.</p>
|
||||||
|
|
||||||
<h2>Blocks you can override</h2>
|
<h2>Blocks you can override</h2>
|
||||||
|
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code><title></code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
|
<tr><td><code>title</code></td><td><code>site_name</code> config value</td><td><code><title></code>, and reused by <code>og:title</code> / <code>twitter:title</code></td></tr>
|
||||||
<tr><td><code>description</code></td><td>generic site description</td><td><code><meta name="description"></code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
|
<tr><td><code>description</code></td><td>generic site description</td><td><code><meta name="description"></code>, and reused by <code>og:description</code> / <code>twitter:description</code></td></tr>
|
||||||
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code><meta name="robots"></code></td></tr>
|
<tr><td><code>robots</code></td><td><code>index, follow</code></td><td><code><meta name="robots"></code></td></tr>
|
||||||
|
<tr><td><code>keywords</code></td><td>empty</td><td><code><meta name="keywords"></code></td></tr>
|
||||||
|
<tr><td><code>tags</code></td><td>empty</td><td>not rendered — comma-separated, harvested by <a href="/admin/docs/content-index">the content index</a> for blog tag browsing</td></tr>
|
||||||
|
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><changefreq></code></td></tr>
|
||||||
|
<tr><td><code>priority</code></td><td><code>0.5</code></td><td>not rendered — harvested for <code>/sitemap.xml</code>'s <code><priority></code></td></tr>
|
||||||
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code><link rel="canonical"></code>, and reused by <code>og:url</code></td></tr>
|
<tr><td><code>canonical</code></td><td><code>{{ '{{ request_path }}' }}</code></td><td><code><link rel="canonical"></code>, and reused by <code>og:url</code></td></tr>
|
||||||
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code><meta property="og:type"></code></td></tr>
|
<tr><td><code>og_type</code></td><td><code>website</code></td><td><code><meta property="og:type"></code></td></tr>
|
||||||
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta property="og:title"></code></td></tr>
|
<tr><td><code>og_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta property="og:title"></code></td></tr>
|
||||||
@@ -29,6 +33,7 @@
|
|||||||
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
<tr><td><code>twitter_card</code></td><td><code>summary</code></td><td><code><meta name="twitter:card"></code></td></tr>
|
||||||
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
<tr><td><code>twitter_title</code></td><td><code>{{ '{{ block(\'title\') }}' }}</code></td><td><code><meta name="twitter:title"></code></td></tr>
|
||||||
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></code></td></tr>
|
<tr><td><code>twitter_description</code></td><td><code>{{ '{{ block(\'description\') }}' }}</code></td><td><code><meta name="twitter:description"></code></td></tr>
|
||||||
|
<tr><td><code>head_extra</code></td><td>empty</td><td>open-ended — anything a subtree's own layout needs in <code><head></code> that doesn't fit an existing block. <code>App/pages/blog/_layout/layout.twig</code> overrides it with the blog's RSS <code><link rel="alternate"></code>, scoped to <code>/blog/*</code> only since only that layout overrides it.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -38,7 +43,7 @@
|
|||||||
|
|
||||||
<p>Any <code>index.twig</code> can override any subset of these blocks, same as <code>title</code> or <code>content</code>:</p>
|
<p>Any <code>index.twig</code> can override any subset of these blocks, same as <code>title</code> or <code>content</code>:</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Pricing{% endblock %}
|
{% block title %}Pricing{% endblock %}
|
||||||
{% block description %}Plans and pricing for the whole team.{% endblock %}
|
{% block description %}Plans and pricing for the whole team.{% endblock %}
|
||||||
@@ -54,12 +59,16 @@
|
|||||||
|
|
||||||
<p>Every <code>App/pages/*/index.twig</code> page in this project already includes the full block below as a reference — copy it into a new page and fill in the blanks. Nothing here is required (the layout's defaults are fine on their own), but having every knob visible up front makes it obvious what's available. Don't want to copy-paste by hand? <code>php novaconium/bin/create-static-page.php <path></code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
<p>Every <code>App/pages/*/index.twig</code> page in this project already includes the full block below as a reference — copy it into a new page and fill in the blanks. Nothing here is required (the layout's defaults are fine on their own), but having every knob visible up front makes it obvious what's available. Don't want to copy-paste by hand? <code>php novaconium/bin/create-static-page.php <path></code> scaffolds this exact template for you — see <a href="/admin/docs/getting-started">Getting started</a>.</p>
|
||||||
|
|
||||||
<pre><code>{% verbatim %}{% extends layout %}
|
<pre><code class="nohighlight">{% verbatim %}{% extends layout %}
|
||||||
|
|
||||||
{% block title %}Page title{% endblock %}
|
{% block title %}Page title{% endblock %}
|
||||||
{% block description %}One or two sentences describing this page.{% endblock %}
|
{% block description %}One or two sentences describing this page.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}index, follow{% endblock %}
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block keywords %}{% endblock %}
|
||||||
|
{% block tags %}{% endblock %}
|
||||||
|
{% block changefreq %}monthly{% endblock %}
|
||||||
|
{% block priority %}0.5{% endblock %}
|
||||||
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
{% block og_type %}website{% endblock %}
|
{% block og_type %}website{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Session{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Lib\Session — a thin wrapper around native PHP sessions, with CodeIgniter-style flash data.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Session</h1>
|
||||||
|
|
||||||
|
<p><code>Lib\Session</code> (<code>novaconium/lib/Session.php</code>) is a thin wrapper around PHP's native session handling — plain <code>session_start()</code>/<code>$_SESSION</code>, not a custom session store — so sidecars have a consistent get/set API instead of touching <code>$_SESSION</code> directly. It's a <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\</a> class like <code>Input</code>/<code>Csrf</code>/<code>Mailer</code>, so a project can override it entirely by dropping its own <code>App/lib/Session.php</code>.</p>
|
||||||
|
|
||||||
|
<h2>Using it</h2>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Session;
|
||||||
|
|
||||||
|
Session::set('user_id', 42);
|
||||||
|
$userId = Session::get('user_id'); // 42
|
||||||
|
$loggedIn = Session::has('user_id'); // true
|
||||||
|
Session::remove('user_id');</code></pre>
|
||||||
|
|
||||||
|
<p>The session is started lazily — nothing calls <code>session_start()</code> until the first real call to any <code>Session</code> method, so a page that never touches <code>Session</code> never gets a session cookie. <a class="icon-link" href="/admin/docs/libraries">{{ icons.book() }}Lib\Csrf</a> uses the exact same lazy-start mechanism to run its own session-token CSRF protection — both classes can touch the same native session in the same request without conflict, since <code>session_start()</code> is only ever actually called once (PHP no-ops a second call).</p>
|
||||||
|
|
||||||
|
<p><code>Session::regenerate()</code> swaps the session id for a fresh one while keeping the session's data — call it on any privilege change, so a session id an attacker planted or observed before the change is worthless after it (session fixation). <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}Admin authentication</a> does exactly this on login and logout.</p>
|
||||||
|
|
||||||
|
<h2>Flash data</h2>
|
||||||
|
|
||||||
|
<p>A flashed value is readable on exactly the next request, then gone — useful for post/redirect/GET flows (a "message sent" banner after a redirect) without a query-string flag like <code>?sent=1</code>:</p>
|
||||||
|
|
||||||
|
<pre><code>use Lib\Session;
|
||||||
|
use App\Response;
|
||||||
|
|
||||||
|
// In the sidecar handling the POST:
|
||||||
|
Session::flash('message', 'Sent! We\'ll be in touch soon.');
|
||||||
|
return Response::redirect('/contact');
|
||||||
|
|
||||||
|
// In the sidecar handling the following GET (the redirect target):
|
||||||
|
return [
|
||||||
|
'flashMessage' => Session::getFlash('message'),
|
||||||
|
];</code></pre>
|
||||||
|
|
||||||
|
<p><code>Session::getFlash($key, $default = null)</code> returns the value on the request immediately after <code>flash()</code> was called, and the default on every request after that — regardless of whether <code>getFlash()</code> was actually called on that one request in between. A value flashed during the current request is never visible to <code>getFlash()</code> during that same request; it becomes visible on the next one.</p>
|
||||||
|
|
||||||
|
<p>Mechanically, this is a single swap rather than a separate expiry/sweep step: the first time any <code>Session</code> method runs in a request, it snapshots whatever was flashed on the previous request into an in-memory value for that request's <code>getFlash()</code> calls, then immediately clears the stored flash bucket so <code>flash()</code> calls made during the current request start filling a fresh bucket — the one the next request will snapshot in turn.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -63,9 +63,11 @@ $all = Input::post(); // the whole cleaned $_POST array</code><
|
|||||||
|
|
||||||
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
|
<p>Calling <code>post()</code>/<code>get()</code> with no key returns the entire cleaned array (handy for passing straight to <code>SpamGuard::isSpam()</code>, as below); calling it with a key returns that key's cleaned value, or the given default if it's absent. Nested arrays (e.g. a checkbox group posted as <code>tags[]</code>) are cleaned recursively. The result is memoized per request, so calling <code>Input::post()</code> repeatedly across a sidecar doesn't re-clean the superglobal each time.</p>
|
||||||
|
|
||||||
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries (PDO prepared statements). There's no database layer in this framework yet (SQLite groundwork is Backlog — see <code>novaconium/ISSUES.md</code>); when one lands, use prepared statements exclusively. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
<p><strong>This is not SQL-injection protection.</strong> The cleaning <code>Input</code> does (trim + strip tags, via <code>Lib\Validate::clean()</code>, plus null-byte stripping) is defense-in-depth against HTML/script injection in output contexts — Twig already autoescapes <code>{{ '{{ }}' }}</code> output by default (see <code>novaconium/src/Renderer.php</code>), so this is a second layer, not the only one. No string transform makes arbitrary input safe to concatenate into a SQL query; the real defense is parameterized queries. <a href="/admin/docs/database">Lib\Db</a>'s <code>query()</code> method uses PDO prepared statements exclusively for exactly this reason. <code>Input</code> deliberately has no <code>sqlSafe()</code>-style method, since a method implying "cleaned = safe to interpolate into SQL" would be actively dangerous.</p>
|
||||||
|
|
||||||
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later. See <code>novaconium/pages/admin/password-hash/index.php</code> for the one place this framework does that on purpose.</p>
|
<p>One documented exception: a field that needs an exact, unmodified value — a password about to be hashed or verified, say — should read <code>$_POST</code> directly instead of going through <code>Input::post()</code>. Cleaning would silently strip characters like <code><</code>/<code>></code> before hashing, producing a hash that doesn't match what's actually typed later (or failing a login whose password actually matches). See the password fields in <code>novaconium/pages/admin/users/index.php</code> and <code>novaconium/pages/admin/login/index.php</code> for the places this framework does that on purpose.</p>
|
||||||
|
|
||||||
|
<p>A sidecar is also where a page gets assigned to a user or group: one <code>Lib\Access</code> call at the top, returning its <code>Response</code> when access is denied — see <a href="/admin/docs/access-control">Access control</a>.</p>
|
||||||
|
|
||||||
<h3>CSRF protection</h3>
|
<h3>CSRF protection</h3>
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ $phone = Validate::isPhone($old['phone']); // '5551234567' or false</code></pr
|
|||||||
|
|
||||||
<p>All three classes live under <code>novaconium/lib/</code> as framework defaults — like any other <code>Lib\</code> class, a project can override any of them by dropping a same-named file in <code>App/lib/</code> (see <a href="/admin/docs/libraries">Libraries</a>).</p>
|
<p>All three classes live under <code>novaconium/lib/</code> as framework defaults — like any other <code>Lib\</code> class, a project can override any of them by dropping a same-named file in <code>App/lib/</code> (see <a href="/admin/docs/libraries">Libraries</a>).</p>
|
||||||
|
|
||||||
<h2>Copy-paste starter: a sidecar, three ways</h2>
|
<h2>Copy-paste starter: a sidecar, four ways</h2>
|
||||||
|
|
||||||
<p>Drop this in as <code>App/pages/example/index.php</code> (next to an <code>App/pages/example/index.twig</code> using the <a href="/admin/docs/seo">SEO starter template</a>) and delete whichever example you don't need:</p>
|
<p>Drop this in as <code>App/pages/example/index.php</code> (next to an <code>App/pages/example/index.twig</code> using the <a href="/admin/docs/seo">SEO starter template</a>) and delete whichever example you don't need:</p>
|
||||||
|
|
||||||
@@ -166,9 +168,26 @@ return [
|
|||||||
//
|
//
|
||||||
// ob_start();
|
// ob_start();
|
||||||
// phpinfo();
|
// phpinfo();
|
||||||
// return Response::html(ob_get_clean());{% endverbatim %}</code></pre>
|
// return Response::html(ob_get_clean());
|
||||||
|
|
||||||
<p>Only one of the three <code>return</code>s in a real sidecar ever runs, obviously — pick one, or branch between them with an <code>if</code>. The IP example works with or without a sidecar-only page (no <code>index.twig</code>); the <code>phpinfo()</code> example needs <em>no</em> <code>index.twig</code> at all, since <code>Response::html()</code> bypasses Twig — see the JSON-only example above for another sidecar-only page.</p>
|
// 4. Require a login — gate the page to a group, a user, or any
|
||||||
|
// logged-in account before doing anything else. Access::require()
|
||||||
|
// returns null when the visitor may proceed, or a ready-made Response
|
||||||
|
// (a login redirect that comes back here afterwards, or a 404 for the
|
||||||
|
// wrong account) for you to return as-is. Needs admin_auth_enabled and
|
||||||
|
// at least one user — see /admin/docs/access-control for the rules and
|
||||||
|
// /admin/docs/admin-auth for accounts and groups.
|
||||||
|
// use Lib\Access;
|
||||||
|
//
|
||||||
|
// if ($denied = Access::require('group:members')) {
|
||||||
|
// return $denied;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return [
|
||||||
|
// 'message' => 'Hello, member!',
|
||||||
|
// ];{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>Only one of the numbered <code>return</code>s in a real sidecar ever runs, obviously — pick one, or branch between them with an <code>if</code>. The IP example works with or without a sidecar-only page (no <code>index.twig</code>); the <code>phpinfo()</code> example needs <em>no</em> <code>index.twig</code> at all, since <code>Response::html()</code> bypasses Twig — see the JSON-only example above for another sidecar-only page. The login gate in example 4 isn't really an alternative to the other three — it's a first line that composes with any of them: gate first, then return whatever the page normally would. Swap the rule for <code>Access::require('user:bob')</code>, several rules (any one grants access), or no rules at all for "anyone logged in"; admins always pass.</p>
|
||||||
|
|
||||||
<p><strong>Never ship <code>phpinfo()</code> to production</strong> — it dumps environment variables, file paths, loaded extensions, and configuration values that are useful to an attacker mapping your server. Delete the page after you're done with it, or at minimum gate it behind <a href="/admin/docs/admin-auth">admin authentication</a> the same way <code>/admin/*</code> already is, so it's never reachable by the public.</p>
|
<p><strong>Never ship <code>phpinfo()</code> to production</strong> — it dumps environment variables, file paths, loaded extensions, and configuration values that are useful to an attacker mapping your server. Delete the page after you're done with it, or at minimum gate it behind <a href="/admin/docs/admin-auth">admin authentication</a> the same way <code>/admin/*</code> already is, so it's never reachable by the public.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}XML sitemap{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}/sitemap.xml — generated from the content index, with per-page changefreq/priority.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.sitemap() }}XML sitemap</h1>
|
||||||
|
|
||||||
|
<p><code>/sitemap.xml</code> (<code>novaconium/pages/sitemap.xml/index.php</code>) lists every indexed page for search engine discovery, following the <a href="https://www.sitemaps.org/protocol.html">sitemaps.org protocol</a>: one <code><url></code> entry per page with <code><loc></code>, <code><lastmod></code>, <code><changefreq></code>, and <code><priority></code>. It's a framework default — a directory literally named <code>sitemap.xml</code> under <code>novaconium/pages/</code>; <code>Router</code> only ever splits the request path on <code>/</code>, so that resolves the literal <code>/sitemap.xml</code> URL correctly, no special extension-routing involved.</p>
|
||||||
|
|
||||||
|
<p>Sidecar-only — no <code>index.twig</code>, since <code>Response::xml(...)</code> bypasses Twig entirely (see <a href="/admin/docs/sidecars">{{ icons.book() }}Sidecars</a>' JSON-only example for the same pattern). Built entirely from the <a href="/admin/docs/content-index">{{ icons.search() }}content index</a> — it's one of the three routes gated by <code>content_index_enabled</code> (default <code>false</code>), so it 404s exactly like a route that doesn't exist until that's turned on.</p>
|
||||||
|
|
||||||
|
<h2>Per-page <code>changefreq</code>/<code>priority</code></h2>
|
||||||
|
|
||||||
|
<p>Two Twig blocks, declared in <code>novaconium/pages/_layout/layout.twig</code> alongside the rest of the <a href="/admin/docs/seo">{{ icons.book() }}SEO</a> blocks — same override mechanism, just harvested by the content index rather than rendered into the page:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Block</th><th>Default</th><th>Sitemap element</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>changefreq</code></td><td><code>monthly</code></td><td><code><changefreq></code> — how often the page is expected to change (<code>always</code>/<code>hourly</code>/<code>daily</code>/<code>weekly</code>/<code>monthly</code>/<code>yearly</code>/<code>never</code>, per the protocol)</td></tr>
|
||||||
|
<tr><td><code>priority</code></td><td><code>0.5</code></td><td><code><priority></code> — relative priority against this site's own other pages, <code>0.0</code>–<code>1.0</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<pre><code class="nohighlight">{% verbatim %}{% block changefreq %}weekly{% endblock %}
|
||||||
|
{% block priority %}1.0{% endblock %}{% endverbatim %}</code></pre>
|
||||||
|
|
||||||
|
<p>A page that doesn't override either just gets the layout's defaults — nothing to set for most pages. Bump <code>priority</code> on a handful of pages that matter most (the homepage, key landing pages) rather than trying to rank every page precisely; search engines treat this as a hint, not a strict ordering.</p>
|
||||||
|
|
||||||
|
<h2>What's included, what isn't</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Only pages the content index actually indexes — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a> for the full crawl rules. In short: any page whose resolved <code>robots</code> block contains <code>noindex</code> (every <code>/admin/*</code> page already does) or that's listed in <a href="/admin/docs/drafts">{{ icons.lock() }}draft_routes</a> is skipped.</li>
|
||||||
|
<li><code>[param]</code>-wildcard routes (e.g. <code>blog/tag/[tag]</code>) aren't crawled at all — a wildcard's concrete values aren't knowable without a data source, so dynamic routes like individual tag pages don't get their own sitemap entries. This is a known V1 limitation, not an oversight.</li>
|
||||||
|
<li><code><lastmod></code> is the page's own source file's mtime (<code>content_pages.source_mtime</code>), not when the sitemap itself was last regenerated — it reflects when the content actually last changed.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>A known limitation: relative <code><loc></code> URLs</h2>
|
||||||
|
|
||||||
|
<p>The sitemaps.org protocol calls for <code><loc></code> to be a fully-qualified absolute URL. This framework's <code><loc></code> values are site-relative paths instead (e.g. <code>/about</code>, not <code>https://example.com/about</code>) — consistent with how <code>canonical</code>/<code>og:url</code> already work (see <a href="/admin/docs/seo">{{ icons.book() }}SEO</a>), since there's no site-wide base-URL config to build absolute URLs from. Most tooling tolerates this, but it isn't strictly spec-compliant, and a search engine or validator that enforces the letter of the protocol may reject entries. If that matters for a given deployment, override <code>novaconium/pages/sitemap.xml/index.php</code> with your own <code>App/pages/sitemap.xml/index.php</code> (same override-by-path mechanism as any other framework default) and prefix each <code><loc></code> with the site's real domain.</p>
|
||||||
|
|
||||||
|
<h2>How it's built</h2>
|
||||||
|
|
||||||
|
<p>Calls <code>ContentIndexer::ensureFresh()</code> (the lazy reindex-if-stale check — see <a href="/admin/docs/content-index">{{ icons.search() }}Content index</a>), queries <code>content_pages</code>, and builds the XML with plain string concatenation — no <code>DOMDocument</code>, this site's scale doesn't need one. Every value is still <code>htmlspecialchars(..., ENT_XML1)</code>-escaped, since <code>route</code>/<code>changefreq</code>/<code>priority</code> all ultimately come from page-author-controlled Twig blocks, not hardcoded constants.</p>
|
||||||
|
|
||||||
|
<h2>Submitting it to search engines</h2>
|
||||||
|
|
||||||
|
<p>This framework doesn't ship a <code>public/robots.txt</code> — add one yourself with a <code>Sitemap:</code> line pointing at wherever <code>/sitemap.xml</code> ends up being served, and/or submit the URL directly through each search engine's own webmaster tools (e.g. Google Search Console). Re-submission isn't needed on every change — crawlers revisit periodically on their own, and <code><lastmod></code> is the signal that tells them what's actually changed since their last visit.</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>
|
||||||
|
|
||||||
@@ -57,4 +57,14 @@ docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
|
|||||||
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p>
|
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p>
|
||||||
|
|
||||||
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
|
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
|
||||||
|
|
||||||
|
<h2>Syntax-highlighted code blocks follow the same toggle</h2>
|
||||||
|
|
||||||
|
<p><a href="/admin/docs/upgrading-highlightjs">highlight.js</a> colors PHP/Bash/HTML(XML) <code><pre><code></code> blocks site-wide, and swaps between two vendored themes — <strong>ir-black</strong> (dark) and <strong>github</strong> (light) — the same way the rest of the palette does, but as a separate mechanism from the Sass variables above, since these are two plain vendored CSS files, not compiled from this project's own <code>_colors.sass</code>. <code>novaconium/pages/_layout/syntax-highlight-init.twig</code> creates the correct theme <code><link></code> before paint (same FOUC-avoidance trick as <code>theme-init.twig</code>), and <code>novaconium/pages/_layout/syntax-highlight.twig</code> watches the <code>data-theme</code> attribute with a <code>MutationObserver</code> to swap it live when the toggle button is clicked — it doesn't need to know about the toggle button itself, only the attribute it already mutates.</p>
|
||||||
|
|
||||||
|
<p>A code block written in Twig syntax has no highlight.js grammar to match against — those are marked <code>class="nohighlight"</code> by hand at the source rather than highlighted incorrectly. See <code>AGENTS.md</code> for which files have them.</p>
|
||||||
|
|
||||||
|
<h2>Copy-to-clipboard on code blocks</h2>
|
||||||
|
|
||||||
|
<p><code>novaconium/pages/_layout/code-copy.twig</code>, included once from <code>_layout/layout.twig</code>'s footer, injects a hover-revealed copy button into every <code><pre></code> containing a <code><code></code> on the page — no per-page markup needed. It copies via <code>code.textContent</code> (not <code>innerHTML</code>), so HTML-entity-escaped samples like <code>&lt;h1&gt;</code> in the <a href="/admin/docs/seo">SEO</a> starter template come out as literal characters, not escaped markup. Uses the same event-delegation pattern as the theme toggle in <code>_layout/nav.twig</code>: one document-level click listener rather than a listener per button.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block title %}Third-party{% endblock %}
|
{% block title %}Third-party{% endblock %}
|
||||||
|
|
||||||
{% block description %}Vendored Twig and its license.{% endblock %}
|
{% block description %}Vendored Twig and highlight.js, and their licenses.{% endblock %}
|
||||||
|
|
||||||
{% block robots %}noindex, nofollow{% endblock %}
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
@@ -10,4 +10,6 @@
|
|||||||
<h1>Third-party</h1>
|
<h1>Third-party</h1>
|
||||||
|
|
||||||
<p><a href="https://twig.symfony.com/">Twig</a> is vendored in source form under <code>novaconium/vendor/twig/</code> (no Composer — see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a> for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at <code>novaconium/vendor/twig/LICENSE</code>.</p>
|
<p><a href="https://twig.symfony.com/">Twig</a> is vendored in source form under <code>novaconium/vendor/twig/</code> (no Composer — see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a> for how to upgrade it). It's BSD-3-Clause licensed; the full license text ships alongside it at <code>novaconium/vendor/twig/LICENSE</code>.</p>
|
||||||
|
|
||||||
|
<p><a href="https://highlightjs.org/">highlight.js</a> (v11.11.1) is vendored as its built distribution under <code>public/vendor/highlightjs/</code> — not <code>novaconium/vendor/</code> like Twig, since it's fetched by the browser and only <code>public/</code> is web-reachable (see <a href="/admin/docs/upgrading-highlightjs">Upgrading highlight.js</a>). Also BSD-3-Clause; the license text ships at <code>public/vendor/highlightjs/LICENSE</code>.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends 'admin/docs/_layout/layout.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Upgrading highlight.js{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}How to bump the vendored copy of highlight.js.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block docs_content %}
|
||||||
|
<h1>Upgrading vendored highlight.js</h1>
|
||||||
|
|
||||||
|
<p>Like Twig (see <a href="/admin/docs/upgrading-twig">Upgrading Twig</a>), highlight.js is vendored by hand — no Composer, no npm, no lockfile. Upgrading is a manual copy-and-verify process.</p>
|
||||||
|
|
||||||
|
<h2>The one thing that makes this different from every other vendored/framework file</h2>
|
||||||
|
|
||||||
|
<p>Everything else under <code>novaconium/</code> gets refreshed automatically when a project runs the <a href="/admin/docs/getting-started">"Updating the framework"</a> workflow (<code>rm -rf novaconium && cp -r <new-novaconium></code>). highlight.js is vendored under <code>public/vendor/highlightjs/</code> instead, because its files are fetched by the browser and only <code>public/</code> is the Apache document root — <code>novaconium/</code> isn't web-reachable at all. <code>public/</code> is project-owned and that update workflow never touches it. <strong>A future framework release that bumps the vendored highlight.js version will not update it on an existing project automatically</strong> — re-vendoring it is a separate, manual step, following this page, even after an otherwise-routine framework update.</p>
|
||||||
|
|
||||||
|
<h2>What's currently vendored</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Version: <strong>11.11.1</strong> (see the version comment at the top of <code>public/vendor/highlightjs/highlight.min.js</code> — that comment is always the source of truth, this doc can drift).</li>
|
||||||
|
<li>The root <code>build/highlight.min.js</code> bundle from <a href="https://github.com/highlightjs/cdn-release">highlightjs/cdn-release</a> — includes <code>php</code>, <code>bash</code>, <code>css</code>, <code>python</code>, <code>javascript</code>, and <code>xml</code> (covers HTML) out of the box.</li>
|
||||||
|
<li>Three separate per-language files under <code>public/vendor/highlightjs/languages/</code> — <code>yaml.min.js</code>, <code>json.min.js</code>, <code>ini.min.js</code> (covers <code>.env</code>-style key/value files too) — checked, not assumed, that these aren't part of the core bundle before vendoring them separately from <code>build/languages/</code> in the same <code>cdn-release</code> repo, at the same pinned tag.</li>
|
||||||
|
<li>Two themes: <code>public/vendor/highlightjs/styles/ir-black.min.css</code> (dark) and <code>styles/github.min.css</code> (light), swapped via the site's existing <code>data-theme</code> toggle — see <a href="/admin/docs/styling">Styling</a>.</li>
|
||||||
|
<li><code>public/vendor/highlightjs/LICENSE</code> (BSD-3-Clause) — covers the per-language files too, same license, same repo.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>All nine languages are why <code>hljs.configure({ languages: [...] })</code> in <code>novaconium/pages/_layout/syntax-highlight.twig</code> lists <code>['php', 'bash', 'xml', 'css', 'python', 'javascript', 'yaml', 'json', 'ini']</code> — see <a href="/blog/code-highlighting">the Code Highlighting post</a> for a worked example of each.</p>
|
||||||
|
|
||||||
|
<h2>How to upgrade</h2>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Pick a target version from the <a href="https://github.com/highlightjs/cdn-release/tags">cdn-release tags</a> — always a stable tag (e.g. <code>11.x.y</code>), never the <code>main</code> branch, which tracks an in-progress pre-release (this project's own vendored copy was pinned from tag <code>11.11.1</code> specifically for this reason, not <code>main</code>).</li>
|
||||||
|
<li>Download, from that same tag: <code>build/highlight.min.js</code>, <code>build/languages/yaml.min.js</code>, <code>build/languages/json.min.js</code>, <code>build/languages/ini.min.js</code>, <code>build/styles/ir-black.min.css</code>, <code>build/styles/github.min.css</code>, and <code>build/LICENSE</code>.</li>
|
||||||
|
<li>Replace the seven files under <code>public/vendor/highlightjs/</code> wholesale (<code>highlight.min.js</code>, the three files under <code>languages/</code>, the two under <code>styles/</code>, and <code>LICENSE</code>).</li>
|
||||||
|
<li>Confirm <code>php</code>, <code>bash</code>, <code>css</code>, <code>python</code>, and <code>javascript</code> are still present in the new core bundle (e.g. <code>grep -o '"php"' highlight.min.js</code>) — the root bundle's included-language set can change between releases; if one of these ever drops out, either vendor that language's individual file from <code>build/languages/</code> the same way <code>yaml</code>/<code>json</code>/<code>ini</code> already are, or adjust <code>hljs.configure({ languages: [...] })</code> to match what's actually available.</li>
|
||||||
|
<li>Run the app and check: code blocks still get colored on <a href="/blog/code-highlighting">the Code Highlighting post</a> (all nine languages, one worked example each) and the theme still swaps live with the dark/light toggle, and a <code>class="nohighlight"</code> Twig-syntax block (e.g. on <code>/admin/docs/seo</code>) still renders plain, uncolored. There's no automated test suite, so this is the real regression check.</li>
|
||||||
|
<li>Update the version note at the top of this page.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Adding a new highlighted language</h2>
|
||||||
|
|
||||||
|
<p>If a project adds code blocks in a language outside the nine above, vendor that language's file from <code>build/languages/<name>.min.js</code> in <code>cdn-release</code> (at the same pinned tag as everything else) into <code>public/vendor/highlightjs/languages/</code>, add a <code><script src="/vendor/highlightjs/languages/<name>.min.js"></script></code> tag after the core bundle (and after the other <code>languages/</code> scripts, order between them doesn't matter) in <code>novaconium/pages/_layout/syntax-highlight.twig</code>, and add the language's name to the <code>hljs.configure({ languages: [...] })</code> call there — each language file self-registers against the global <code>hljs</code> via its own <code>hljs.registerLanguage(...)</code> call once loaded, no other wiring needed.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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/password-hash">{{ icons.lock() }}Generate admin password hash</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/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,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\AdminAuth;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\Session;
|
||||||
|
|
||||||
|
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||||
|
// isn't handed $config, so it loads its own copy to read
|
||||||
|
// admin_auth_enabled before touching Lib\Db at all (same pattern as
|
||||||
|
// /search reading content_index_enabled).
|
||||||
|
$config = require __DIR__ . '/../../../config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin auth is off by default (depends on SQLite) — see
|
||||||
|
// /admin/docs/admin-auth. When it's off there's nothing to log in to:
|
||||||
|
// this route must 404 exactly like a page that doesn't exist, and never
|
||||||
|
// construct a Lib\Db connection (which would otherwise create
|
||||||
|
// data/novaconium.sqlite just because this file exists, even on a site
|
||||||
|
// that never opted in).
|
||||||
|
if (!$config['admin_auth_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the one /admin/* route bootstrap.php exempts from the admin
|
||||||
|
// gate — it has to be reachable logged-out, or the redirect here would
|
||||||
|
// loop. It serves registered users too, not just admins: Lib\Access (see
|
||||||
|
// /admin/docs/access-control) sends anyone hitting a gated page here.
|
||||||
|
|
||||||
|
// Where to go after a successful login. Lib\Access passes the gated
|
||||||
|
// page's path along as ?return=, carried through the form as a hidden
|
||||||
|
// field. Local paths only — must start with '/' but not '//' (a
|
||||||
|
// protocol-relative URL) and contain no backslash — so a crafted login
|
||||||
|
// link can never bounce someone to another site after they've typed
|
||||||
|
// their password here.
|
||||||
|
$sanitizeReturn = static function (?string $path): ?string {
|
||||||
|
if ($path === null || $path === '' || $path[0] !== '/') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, '//') || str_contains($path, '\\')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Where a login (or an already-logged-in visit) lands when there's no
|
||||||
|
// return path: admins on the admin panel, registered users on the
|
||||||
|
// homepage (there's nothing for them under /admin — it 404s). During the
|
||||||
|
// zero-users setup window, the admin panel — that's where the "create
|
||||||
|
// the first user" path starts.
|
||||||
|
$defaultTarget = static function (): string {
|
||||||
|
if (!AdminAuth::hasUsers()) {
|
||||||
|
return '/admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = AdminAuth::currentUser();
|
||||||
|
|
||||||
|
return $user !== null && $user['role'] === 'admin' ? '/admin' : '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$return = $sanitizeReturn(Input::post('return'));
|
||||||
|
$failureUrl = '/admin/login' . ($return !== null ? '?return=' . rawurlencode($return) : '');
|
||||||
|
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
Session::flash('login_error', 'Your session expired before submitting — please try again.');
|
||||||
|
|
||||||
|
return Response::redirect($failureUrl, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password read directly, not via Input::post() — cleaning would
|
||||||
|
// silently strip characters like < and > before password_verify(),
|
||||||
|
// failing a login the password actually matches. Same documented
|
||||||
|
// exception /admin/users makes when hashing — see
|
||||||
|
// /admin/docs/sidecars' "Form security" section.
|
||||||
|
$ok = AdminAuth::attempt(
|
||||||
|
(string) Input::post('username', ''),
|
||||||
|
(string) ($_POST['password'] ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ok) {
|
||||||
|
return Response::redirect($return ?? $defaultTarget(), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
Session::flash('login_error', 'Wrong username or password.');
|
||||||
|
|
||||||
|
return Response::redirect($failureUrl, 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already logged in (or the gate is open because no users exist yet) —
|
||||||
|
// the form is pointless, move along.
|
||||||
|
if (AdminAuth::isLoggedIn(true)) {
|
||||||
|
return Response::redirect($defaultTarget(), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'return' => $sanitizeReturn(Input::get('return')),
|
||||||
|
'error' => Session::getFlash('login_error'),
|
||||||
|
'notice' => Session::getFlash('admin_notice'),
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Log in{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Log in to your account.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h1 class="icon-heading">{{ icons.lock() }}Log in</h1>
|
||||||
|
|
||||||
|
{% if notice %}
|
||||||
|
<p><strong>{{ notice }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p><strong>{{ error }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/admin/login">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
{% if return %}<input type="hidden" name="return" value="{{ return }}">{% endif %}
|
||||||
|
<p>
|
||||||
|
<label for="username">Username</label><br>
|
||||||
|
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="password">Password</label><br>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
|
</p>
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\AdminAuth;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\Session;
|
||||||
|
|
||||||
|
// Same two-step config load as /admin/login — see that sidecar. 404 when
|
||||||
|
// admin auth is off so this route has zero footprint (no session cookie,
|
||||||
|
// no Lib\Db touch) on a site that never opted in.
|
||||||
|
$config = require __DIR__ . '/../../../config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$config['admin_auth_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real page now, replacing the hardcoded pre-router special case
|
||||||
|
// bootstrap.php needed back when logout meant tricking the browser into
|
||||||
|
// dropping cached Basic Auth credentials. Unlike then, this is a real
|
||||||
|
// server-side logout: the session's user id is gone afterwards, whatever
|
||||||
|
// the browser resends.
|
||||||
|
//
|
||||||
|
// POST-only, with a GET confirm form, same shape as /admin/clear-cache —
|
||||||
|
// NOT a logout-on-GET link. Sidecars must be side-effect-free on GET
|
||||||
|
// (ordinary HTTP hygiene, and the content-index crawl relies on it: it
|
||||||
|
// invokes every page's sidecar the way a real GET would, so a
|
||||||
|
// logout-on-GET here would silently end the crawling admin's own session
|
||||||
|
// the first time a lazy reindex rendered this page).
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
return Response::redirect('/admin/logout?error=security', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminAuth::logout();
|
||||||
|
Session::flash('admin_notice', 'You have been logged out.');
|
||||||
|
|
||||||
|
return Response::redirect('/admin/login', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'securityError' => Input::get('error') === 'security',
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Log out{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Log out of the admin area.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<h1 class="icon-heading">{{ icons.external_link() }}Log out</h1>
|
||||||
|
<p>Ends your admin session on the server — you'll need to log in again at <a href="/admin/login">/admin/login</a> to get back in.</p>
|
||||||
|
{% if securityError %}
|
||||||
|
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<button type="submit">Log out</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Response;
|
|
||||||
use Lib\Csrf;
|
|
||||||
use Lib\Input;
|
|
||||||
|
|
||||||
$hash = null;
|
|
||||||
$error = null;
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
if (!Csrf::verify(Input::post('csrf_token'))) {
|
|
||||||
return Response::redirect('/admin/password-hash?error=security');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read directly, not via Input::post() — that cleaning would silently
|
|
||||||
// strip characters like < and > before hashing, producing a hash that
|
|
||||||
// doesn't match what's actually typed later at the Basic Auth prompt
|
|
||||||
// (which does zero sanitization).
|
|
||||||
$password = $_POST['password'] ?? '';
|
|
||||||
|
|
||||||
if ($password === '') {
|
|
||||||
$error = 'Enter a password to hash.';
|
|
||||||
} elseif (strlen($password) < 8) {
|
|
||||||
$error = 'Use at least 8 characters.';
|
|
||||||
} else {
|
|
||||||
// Computed once per request and never stored/logged — the page
|
|
||||||
// that renders this is the only place it's ever seen.
|
|
||||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'hash' => $hash,
|
|
||||||
'error' => $error,
|
|
||||||
'securityError' => Input::get('error') === 'security',
|
|
||||||
'csrfField' => Csrf::fieldName(),
|
|
||||||
'csrfToken' => Csrf::token(),
|
|
||||||
];
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{% extends layout %}
|
|
||||||
|
|
||||||
{% import '_layout/icons.twig' as icons %}
|
|
||||||
|
|
||||||
{% block title %}Generate admin password hash{% endblock %}
|
|
||||||
{% block description %}Generate a password_hash() value for admin_password_hash without using the CLI.{% endblock %}
|
|
||||||
{% block robots %}noindex, nofollow{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<article>
|
|
||||||
<h1 class="icon-heading">{{ icons.lock() }}Generate admin password hash</h1>
|
|
||||||
|
|
||||||
<p>A browser-based alternative to <code>php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);"</code>. Enter a password, get back the hash <code>App/config.php</code> expects for <code>admin_password_hash</code> — see <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.book() }}Admin authentication</a>. Nothing typed here is stored, logged, or sent anywhere except computed once for this response.</p>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/password-hash">
|
|
||||||
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
|
||||||
<p>
|
|
||||||
<label for="password">Password</label><br>
|
|
||||||
<input type="password" id="password" name="password" autocomplete="new-password">
|
|
||||||
</p>
|
|
||||||
<button type="submit">Generate hash</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if securityError %}
|
|
||||||
<p><strong>Your session expired before submitting — please try again.</strong></p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<p><strong>{{ error }}</strong></p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if hash %}
|
|
||||||
<p>Add this to <code>App/config.php</code>:</p>
|
|
||||||
<pre><code><?php
|
|
||||||
// App/config.php
|
|
||||||
return [
|
|
||||||
'admin_username' => 'admin',
|
|
||||||
'admin_password_hash' => '{{ hash }}',
|
|
||||||
];</code></pre>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\AdminAuth;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\Mailer;
|
||||||
|
use Lib\Session;
|
||||||
|
use Lib\Validate;
|
||||||
|
|
||||||
|
// Same two-step config load as /admin/login — see that sidecar. 404 when
|
||||||
|
// admin auth is off, before anything here touches Lib\Db, so the feature
|
||||||
|
// has zero footprint on a site that never opted in.
|
||||||
|
$config = require __DIR__ . '/../../../config.php';
|
||||||
|
$appConfigFile = __DIR__ . '/../../../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$config = array_merge($config, require $appConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$config['admin_auth_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No auth check here — bootstrap.php's admin gate already covers this
|
||||||
|
// route like every other /admin/* page (admins only; registered users get
|
||||||
|
// a 404 there). While zero users exist that gate is deliberately open,
|
||||||
|
// which is exactly what makes creating the *first* user here possible.
|
||||||
|
|
||||||
|
// Disabling or demoting the last active admin would lock everyone out of
|
||||||
|
// /admin/* permanently (the zero-users setup window doesn't reopen — the
|
||||||
|
// table isn't empty), recoverable only via the CLI or editing the
|
||||||
|
// database by hand. Both actions below refuse when this returns 1 and
|
||||||
|
// the target is an active admin.
|
||||||
|
$activeAdminCount = static fn (): int => (int) Db::query(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
|
||||||
|
)->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 (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
Session::flash('users_error', 'Your session expired before submitting — please try again.');
|
||||||
|
|
||||||
|
return Response::redirect('/admin/users', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = Input::post('action', '');
|
||||||
|
|
||||||
|
if ($action === 'create') {
|
||||||
|
$username = trim((string) Input::post('username', ''));
|
||||||
|
// Validate::isEmail() returns the normalized (trimmed, lowercased)
|
||||||
|
// address, or false — stored normalized so the future
|
||||||
|
// email-verification flow (see novaconium/ISSUES.md) can match
|
||||||
|
// addresses case-insensitively for free.
|
||||||
|
$email = Validate::isEmail((string) Input::post('email', ''));
|
||||||
|
$group = trim((string) Input::post('group', ''));
|
||||||
|
// Passwords read directly, not via Input::post() — cleaning would
|
||||||
|
// silently strip characters like < and > before hashing, producing
|
||||||
|
// a hash that doesn't match what's actually typed at /admin/login
|
||||||
|
// later. See /admin/docs/sidecars' "Form security" section.
|
||||||
|
$password = (string) ($_POST['password'] ?? '');
|
||||||
|
|
||||||
|
$exists = $username !== ''
|
||||||
|
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)', [$username])->fetchColumn();
|
||||||
|
$emailExists = $email !== false
|
||||||
|
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)', [$email])->fetchColumn();
|
||||||
|
|
||||||
|
if ($username === '' || strlen($username) > 64) {
|
||||||
|
Session::flash('users_error', 'Enter a username (64 characters max).');
|
||||||
|
} elseif ($email === false) {
|
||||||
|
Session::flash('users_error', 'Enter a valid email address.');
|
||||||
|
} elseif ($emailExists) {
|
||||||
|
Session::flash('users_error', 'That email address is already in use.');
|
||||||
|
} elseif (strlen($group) > 64) {
|
||||||
|
Session::flash('users_error', 'Group names are 64 characters max.');
|
||||||
|
} elseif ($exists) {
|
||||||
|
Session::flash('users_error', 'That username is already taken.');
|
||||||
|
} elseif (strlen($password) < 8) {
|
||||||
|
Session::flash('users_error', 'Use a password of at least 8 characters.');
|
||||||
|
} else {
|
||||||
|
// The first user ever created is the admin; everyone after is
|
||||||
|
// a registered user (optionally in a group) — Lib\Access rules
|
||||||
|
// decide what content they can see, and /admin/* 404s for
|
||||||
|
// them. See /admin/docs/admin-auth and
|
||||||
|
// /admin/docs/access-control.
|
||||||
|
$wasFirstUser = !AdminAuth::hasUsers();
|
||||||
|
$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(
|
||||||
|
'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, $now, $wasFirstUser ? $now : null]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Creating the first user is what closes the open setup gate —
|
||||||
|
// log its creator in as it, or their very next request would
|
||||||
|
// bounce them to the login form they were just typing into.
|
||||||
|
if ($wasFirstUser) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($action === 'disable' || $action === 'enable') {
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('users_error', 'No such user.');
|
||||||
|
} elseif ($action === 'disable' && (int) $target['is_disabled'] === 0 && $target['role'] === 'admin' && $activeAdminCount() <= 1) {
|
||||||
|
Session::flash('users_error', 'Cannot disable the last active admin — that would lock everyone out of /admin.');
|
||||||
|
} else {
|
||||||
|
Db::query('UPDATE users SET is_disabled = ? WHERE id = ?', [$action === 'disable' ? 1 : 0, $id]);
|
||||||
|
Session::flash('users_notice', sprintf("User \u{201C}%s\u{201D} %sd.", $target['username'], $action));
|
||||||
|
}
|
||||||
|
} elseif ($action === 'role') {
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$role = Input::post('role', '');
|
||||||
|
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('users_error', 'No such user.');
|
||||||
|
} elseif (!in_array($role, ['admin', 'registered'], true)) {
|
||||||
|
Session::flash('users_error', 'No such role.');
|
||||||
|
} elseif ($role === 'registered' && $target['role'] === 'admin' && (int) $target['is_disabled'] === 0 && $activeAdminCount() <= 1) {
|
||||||
|
Session::flash('users_error', 'Cannot demote the last active admin — that would lock everyone out of /admin.');
|
||||||
|
} else {
|
||||||
|
Db::query('UPDATE users SET role = ? WHERE id = ?', [$role, $id]);
|
||||||
|
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} is now {$role}.");
|
||||||
|
}
|
||||||
|
} elseif ($action === 'delete') {
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$target = Db::query('SELECT id, username, role, is_disabled FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('users_error', 'No such user.');
|
||||||
|
} elseif ((int) $target['is_disabled'] === 0 && $target['role'] === 'admin' && $activeAdminCount() <= 1) {
|
||||||
|
Session::flash('users_error', 'Cannot delete the last active admin — that would lock everyone out of /admin.');
|
||||||
|
} else {
|
||||||
|
// A hard delete, not a soft one — is_disabled already covers
|
||||||
|
// "keep the account but shut it out", so delete is for
|
||||||
|
// accounts that shouldn't exist at all. Any live session dies
|
||||||
|
// 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]);
|
||||||
|
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
||||||
|
}
|
||||||
|
} elseif ($action === 'email') {
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$email = Validate::isEmail((string) Input::post('email', ''));
|
||||||
|
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$emailExists = $email !== false
|
||||||
|
&& (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users WHERE email = ? AND id != ?)', [$email, $id])->fetchColumn();
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('users_error', 'No such user.');
|
||||||
|
} elseif ($email === false) {
|
||||||
|
Session::flash('users_error', 'Enter a valid email address.');
|
||||||
|
} elseif ($emailExists) {
|
||||||
|
Session::flash('users_error', 'That email address is already in use.');
|
||||||
|
} 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]);
|
||||||
|
$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') {
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$group = trim((string) Input::post('group', ''));
|
||||||
|
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('users_error', 'No such user.');
|
||||||
|
} elseif (strlen($group) > 64) {
|
||||||
|
Session::flash('users_error', 'Group names are 64 characters max.');
|
||||||
|
} else {
|
||||||
|
Db::query('UPDATE users SET user_group = ? WHERE id = ?', [$group, $id]);
|
||||||
|
Session::flash('users_notice', $group === ''
|
||||||
|
? "User \u{201C}{$target['username']}\u{201D} removed from their group."
|
||||||
|
: "User \u{201C}{$target['username']}\u{201D} assigned to group \u{201C}{$group}\u{201D}.");
|
||||||
|
}
|
||||||
|
} elseif ($action === 'password') {
|
||||||
|
$id = (int) Input::post('id', '0');
|
||||||
|
$password = (string) ($_POST['password'] ?? '');
|
||||||
|
$target = Db::query('SELECT id, username FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($target === false) {
|
||||||
|
Session::flash('users_error', 'No such user.');
|
||||||
|
} elseif (strlen($password) < 8) {
|
||||||
|
Session::flash('users_error', 'Use a password of at least 8 characters.');
|
||||||
|
} else {
|
||||||
|
Db::query('UPDATE users SET password_hash = ? WHERE id = ?', [password_hash($password, PASSWORD_DEFAULT), $id]);
|
||||||
|
Session::flash('users_notice', "Password changed for \u{201C}{$target['username']}\u{201D}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::redirect('/admin/users', 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'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,
|
||||||
|
'notice' => Session::getFlash('users_notice'),
|
||||||
|
'error' => Session::getFlash('users_error'),
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Users{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}Create, disable, and manage user accounts, roles, and groups.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}noindex, nofollow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<article>
|
||||||
|
<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. 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 %}
|
||||||
|
<p><strong>{{ notice }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p><strong>{{ error }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if users is empty %}
|
||||||
|
<p><strong>No users exist yet, so the admin area is open to anyone who can reach it.</strong> Create the first user below to close the gate — it becomes the admin account, and you'll be logged in as it automatically.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Verified</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.username }}{% if user.id == currentUserId %} <em>(you)</em>{% endif %}</td>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>{{ user.role == 'admin' ? 'Admin' : 'Registered' }}</td>
|
||||||
|
<td>{{ user.user_group ?: '—' }}</td>
|
||||||
|
<td>{{ user.created_at }}</td>
|
||||||
|
<td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td>
|
||||||
|
<td>{{ user.verified_at ? 'Verified' : 'Unverified' }}</td>
|
||||||
|
<td>
|
||||||
|
<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="{{ user.is_disabled ? 'enable' : 'disable' }}">
|
||||||
|
<button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button>
|
||||||
|
</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">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="id" value="{{ user.id }}">
|
||||||
|
<input type="hidden" name="action" value="role">
|
||||||
|
<input type="hidden" name="role" value="{{ user.role == 'admin' ? 'registered' : 'admin' }}">
|
||||||
|
<button type="submit">{{ user.role == 'admin' ? 'Make registered' : 'Make admin' }}</button>
|
||||||
|
</form>
|
||||||
|
<details>
|
||||||
|
<summary>Change email</summary>
|
||||||
|
<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="email">
|
||||||
|
<p>
|
||||||
|
<label for="email-{{ user.id }}">New email address</label><br>
|
||||||
|
<input type="email" id="email-{{ user.id }}" name="email" value="{{ user.email }}" required>
|
||||||
|
</p>
|
||||||
|
<button type="submit">Change email</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Change group</summary>
|
||||||
|
<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="group">
|
||||||
|
<p>
|
||||||
|
<label for="group-{{ user.id }}">Group (empty for none)</label><br>
|
||||||
|
<input type="text" id="group-{{ user.id }}" name="group" value="{{ user.user_group }}">
|
||||||
|
</p>
|
||||||
|
<button type="submit">Change group</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Change password</summary>
|
||||||
|
<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="password">
|
||||||
|
<p>
|
||||||
|
<label for="password-{{ user.id }}">New password</label><br>
|
||||||
|
<input type="password" id="password-{{ user.id }}" name="password" autocomplete="new-password" required>
|
||||||
|
</p>
|
||||||
|
<button type="submit">Change password</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Delete</summary>
|
||||||
|
<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="delete">
|
||||||
|
<p>Permanently removes <strong>{{ user.username }}</strong> — there's no undo. To shut an account out but keep it, use Disable instead.</p>
|
||||||
|
<button type="submit">Delete {{ user.username }}</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Create a user</h2>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/users">
|
||||||
|
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
<p>
|
||||||
|
<label for="username">Username</label><br>
|
||||||
|
<input type="text" id="username" name="username" autocomplete="off" required>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="email">Email address</label><br>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="off" required>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="password">Password (at least 8 characters)</label><br>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="new-password" required>
|
||||||
|
</p>
|
||||||
|
{% if users is not empty %}
|
||||||
|
<p>
|
||||||
|
<label for="group">Group (optional — e.g. <code>members</code>, matched by <code>Access::require('group:members')</code>)</label><br>
|
||||||
|
<input type="text" id="group" name="group" autocomplete="off">
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit">Create user</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// /search — a framework default (novaconium/pages/, not App/pages/) since
|
||||||
|
// full-text search over the whole site is generic machinery, not project
|
||||||
|
// content. Has both this sidecar and an index.twig, unlike sitemap.xml —
|
||||||
|
// it renders a real HTML page (a form plus results), not a bypass-Twig
|
||||||
|
// Response.
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Input;
|
||||||
|
|
||||||
|
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||||
|
// isn't handed $config, so it loads its own copy to read
|
||||||
|
// content_index_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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content index is off by default (depends on SQLite) — see
|
||||||
|
// /admin/docs/content-index. When it's off, this route must 404 exactly
|
||||||
|
// like a page that doesn't exist, and never construct a Lib\Db connection
|
||||||
|
// (which would otherwise create data/novaconium.sqlite just because this
|
||||||
|
// file exists, even on a site that never opted in).
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input::get(), not $_GET directly — see /admin/docs/sidecars' "Form
|
||||||
|
// security" section. Only Lib\Input's HTML/script-injection cleaning
|
||||||
|
// matters here (this value never reaches SQL unparameterized either way,
|
||||||
|
// see the FTS5 escaping note below).
|
||||||
|
$query = trim((string) Input::get('q', ''));
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
// Bare /search (no ?q=) just shows the empty form — skip the query and
|
||||||
|
// the reindex-freshness check entirely, so landing on the page cold costs
|
||||||
|
// nothing beyond the normal page render.
|
||||||
|
if ($query !== '') {
|
||||||
|
// Lazy reindex-if-stale — a no-op on most requests (only actually
|
||||||
|
// reindexes when a page's source file changed since the last index).
|
||||||
|
// Deliberately guarded by $query !== '' rather than called
|
||||||
|
// unconditionally at the top of the file: ContentIndexer's own crawl
|
||||||
|
// renders every real page, including this one, so /search visiting
|
||||||
|
// itself with an empty query during a crawl must NOT trigger another
|
||||||
|
// reindex — ContentIndexer also has its own reentrancy guard for this
|
||||||
|
// (see its docblock), but not paying the freshness-check cost on
|
||||||
|
// every bare page load is a second, independent reason this call sits
|
||||||
|
// inside the if.
|
||||||
|
ContentIndexer::ensureFresh();
|
||||||
|
|
||||||
|
// Parameter binding prevents SQL injection, but the bound value is
|
||||||
|
// still parsed as its own FTS5 query-language expression, not a plain
|
||||||
|
// string — a literal " or an FTS operator in $query could otherwise
|
||||||
|
// throw a syntax error or search for something unintended. Wrapping it
|
||||||
|
// as a quoted phrase (doubling any embedded ") makes the whole query
|
||||||
|
// an FTS5 phrase-match literal, neutralizing that syntax entirely.
|
||||||
|
// Verified against a literal ", "*", "OR", and a "'; DROP TABLE ..."
|
||||||
|
// attempt — all return normal (possibly empty) results, no fatal, no
|
||||||
|
// effect on the database.
|
||||||
|
$ftsQuery = '"' . str_replace('"', '""', $query) . '"';
|
||||||
|
|
||||||
|
// content_search is the FTS5 virtual table ContentIndexer::reindex()
|
||||||
|
// populates with route/title/stripped-HTML body — "rank" is an FTS5
|
||||||
|
// built-in column (not one we define) giving relevance ordering, most
|
||||||
|
// relevant first. LIMIT 50 is just a sanity cap, not real pagination.
|
||||||
|
$results = Db::query(
|
||||||
|
'SELECT route, title FROM content_search WHERE content_search MATCH ? ORDER BY rank LIMIT 50',
|
||||||
|
[$ftsQuery]
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// A second query rather than joining content_pages into the FTS5
|
||||||
|
// query directly — content_search is a virtual table, and mixing a
|
||||||
|
// real table JOIN into an FTS5 MATCH query is more fragile than doing
|
||||||
|
// the description lookup separately. IN (...) with one placeholder
|
||||||
|
// per route, built from the exact route set the first query returned
|
||||||
|
// — never string-interpolating $query or user input into this SQL,
|
||||||
|
// only the already-fetched, already-trusted route values.
|
||||||
|
if ($results !== []) {
|
||||||
|
$routes = array_column($results, 'route');
|
||||||
|
$placeholders = implode(',', array_fill(0, count($routes), '?'));
|
||||||
|
$descriptions = Db::query(
|
||||||
|
"SELECT route, description FROM content_pages WHERE route IN ({$placeholders})",
|
||||||
|
$routes
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
foreach ($results as &$result) {
|
||||||
|
$result['description'] = $descriptions[$result['route']] ?? '';
|
||||||
|
}
|
||||||
|
unset($result); // break the foreach-by-reference alias, standard PHP gotcha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// $results stays [] (not an error) for a blank query or one with zero
|
||||||
|
// matches — the twig template only distinguishes those two cases by
|
||||||
|
// checking $query itself, not by any error flag.
|
||||||
|
return [
|
||||||
|
'query' => $query,
|
||||||
|
'results' => $results,
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Search{% endblock %}
|
||||||
|
{% block description %}Search this site.{% endblock %}
|
||||||
|
{% block robots %}noindex, follow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="icon-heading">{{ icons.search() }}Search</h1>
|
||||||
|
|
||||||
|
<form method="get" action="/search">
|
||||||
|
<input type="search" name="q" value="{{ query }}" placeholder="Search…" aria-label="Search">
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if query != '' %}
|
||||||
|
{% if results|length > 0 %}
|
||||||
|
<ul class="post-list">
|
||||||
|
{% for result in results %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ result.route }}">{{ result.title }}</a>
|
||||||
|
{% if result.description %}<p>{{ result.description }}</p>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>No results for “{{ query }}”.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// /sitemap.xml — a directory literally named "sitemap.xml" under
|
||||||
|
// novaconium/pages/, a framework default (like /admin/*), since sitemap
|
||||||
|
// generation is generic machinery, not project content. Router only ever
|
||||||
|
// splits the request path on "/", so a directory named "sitemap.xml"
|
||||||
|
// resolves the literal /sitemap.xml URL correctly — there's no special
|
||||||
|
// extension-routing mechanism involved. Sidecar-only: no index.twig next
|
||||||
|
// to this file, since Response::xml() bypasses Twig entirely (see
|
||||||
|
// /admin/docs/sidecars' JSON-only example for the same pattern).
|
||||||
|
|
||||||
|
use App\ContentIndexer;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Db;
|
||||||
|
|
||||||
|
// Same two-step config load bootstrap.php/bin scripts use — this sidecar
|
||||||
|
// isn't handed $config, so it loads its own copy to read
|
||||||
|
// content_index_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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content index is off by default (depends on SQLite) — see
|
||||||
|
// /admin/docs/content-index. When it's off, this route must 404 exactly
|
||||||
|
// like a page that doesn't exist, and never construct a Lib\Db connection
|
||||||
|
// (which would otherwise create data/novaconium.sqlite just because this
|
||||||
|
// file exists, even on a site that never opted in).
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
return Response::html('404 Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy reindex-if-stale — a no-op on most requests (only actually
|
||||||
|
// reindexes when a page's source file changed since the last index).
|
||||||
|
// ContentIndexer's crawl itself never renders this route: a sidecar
|
||||||
|
// returning a Response (this one always does) has nothing meaningful to
|
||||||
|
// index, so Renderer::renderForIndex() returns null for it and
|
||||||
|
// ContentIndexer skips it — no reentrancy concern here, unlike /search
|
||||||
|
// (see that file's comments), which does get crawled since it renders
|
||||||
|
// normally on an empty query.
|
||||||
|
ContentIndexer::ensureFresh();
|
||||||
|
|
||||||
|
// One row per indexed page — populated entirely by ContentIndexer::
|
||||||
|
// reindex(), never written to directly here. changefreq/priority come
|
||||||
|
// from each page's own {% block changefreq %}/{% block priority %}
|
||||||
|
// (defaults: monthly / 0.5 — see novaconium/pages/_layout/layout.twig),
|
||||||
|
// source_mtime is that page's source file's own mtime, used below as
|
||||||
|
// <lastmod> so it reflects when the content actually last changed, not
|
||||||
|
// when the index was last rebuilt.
|
||||||
|
$pages = Db::query('SELECT route, changefreq, priority, source_mtime FROM content_pages ORDER BY route')
|
||||||
|
->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Plain string concatenation rather than DOMDocument — this site's scale
|
||||||
|
// doesn't need a real XML builder, but every value is still
|
||||||
|
// htmlspecialchars()-escaped (ENT_XML1, not the HTML default) since
|
||||||
|
// route/changefreq/priority all ultimately come from page-author-controlled
|
||||||
|
// Twig blocks, not hardcoded constants.
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
|
||||||
|
|
||||||
|
foreach ($pages as $page) {
|
||||||
|
$xml .= ' <url>' . "\n";
|
||||||
|
$xml .= ' <loc>' . htmlspecialchars($page['route'], ENT_XML1) . '</loc>' . "\n";
|
||||||
|
$xml .= ' <lastmod>' . gmdate('Y-m-d', (int) $page['source_mtime']) . '</lastmod>' . "\n";
|
||||||
|
$xml .= ' <changefreq>' . htmlspecialchars($page['changefreq'], ENT_XML1) . '</changefreq>' . "\n";
|
||||||
|
$xml .= ' <priority>' . htmlspecialchars($page['priority'], ENT_XML1) . '</priority>' . "\n";
|
||||||
|
$xml .= ' </url>' . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml .= '</urlset>' . "\n";
|
||||||
|
|
||||||
|
return Response::xml($xml);
|
||||||
@@ -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 %}
|
||||||
@@ -131,6 +131,7 @@ code
|
|||||||
font-size: 0.9em
|
font-size: 0.9em
|
||||||
|
|
||||||
pre
|
pre
|
||||||
|
position: relative
|
||||||
background: var(--surface)
|
background: var(--surface)
|
||||||
border: 1px solid var(--border-color)
|
border: 1px solid var(--border-color)
|
||||||
border-radius: 6px
|
border-radius: 6px
|
||||||
@@ -142,6 +143,48 @@ pre
|
|||||||
padding: 0
|
padding: 0
|
||||||
color: var(--text-color)
|
color: var(--text-color)
|
||||||
|
|
||||||
|
&:hover .copy-code-button, .copy-code-button:focus-visible
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
.copy-code-button
|
||||||
|
position: absolute
|
||||||
|
top: 0.5rem
|
||||||
|
right: 0.5rem
|
||||||
|
display: inline-flex
|
||||||
|
align-items: center
|
||||||
|
gap: 0.3rem
|
||||||
|
background: var(--bg)
|
||||||
|
border: 1px solid var(--border-color)
|
||||||
|
border-radius: 4px
|
||||||
|
color: var(--muted-color)
|
||||||
|
font-size: 0.75rem
|
||||||
|
padding: 0.25rem 0.5rem
|
||||||
|
cursor: pointer
|
||||||
|
opacity: 0
|
||||||
|
transition: opacity 0.15s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: var(--bg)
|
||||||
|
color: var(--text-color)
|
||||||
|
border-color: var(--accent)
|
||||||
|
|
||||||
|
&.copied
|
||||||
|
color: var(--accent)
|
||||||
|
border-color: var(--accent)
|
||||||
|
|
||||||
|
// Vendored highlight.js themes (public/vendor/highlightjs/, see
|
||||||
|
// /admin/docs/upgrading-highlightjs) each set their own background and
|
||||||
|
// pre code.hljs padding — overridden here so a highlighted block blends
|
||||||
|
// into the existing pre box above instead of introducing a second,
|
||||||
|
// mismatched background/padding. Loaded after the theme stylesheet (see
|
||||||
|
// novaconium/pages/_layout/syntax-highlight-init.twig), so this wins by
|
||||||
|
// source order alone, no !important needed.
|
||||||
|
.hljs
|
||||||
|
background: transparent
|
||||||
|
|
||||||
|
pre code.hljs
|
||||||
|
padding: 0
|
||||||
|
|
||||||
hr
|
hr
|
||||||
border: none
|
border: none
|
||||||
border-top: 1px solid var(--border-color)
|
border-top: 1px solid var(--border-color)
|
||||||
@@ -156,6 +199,17 @@ label
|
|||||||
small
|
small
|
||||||
color: var(--muted-color)
|
color: var(--muted-color)
|
||||||
|
|
||||||
|
footer
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: space-between
|
||||||
|
gap: 1rem
|
||||||
|
flex-wrap: wrap
|
||||||
|
|
||||||
|
.footer-menu
|
||||||
|
display: flex
|
||||||
|
gap: 1rem
|
||||||
|
|
||||||
// Honeypot field for spam prevention (see App/pages/contact/index.php).
|
// Honeypot field for spam prevention (see App/pages/contact/index.php).
|
||||||
// Off-screen positioning rather than display:none/visibility:hidden,
|
// Off-screen positioning rather than display:none/visibility:hidden,
|
||||||
// since some spam bots specifically skip fields hidden that way.
|
// since some spam bots specifically skip fields hidden that way.
|
||||||
@@ -332,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
|
||||||
|
|
||||||
|
|||||||
+178
-38
@@ -2,62 +2,202 @@
|
|||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
|
|
||||||
|
use Lib\Db;
|
||||||
|
use Lib\Session;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable HTTP Basic Auth gate for /admin/*. A single check, called once
|
* Session-based login backed by the `users` table
|
||||||
* from bootstrap.php for any route under admin/, so protecting a new admin
|
* (novaconium/migrations/0002_create_users.sql) on Lib\Db's default
|
||||||
* page (clear-cache today, anything future) needs no per-page wiring.
|
* connection. This replaced the single-user HTTP Basic Auth stopgap that
|
||||||
|
* originally shipped under this same class name — same call sites
|
||||||
|
* (bootstrap.php's admin gate and draft gate), new mechanism, per the
|
||||||
|
* "replace it, don't layer on top of it" plan in novaconium/ISSUES.md.
|
||||||
*
|
*
|
||||||
* Deliberately not a full user system — no sessions, no user table, no
|
* Two roles: 'admin' (full /admin/* access, sees drafts, passes every
|
||||||
* password reset. It's a stopgap until proper multi-user admin login
|
* Lib\Access rule) and 'registered' (can log in, and sees whatever
|
||||||
* (see novaconium/ISSUES.md) lands; that feature will replace this, not extend it.
|
* content Lib\Access grants their account or their group — see
|
||||||
|
* /admin/docs/access-control — but /admin/* 404s for them). The first
|
||||||
|
* user ever created is the admin; users created after that are
|
||||||
|
* registered, each optionally assigned one group (users.user_group, a
|
||||||
|
* plain text label — no groups table).
|
||||||
*
|
*
|
||||||
* Note: Lib\Csrf (unrelated to login state here) does start a native PHP
|
* Gated by config['admin_auth_enabled'] (default false — same off-by-
|
||||||
* session when a form calls it — so "no sessions" above is specifically
|
* default posture as the content index, and for the same reason: this
|
||||||
* about this class's own login check, not a framework-wide guarantee.
|
* depends on SQLite, a real dependency plenty of sites won't want).
|
||||||
|
* Every method that touches Lib\Db is only reachable when the flag is
|
||||||
|
* true, so a site that never enables it never gets a data/ database file
|
||||||
|
* created just because this class exists.
|
||||||
|
*
|
||||||
|
* Bootstrap posture while enabled but with zero users yet: open access,
|
||||||
|
* so the first user can be created at /admin/users (or with
|
||||||
|
* `php novaconium/bin/create-admin-user.php`) — the same window the old
|
||||||
|
* Basic Auth had between deciding to enable it and pasting a hash into
|
||||||
|
* App/config.php. Enabling the flag alone protects nothing; creating the
|
||||||
|
* first user is what closes the gate.
|
||||||
*/
|
*/
|
||||||
final class AdminAuth
|
final class AdminAuth
|
||||||
{
|
{
|
||||||
|
private const SESSION_KEY = '_admin_user_id';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exits with a 401 challenge if the request isn't authenticated.
|
* Per-request memo for currentUser() — static state never survives
|
||||||
* A no-op (auth disabled) when $passwordHash is empty.
|
* across requests (same guarantee Lib\Session's flash swap relies on),
|
||||||
|
* so this only saves repeat lookups within one request.
|
||||||
|
*
|
||||||
|
* @var array<string, mixed>|null
|
||||||
*/
|
*/
|
||||||
public static function requireLogin(string $username, string $passwordHash): void
|
private static ?array $currentUser = null;
|
||||||
|
|
||||||
|
private static bool $currentUserLoaded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects to /admin/login and exits if nobody is logged in. A no-op
|
||||||
|
* (open access) when $enabled is false, or while no users exist yet
|
||||||
|
* (see the class docblock). bootstrap.php calls this for every
|
||||||
|
* /admin/* route except admin/login itself — which must stay
|
||||||
|
* reachable logged-out, or the redirect would loop — and then
|
||||||
|
* separately 404s admin routes for logged-in non-admins (a registered
|
||||||
|
* user is *authenticated*, so bouncing them back to the login form
|
||||||
|
* would be a lie; what they lack is the admin role).
|
||||||
|
*/
|
||||||
|
public static function requireLogin(bool $enabled): void
|
||||||
{
|
{
|
||||||
if ($passwordHash === '') {
|
if (self::isLoggedIn($enabled)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$providedUser = $_SERVER['PHP_AUTH_USER'] ?? null;
|
http_response_code(303);
|
||||||
$providedPass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
header('Location: /admin/login');
|
||||||
|
|
||||||
if (
|
|
||||||
$providedUser === $username
|
|
||||||
&& $providedPass !== null
|
|
||||||
&& password_verify($providedPass, $passwordHash)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
header('WWW-Authenticate: Basic realm="Admin"');
|
|
||||||
http_response_code(401);
|
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
|
||||||
echo "401 Unauthorized\n";
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP Basic Auth has no real server-side "log out" — the browser just
|
* Whether the request has any logged-in user at all, admin or
|
||||||
* keeps resending the cached credentials. The standard workaround: always
|
* registered. Returns true (open access) when $enabled is false or no
|
||||||
* issue a fresh 401 challenge here regardless of what was sent, which
|
* users exist yet.
|
||||||
* makes the browser discard the credentials it had cached for this realm
|
*/
|
||||||
* and prompt again next time /admin is visited.
|
public static function isLoggedIn(bool $enabled): bool
|
||||||
|
{
|
||||||
|
if (!$enabled || !self::hasUsers()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::currentUser() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the request is by a logged-in admin, with no response side
|
||||||
|
* effects — the check behind /admin/* and the draft-page gate in
|
||||||
|
* novaconium/bootstrap.php (see /admin/docs/drafts), which reacts to
|
||||||
|
* failure with a plain 404, not a login redirect, so an
|
||||||
|
* unauthenticated visitor can't tell a draft exists at all. Returns
|
||||||
|
* true (open access) when $enabled is false or no users exist yet, 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.
|
||||||
|
*/
|
||||||
|
public static function isAdmin(bool $enabled): bool
|
||||||
|
{
|
||||||
|
if (!$enabled || !self::hasUsers()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = self::currentUser();
|
||||||
|
|
||||||
|
return $user !== null && $user['role'] === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a username/password against the users table and, on
|
||||||
|
* success, logs the session in. Disabled and unverified users fail
|
||||||
|
* exactly like a wrong password — the response never distinguishes
|
||||||
|
* "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
|
||||||
|
{
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Db::query(
|
||||||
|
'SELECT id, username, email, password_hash, role, user_group, is_disabled, verified_at FROM users WHERE username = ?',
|
||||||
|
[$username]
|
||||||
|
)->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($user === false || (int) $user['is_disabled'] === 1 || $user['verified_at'] === null || !password_verify($password, $user['password_hash'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh session id on login so a pre-login session id (which an
|
||||||
|
// attacker could have planted or observed) never becomes an
|
||||||
|
// authenticated one — see Session::regenerate().
|
||||||
|
Session::regenerate();
|
||||||
|
Session::set(self::SESSION_KEY, (int) $user['id']);
|
||||||
|
|
||||||
|
unset($user['password_hash']);
|
||||||
|
self::$currentUser = $user;
|
||||||
|
self::$currentUserLoaded = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real server-side logout (unlike the Basic Auth predecessor's
|
||||||
|
* 401-challenge trick): drop the logged-in user id from the session
|
||||||
|
* and regenerate the session id.
|
||||||
*/
|
*/
|
||||||
public static function logout(): void
|
public static function logout(): void
|
||||||
{
|
{
|
||||||
header('WWW-Authenticate: Basic realm="Admin"');
|
Session::remove(self::SESSION_KEY);
|
||||||
http_response_code(401);
|
Session::regenerate();
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
|
||||||
echo '<p>You have been logged out. <a href="/">Return home</a>.</p>';
|
self::$currentUser = null;
|
||||||
exit;
|
self::$currentUserLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The logged-in user's row (id/username/email/role/user_group/
|
||||||
|
* is_disabled — never the password hash), or null. Re-checked against
|
||||||
|
* the users table on every request, not just at login, so disabling,
|
||||||
|
* deleting, or un-verifying (e.g. an email change reset — see
|
||||||
|
* /admin/docs/admin-auth) a user locks their existing session out on
|
||||||
|
* its very next request — no "still logged in until the session
|
||||||
|
* expires" window.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public static function currentUser(): ?array
|
||||||
|
{
|
||||||
|
if (self::$currentUserLoaded) {
|
||||||
|
return self::$currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$currentUserLoaded = true;
|
||||||
|
|
||||||
|
$userId = Session::get(self::SESSION_KEY);
|
||||||
|
if (!is_int($userId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Db::query(
|
||||||
|
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0 AND verified_at IS NOT NULL',
|
||||||
|
[$userId]
|
||||||
|
)->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
self::$currentUser = $user === false ? null : $user;
|
||||||
|
|
||||||
|
return self::$currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasUsers(): bool
|
||||||
|
{
|
||||||
|
return (bool) Db::query('SELECT EXISTS(SELECT 1 FROM users)')->fetchColumn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use Lib\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crawls every routable page, renders it, and indexes it into SQLite —
|
||||||
|
* the shared mechanism behind /sitemap.xml, /search, and blog tag browsing
|
||||||
|
* (see /admin/docs/content-index). Content itself stays in files (Twig
|
||||||
|
* pages); this only extracts and stores metadata (title/description/
|
||||||
|
* keywords/tags/changefreq/priority, each pulled via Renderer::
|
||||||
|
* renderForIndex()'s Twig renderBlock() calls) plus a stripped-HTML plain-
|
||||||
|
* text copy of the body for full-text search (SQLite FTS5).
|
||||||
|
*
|
||||||
|
* Off by default: config['content_index_enabled'] gates the whole
|
||||||
|
* subsystem, since this is a real SQLite dependency plenty of sites built
|
||||||
|
* on this framework won't want at all — the same posture as Matomo/admin
|
||||||
|
* auth. Every public method here is a no-op when it's false.
|
||||||
|
*
|
||||||
|
* Two trigger paths, both funneling into reindex():
|
||||||
|
* - ensureFresh() — lazy, called from the sitemap/search/tag-browsing
|
||||||
|
* sidecars themselves (never from a normal page view), reindexing only
|
||||||
|
* if something changed since the last index (a cheap filemtime() scan,
|
||||||
|
* no rendering, decides this) and only if config['content_index_auto']
|
||||||
|
* is true (the default).
|
||||||
|
* - novaconium/bin/index-content.php — explicit, ignores content_index_auto,
|
||||||
|
* for projects that would rather trigger indexing from a deploy step.
|
||||||
|
*
|
||||||
|
* A crawl invokes every page's sidecar the same way a real GET request
|
||||||
|
* would (this is how it discovers noindex pages and pulls metadata) —
|
||||||
|
* sidecars are expected to be side-effect-free for non-POST requests
|
||||||
|
* anyway (ordinary HTTP-safe-method hygiene, not a new constraint this
|
||||||
|
* introduces), but reindex() additionally forces $_SERVER['REQUEST_METHOD']
|
||||||
|
* to 'GET' for the duration of the crawl and restores whatever it was
|
||||||
|
* before, so a lazy reindex triggered from within a POST request (however
|
||||||
|
* unlikely for the sidecars this ships with) can never leak that POST into
|
||||||
|
* an unrelated page's sidecar.
|
||||||
|
*/
|
||||||
|
final class ContentIndexer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Guards against reentrancy: the crawl itself renders every routable
|
||||||
|
* page, including /search (a real content_index_enabled consumer,
|
||||||
|
* since it has no other reason not to be crawled) — and /search's own
|
||||||
|
* sidecar calls ensureFresh(). Without this guard, that nested call
|
||||||
|
* would see itself as "in progress" and either start a second
|
||||||
|
* reindex() mid-transaction (PDO fatals on a nested beginTransaction())
|
||||||
|
* or, if it didn't fatal, silently corrupt the outer crawl's result.
|
||||||
|
* Both ensureFresh() and reindex() no-op while a reindex is already
|
||||||
|
* running on this call stack.
|
||||||
|
*/
|
||||||
|
private static bool $indexing = false;
|
||||||
|
|
||||||
|
public static function ensureFresh(): void
|
||||||
|
{
|
||||||
|
if (self::$indexing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = self::config();
|
||||||
|
|
||||||
|
if (!$config['content_index_enabled'] || !$config['content_index_auto']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isStale($config)) {
|
||||||
|
self::reindex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reindex(): void
|
||||||
|
{
|
||||||
|
if (self::$indexing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = self::config();
|
||||||
|
|
||||||
|
if (!$config['content_index_enabled']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Db::connection();
|
||||||
|
$renderer = self::renderer($config);
|
||||||
|
$routes = Overlay::listPageDirs($config['pages_dirs']);
|
||||||
|
|
||||||
|
$originalMethod = $_SERVER['REQUEST_METHOD'] ?? null;
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
self::$indexing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
$pdo->exec('DELETE FROM content_pages');
|
||||||
|
$pdo->exec('DELETE FROM content_tags');
|
||||||
|
$pdo->exec('DELETE FROM content_search');
|
||||||
|
|
||||||
|
$insertPage = $pdo->prepare(
|
||||||
|
'INSERT INTO content_pages (route, title, description, keywords, changefreq, priority, source_mtime) ' .
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$insertTag = $pdo->prepare('INSERT INTO content_tags (route, tag) VALUES (?, ?)');
|
||||||
|
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
|
||||||
|
|
||||||
|
$newestMtime = 0;
|
||||||
|
$sourceCount = 0;
|
||||||
|
|
||||||
|
foreach ($routes as $dir) {
|
||||||
|
if (in_array($dir, $config['draft_routes'], true)) {
|
||||||
|
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);
|
||||||
|
$newestMtime = max($newestMtime, $mtime);
|
||||||
|
|
||||||
|
$route = new Route($dir, [], true);
|
||||||
|
$indexed = $renderer->renderForIndex($route);
|
||||||
|
|
||||||
|
if ($indexed === null || str_contains($indexed->robots, 'noindex')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeUrl = $dir === '' ? '/' : '/' . $dir;
|
||||||
|
|
||||||
|
$insertPage->execute([
|
||||||
|
$routeUrl,
|
||||||
|
$indexed->title,
|
||||||
|
$indexed->description,
|
||||||
|
$indexed->keywords,
|
||||||
|
$indexed->changefreq,
|
||||||
|
$indexed->priority,
|
||||||
|
$mtime,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (self::splitList($indexed->tags) as $tag) {
|
||||||
|
$insertTag->execute([$routeUrl, $tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertSearch->execute([$routeUrl, $indexed->title, strip_tags($indexed->html)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM content_index_meta')->execute();
|
||||||
|
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, source_count, indexed_at) VALUES (1, ?, ?, ?)')
|
||||||
|
->execute([$newestMtime, $sourceCount, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
if ($pdo->inTransaction()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
if ($originalMethod === null) {
|
||||||
|
unset($_SERVER['REQUEST_METHOD']);
|
||||||
|
} else {
|
||||||
|
$_SERVER['REQUEST_METHOD'] = $originalMethod;
|
||||||
|
}
|
||||||
|
self::$indexing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function isStale(array $config): bool
|
||||||
|
{
|
||||||
|
$pdo = Db::connection();
|
||||||
|
|
||||||
|
$meta = $pdo->query('SELECT newest_source_mtime, source_count FROM content_index_meta WHERE id = 1')->fetch();
|
||||||
|
if ($meta === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newest = 0;
|
||||||
|
$count = 0;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $pagesDirs
|
||||||
|
*/
|
||||||
|
private static function sourceMtime(array $pagesDirs, string $dir): int
|
||||||
|
{
|
||||||
|
$twig = Overlay::findFile($pagesDirs, $dir === '' ? 'index.twig' : $dir . '/index.twig');
|
||||||
|
$php = Overlay::findFile($pagesDirs, $dir === '' ? 'index.php' : $dir . '/index.php');
|
||||||
|
|
||||||
|
$mtimes = array_filter([
|
||||||
|
$twig !== null ? filemtime($twig) : false,
|
||||||
|
$php !== null ? filemtime($php) : false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $mtimes === [] ? 0 : (int) max($mtimes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function splitList(string $value): array
|
||||||
|
{
|
||||||
|
$items = array_map('trim', explode(',', $value));
|
||||||
|
|
||||||
|
return array_values(array_filter($items, fn (string $item) => $item !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
*/
|
||||||
|
private static function renderer(array $config): Renderer
|
||||||
|
{
|
||||||
|
return new Renderer($config['pages_dirs'], new Cache($config['cache_dir']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function config(): array
|
||||||
|
{
|
||||||
|
$config = require __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
$appConfigFile = __DIR__ . '/../../App/config.php';
|
||||||
|
if (is_file($appConfigFile)) {
|
||||||
|
$appConfig = require $appConfigFile;
|
||||||
|
$defaultConnections = $config['db_connections'];
|
||||||
|
$appConnections = $appConfig['db_connections'] ?? [];
|
||||||
|
$config = array_merge($config, $appConfig);
|
||||||
|
$config['db_connections'] = array_merge($defaultConnections, $appConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of Renderer::renderForIndex() — everything App\ContentIndexer
|
||||||
|
* needs from a single rendered page, without any HTTP output or cache
|
||||||
|
* write. Each metadata field is pulled via Twig's TemplateWrapper::
|
||||||
|
* renderBlock(), so it reflects whatever that specific page (or, absent an
|
||||||
|
* override, the layout's default) declared — see novaconium/pages/_layout/
|
||||||
|
* layout.twig and /admin/docs/seo.
|
||||||
|
*/
|
||||||
|
final class IndexedPage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $html,
|
||||||
|
public readonly string $title,
|
||||||
|
public readonly string $description,
|
||||||
|
public readonly string $keywords,
|
||||||
|
public readonly string $tags,
|
||||||
|
public readonly string $robots,
|
||||||
|
public readonly string $changefreq,
|
||||||
|
public readonly string $priority,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user