3 Commits

Author SHA1 Message Date
code 8540c6d9ea Scope Ecommerce down to Lib helpers; prune stale Done entries in ISSUES.md
Replace the single Ecommerce entry with three composable Lib\ pieces
(Money, Cart, payment gateway) instead of a framework-owned catalog/
checkout/order-admin system — a project's idea of a "product" is too
site-specific to standardize, so it builds its own on Lib\Db like any
other feature.

Also removes four Done entries (In-house comments, Email verification,
Media/file manager, User deletion & email addresses) that nothing open
still depends on or references, per this file's own stated retention
policy — their history remains in git log and the shipping commits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:49:13 +00:00
code 64defe7f74 Add in-house comments (Lib\Comments)
A reusable comment thread any sidecar can attach to any page, tied to
real logged-in accounts (never anonymous), auto-approved on submission
with hide/delete moderation at /admin/comments — only a verified account
can post, so there's no anonymous-spam vector to pre-vet against.

Framework-level (novaconium/lib, novaconium/migrations, novaconium/pages),
not App/migrations, matching Admin auth and Media manager's shape. A page
needs its own sidecar to use it — this repo has no client-side JS, so
comments ride the same server-rendered POST pattern as every other
dynamic feature, which is also what excludes a page from the static
cache. App/pages/blog/comments-demo/ demonstrates the pattern without
touching existing posts that are referenced elsewhere as the
sidecar-less example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:38:04 +00:00
code e699027b4b Add email verification for user accounts
Every account created after the first must confirm a 24-hour link
(Lib\Mailer::sendMail(), log-file or MailJet) before AdminAuth::attempt()
allows login, folded into the same generic pass/fail as a disabled account
or wrong password. First user and pre-migration rows are grandfathered;
create-admin-user.php also auto-verifies for lockout recovery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:23:20 +00:00
26 changed files with 841 additions and 159 deletions
+9
View File
@@ -51,4 +51,13 @@ return [
// `php novaconium/bin/index-content.php` (e.g. from a deploy step): // `php novaconium/bin/index-content.php` (e.g. from a deploy step):
// 'content_index_enabled' => true, // 'content_index_enabled' => true,
// 'content_index_auto' => false, // 'content_index_auto' => false,
// Docs: /admin/docs/admin-auth — email verification. Set all four to
// switch Lib\Mailer's transactional mail (verification links, not the
// contact form) from the zero-dependency log fallback to MailJet:
// 'mail_driver' => 'mailjet',
// 'mail_from_email' => 'noreply@example.com',
// 'mail_from_name' => 'Example Site',
// 'mailjet_api_key' => '...',
// 'mailjet_api_secret' => '...',
]; ];
+7
View File
@@ -17,6 +17,13 @@
</aside> </aside>
<article> <article>
{% block blog_content %}{% endblock %} {% block blog_content %}{% endblock %}
{# Only pages whose sidecar opts in by returning a 'comments'
key get a thread — see /admin/docs/comments and
App/pages/blog/hello-world/index.php. A sidecar-less post
never has this key, so it's silently skipped. #}
{% if comments is defined %}
{% include '_partials/comments/thread.twig' %}
{% endif %}
</article> </article>
</div> </div>
{% endblock %} {% endblock %}
+59
View File
@@ -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(),
];
+27
View File
@@ -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 %}
+6
View File
@@ -46,5 +46,11 @@ return [
'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.', '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', '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',
],
], ],
]; ];
@@ -32,10 +32,11 @@
<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>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>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>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. Enabled with a single <code>admin_auth_enabled</code> flag in <code>App/config.php</code>; 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>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>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>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>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 &amp; 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>Self-hosted spam prevention &amp; 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>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>
+63 -136
View File
@@ -58,99 +58,85 @@ expected vs. actual behavior. For features, include the motivating use case.>
Suggested build order (foundations first): Suggested build order (foundations first):
1. **In-house comments** — admin login & user management (Done 1. **Ecommerce: Lib\Money** — no dependencies, and the other two Ecommerce
2026-07-14) unblocked this; comments are tied to real user accounts, pieces below both need it.
not anonymous. 2. **Ecommerce: Lib\Cart** — needs Lib\Money.
2. **Ecommerce functionality** — its dependencies (SQLite groundwork, 3. **Ecommerce: payment gateway helper** — needs Lib\Money; independent of
session handling for the cart, admin login for order/product admin) Lib\Cart, so it could also go before it.
are all done now. 4. **Paywall functionality** — needs the payment gateway helper above for
3. **Paywall functionality** — needs everything Ecommerce needs, plus its recurring-billing/payment plumbing; build after it rather than in
Ecommerce itself for the recurring-billing/payment-gateway plumbing; parallel — also now has a concrete precedent to follow for the "gated
build after it rather than in parallel — also now has a concrete content must skip the static cache" part of its design (see Draft
precedent to follow for the "gated content must skip the static cache" pages (admin-only preview) in Done, and the caching/auth standing rule
part of its design (see Draft pages (admin-only preview) in Done, and in `AGENTS.md`), which was still an open question when this entry was
the caching/auth standing rule in `AGENTS.md`), which was still an open originally written.
question when this entry was originally written.
Session handling (with flash sessions), Draft pages (admin-only preview), Session handling (with flash sessions), Draft pages (admin-only preview),
Admin login & user management, and Media/file manager all shipped (see and Admin login & user management all shipped (see Done) — every open
Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager. entry above still depends on at least one of them. The original single
"Ecommerce functionality" entry was scoped down into the three
Lib\-helper pieces above on 2026-07-15 — see Lib\Money's entry for why.
See **Won't Do** below for 404 tracking, dropped in favor of Matomo. See **Won't Do** below for 404 tracking, dropped in favor of Matomo.
### Email verification for user accounts ### Ecommerce: Lib\Money
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** Admin login & user management (Done)
- **Added:** 2026-07-14
Verify a user's email address by sending a confirmation link — the
groundwork is already in place: every account has a required, unique,
normalized email (`users.email`, added the same day as user deletion —
see User deletion & email addresses under Done). Needs a `verified_at`
(or token) column, a token-generation/expiry scheme, a send path
(`Lib\Mailer` is currently a log-to-file stand-in — this feature is
probably what forces it to grow a real mail transport), and a decision
on what an unverified account may do (log in but fail `Lib\Access`
rules? not log in at all?). Also the natural home for password-reset-
by-email later, which shares all the same plumbing.
### In-house comments
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Medium
- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
- **Added:** 2026-07-14
A self-hosted comments library — no third-party service (Disqus,
Commento, etc.) — a `Lib\` class any sidecar can call to attach comments
to any page, not just blog posts, the same way `Lib\SpamGuard`/
`Lib\FormValidator` are reusable across any form rather than hardcoded to
the contact page. Comments tied to a real user account rather than
anonymous name/email fields, which is why this rides on Admin login &
user management rather than SQLite groundwork alone — needs that
feature's user store to exist first. Likely a `comments` table
(route/user/body/created_at/approved or similar — a migration under
`App/migrations/`, following the two-root convention documented in
`/admin/docs/database`) plus a small set of sidecar-callable methods
(list comments for a route, submit one, moderate one). Needs a decision
on moderation model (auto-approve vs. admin-approval queue, reusing the
`/admin/*` auth gate for the moderation UI) and on spam handling (reuse
`Lib\SpamGuard`'s honeypot/timing approach rather than inventing a second
mechanism, consistent with how CSRF protection already works — see
`/admin/docs/sidecars`'s "Form security" section for the existing
input-cleaning/CSRF/spam-prevention layers a comment form should compose
the same way the contact form does).
### Ecommerce functionality
- **Type:** Feature - **Type:** Feature
- **Status:** Backlog - **Status:** Backlog
- **Priority:** Low - **Priority:** Low
- **Depends on:** SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done) - **Added:** 2026-07-15
- **Added:** 2026-07-12
Product catalog, cart, checkout, and order storage — a `products` / First and smallest piece of the former "Ecommerce functionality" entry —
`orders` table in SQLite, a session-based cart (rides on the flash-session scoped down (2026-07-15) from a full catalog/cart/checkout/order-admin
work above), and a payment gateway integration for actually taking money. system to a handful of composable `Lib\` helpers, since a project's idea
Given the project's no-Composer/no-vendored-SDK philosophy, prefer calling of a "product" is too site-specific to standardize; the project builds
a payment provider's HTTP API directly (e.g. Stripe's REST API via cURL) its own catalog and admin UI on `Lib\Db` the same way it would for any
over vendoring a full SDK, same reasoning as vendoring only Twig's `src/` other feature, the same split `Lib\Comments` already makes for what a
rather than pulling in a package manager. Needs a decision on which "page" is. `Lib\Money`: integer-cents arithmetic (add/subtract/multiply
provider(s) to support first. Order/product management rides on admin by a quantity) and formatting, avoiding the classic float-rounding bugs
login above. Large feature — likely worth its own sub-breakdown (catalog, of storing prices as floats. No dependencies — the foundation `Lib\Cart`
cart, checkout, order admin) once it's actually picked up rather than and the payment gateway helper below both need a non-lossy way to
planning it all up front here. represent an amount before either can be built.
### Ecommerce: Lib\Cart
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** Ecommerce: Lib\Money, Session handling (with flash sessions) (Done)
- **Added:** 2026-07-15
Second piece of the former "Ecommerce functionality" entry (see Lib\Money
above for the scoping note). A session-based cart primitive — add/remove/
update line items, line and cart totals via `Lib\Money` — riding on
`Lib\Session` the same way `Lib\Csrf`/`Lib\AdminAuth` lazily touch the
native session. Generic on purpose: the cart holds id/qty/price entries a
sidecar hands it, with no opinion on what a "product" is or where its
catalog data comes from.
### Ecommerce: payment gateway helper
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Depends on:** Ecommerce: Lib\Money
- **Added:** 2026-07-15
Third piece of the former "Ecommerce functionality" entry (see Lib\Money
above for the scoping note). A driver-dispatched `Lib\` class for taking
a payment, mirroring `Lib\Mailer`'s `mail_driver` config-key pattern —
Stripe first (one `charge()`-shaped call plus webhook signature
verification), calling the provider's REST API directly via cURL rather
than vendoring an SDK, same reasoning as vendoring only Twig's `src/`
rather than pulling in a package manager. Adding a second provider later
means one more driver case, same as `Lib\Mailer::sendMail()`.
### Paywall functionality ### Paywall functionality
- **Type:** Feature - **Type:** Feature
- **Status:** Backlog - **Status:** Backlog
- **Priority:** Low - **Priority:** Low
- **Depends on:** Ecommerce functionality (recurring billing/payment plumbing), SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done) - **Depends on:** Ecommerce: payment gateway helper, SQLite groundwork (Done), Session handling (with flash sessions) (Done), Admin login & user management (Done)
- **Added:** 2026-07-12 - **Added:** 2026-07-12
Subscription/membership content gating, similar to OnlyFans/Patreon: Subscription/membership content gating, similar to OnlyFans/Patreon:
@@ -177,65 +163,6 @@ _Nothing yet._
## Done ## Done
### Media/file manager
- **Type:** Feature
- **Status:** Done
- **Priority:** Medium
- **Added:** 2026-07-12
- **Shipped:** 2026-07-15
An upload/browse/delete UI for media (images, PDFs, etc.) at `/admin/media`
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/`. Covered by the existing
`/admin/*` auth gate the moment the page was added, no extra wiring
needed — as planned, there's no separate `*_enabled` flag (unlike admin
auth/content index) since it has no SQLite dependency to gate. Backed by a
plain directory, `public/uploads/` (gitignored per-file with a tracked
`.gitkeep`, same convention as `data/`), rather than a database — no
metadata store (alt text, captions) yet; that's flagged as a natural
SQLite fit if wanted later, not built now. Also removed the top-level
`images/` scaffolding directory/volume (reserved for a future
image-upload feature, per the prior `AGENTS.md` wording): nothing had
ever consumed it, and `public/uploads/` now covers the use case it was
held open for. Safety handling: an extension
allowlist and max upload size, both configurable
(`media_upload_extensions`/`media_upload_max_bytes` in `App/config.php`),
plus filename sanitization (`basename()` + safe-charset reduction,
collision-safe via a `-1`/`-2`/... suffix) and a `realpath()` re-check on
delete to confirm the resolved path still lands inside `public/uploads/`
before unlinking. Documented at `/admin/docs/media-manager` (new topic,
linked from the docs nav/index and the admin dashboard), with a matching
README feature/docs-index entry.
### User deletion & email addresses
- **Type:** Feature
- **Status:** Done
- **Priority:** Medium
- **Depends on:** Admin login & user management (Done)
- **Added:** 2026-07-14
- **Shipped:** 2026-07-14 (b882c30)
Second same-day follow-up to Admin login & user management (below),
also pre-commit — so the `email` column went into the existing
`0002_create_users.sql` like the roles change before it. `/admin/users`
gained a hard-delete action (username/email become reusable; any live
session dies on its next request via the same per-request row re-check
disabling uses; the last-active-admin guard covers delete as well as
disable/demote) and a change-email action. Every account now requires a
unique email address — validated and normalized (trim + lowercase) via
the existing `Lib\Validate::isEmail()`, so uniqueness is
case-insensitive by construction (verified: `BOB@example.com` collides
with `bob@example.com`) — on the create form, the change-email action,
and `bin/create-admin-user.php` (now `<username> <email>`). Not used
for login or any mail yet; it exists so the planned email-verification
flow (new Backlog entry above) has an address for every account that
predates it. Delete stays deliberately distinct from disable in the UI
(inside a confirm-style `<details>` with a warning): disable is
keep-but-shut-out, delete is gone-for-good.
### User roles, groups & page access control ### User roles, groups & page access control
- **Type:** Feature - **Type:** Feature
+8 -3
View File
@@ -65,10 +65,15 @@ if (strlen($password) < 8) {
// Always role 'admin', as the script name says — /admin/users is the // Always role 'admin', as the script name says — /admin/users is the
// place to create registered users; this exists for first-user setup and // place to create registered users; this exists for first-user setup and
// lockout recovery, both of which need an admin. // lockout recovery, both of which need an admin. Auto-verified for the
// same reason /admin/users auto-verifies the very first user: an admin
// created this way has nobody else to have vouched for them, and lockout
// recovery in particular can't depend on a mail transport being
// configured (see /admin/docs/admin-auth's email verification section).
$now = gmdate('Y-m-d\TH:i:s\Z');
Db::query( Db::query(
"INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, 'admin', '', 0, ?)", "INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, 'admin', '', 0, ?, ?)",
[$username, $email, password_hash($password, PASSWORD_DEFAULT), gmdate('Y-m-d\TH:i:s\Z')] [$username, $email, password_hash($password, PASSWORD_DEFAULT), $now, $now]
); );
echo "User '{$username}' created.\n"; echo "User '{$username}' created.\n";
+20
View File
@@ -114,4 +114,24 @@ return [
// see /admin/docs/media-manager). // see /admin/docs/media-manager).
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip'], 'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip'],
'media_upload_max_bytes' => 10 * 1024 * 1024, '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' => '',
]; ];
+84
View File
@@ -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);
}
}
+89
View File
@@ -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,4 @@
ALTER TABLE users ADD COLUMN verified_at TEXT;
ALTER TABLE users ADD COLUMN verification_token TEXT;
ALTER TABLE users ADD COLUMN verification_token_expires_at TEXT;
UPDATE users SET verified_at = created_at WHERE verified_at IS NULL;
@@ -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),
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,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/hello-world/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>
+65
View File
@@ -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 %}
@@ -23,6 +23,7 @@
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li> <li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a></li>
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li> <li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a></li>
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a></li> <li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a></li>
<li><a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a></li>
<li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li> <li><a class="icon-link" href="/admin/docs/layouts">{{ icons.book() }}Layouts</a></li>
<li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li> <li><a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a></li>
<li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li> <li><a class="icon-link" href="/admin/docs/seo">{{ icons.book() }}SEO</a></li>
@@ -37,7 +37,20 @@ return [
<p>The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an <em>admin</em> — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at <code>/admin/users</code>. Running it <em>before</em> flipping <code>admin_auth_enabled</code> on is the safer order — the open setup window then never exists at all.</p> <p>The CLI reads the password from stdin (echo suppressed at an interactive prompt, and pipeable from a deploy script), and always creates an <em>admin</em> — it exists for first-user setup and lockout recovery, both of which need one; registered users are created at <code>/admin/users</code>. Running it <em>before</em> flipping <code>admin_auth_enabled</code> on is the safer order — the open setup window then never exists at all.</p>
<p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username) or for any mail yet, but required up front so the planned email-verification flow (see <code>novaconium/ISSUES.md</code>) has an address for every account that already exists by then.</p> <p>The <code>users</code> table ships as a framework migration (<code>novaconium/migrations/0002_create_users.sql</code>) and is created automatically the first time anything touches the <code>default</code> connection — no manual schema step. Passwords are stored as <code>password_hash()</code> hashes and checked with <code>password_verify()</code>; no plaintext, and nothing here ever selects the hash back out except to verify a login. Every account also has a <strong>unique email address</strong>, stored normalized (trimmed, lowercased, via <code>Lib\Validate::isEmail()</code>) — not used for login (that's the username), but every account after the first now must verify it before logging in (see below).</p>
<h2>Email verification</h2>
<p>Every user created after the first must click a link emailed to their address before they can log in at all — <code>AdminAuth::attempt()</code> fails a login the same generic way it fails a disabled account or a wrong password, with no distinction made to an anonymous caller between "wrong password", "disabled", and "unverified" (novaconium/migrations/0003_add_users_verification.sql's <code>verified_at</code> column). Two accounts are exempt by design, both following the same reasoning as the last-active-admin guard elsewhere on this page — verification can't depend on a mail transport actually being configured:</p>
<ul>
<li><strong>The very first user</strong> (created at <a href="/admin/users">/admin/users</a> or via the CLI below) is auto-verified at creation — there's no other admin to have vouched for them, and they're logged in immediately afterward regardless.</li>
<li><strong>Every row that existed before this feature shipped</strong> is grandfathered — the migration backfills <code>verified_at</code> from <code>created_at</code>, so nobody who could already log in gets locked out retroactively.</li>
</ul>
<p>Every other new account gets a 24-hour verification link (<code>bin2hex(random_bytes(32))</code>, the same token pattern <code>Lib\Csrf::token()</code> uses) sent via <code>Lib\Mailer::sendMail()</code> to <code>/verify-email?token=...</code>. That route follows the same GET-confirms/POST-mutates shape as <a href="/admin/logout">/admin/logout</a>, and for the same two reasons: the content-index crawl hits every routable page as a forced GET, and email-security scanners prefetch links before a human clicks — either would burn a GET-mutates link's token. An invalid, already-used, or expired token never touches the database on either method — it just renders an "invalid or expired" page pointing back at <code>/admin/users</code>. Changing an account's email (the <code>email</code> action at <code>/admin/users</code>) resets verification and sends a fresh link to the <em>new</em> address, since an unconfirmed new address shouldn't inherit the old one's verified status — and because <code>AdminAuth::currentUser()</code> re-checks <code>verified_at</code> on every request (the same immediate re-check <code>is_disabled</code> already gets), a live session is locked out on its very next request too, not just future logins.</p>
<p><code>Lib\Mailer::sendMail()</code> is separate from the pre-existing <code>Mailer::send()</code> the contact form uses (unchanged) — it's driver-dispatched via <code>mail_driver</code> in <code>App/config.php</code>: <code>'log'</code> (the default) appends to the same <code>novaconium/contact-log.txt</code> the contact form uses, so a fresh checkout can create and verify accounts with zero external dependency; <code>'mailjet'</code> sends through MailJet's Send API v3.1 using <code>mailjet_api_key</code>/<code>mailjet_api_secret</code> plus <code>mail_from_email</code>/<code>mail_from_name</code>. Adding a different provider later means adding a new case to that dispatch — callers of <code>sendMail()</code> never change.</p>
<h2>Managing users</h2> <h2>Managing users</h2>
@@ -0,0 +1,52 @@
{% extends 'admin/docs/_layout/layout.twig' %}
{% import '_layout/icons.twig' as icons %}
{% block title %}Comments{% endblock %}
{% block description %}Lib\Comments — a reusable comment thread any page can attach to itself via its own sidecar.{% endblock %}
{% block robots %}noindex, nofollow{% endblock %}
{% block docs_content %}
<h1>Comments</h1>
<p><code>Lib\Comments</code> is a self-hosted comment thread — no third-party service (Disqus, Commento, etc.) — a plain static class any sidecar can call to attach comments to any page, not just blog posts, the same way <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}<code>Lib\SpamGuard</code>/<code>Lib\FormValidator</code></a> are reusable across any form rather than hardcoded to the contact page. It depends on <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> being enabled — comments are tied to a real logged-in account (<code>App\AdminAuth::currentUser()</code>), never anonymous name/email fields, since <code>currentUser()</code> already excludes disabled and unverified accounts, there's nothing further to check before accepting a comment from whoever it returns.</p>
<h2>Auto-approve, moderate after</h2>
<p>A comment is visible the instant it's posted — there's no pending-approval queue. Since only a verified account can post one at all, there's no anonymous-spam vector to pre-vet against, the same reasoning <a class="icon-link" href="/admin/docs/admin-auth">{{ icons.lock() }}admin authentication</a> already applies by disabling rather than pre-vetting accounts. An admin can hide or permanently delete any comment after the fact at <a href="/admin/comments">/admin/comments</a> — hiding removes it from its page immediately (it's excluded at the query level, not just visually), deleting is permanent.</p>
<h2>Attaching a thread to a page</h2>
<p>A page needs its own sidecar to use <code>Lib\Comments</code> — this codebase has no client-side JS/fetch anywhere, every dynamic feature is a plain server-rendered POST form, and comments are no different. Giving a page a sidecar is exactly what excludes it from the <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}static HTML cache</a> (only sidecar-less pages are ever cached), so attaching comments to a previously sidecar-less page is an explicit, per-page tradeoff — the same one <a href="/admin/media">/admin/media</a> and <a href="/search">/search</a> already accept. See <code>App/pages/blog/comments-demo/index.php</code> for a full worked example (the one post under <code>App/pages/blog/</code> with a sidecar, specifically for this):</p>
<pre><code>$pagePath = Comments::currentPagePath();
$user = AdminAuth::currentUser();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Csrf::verify(Input::post('csrf_token'))) { ... }
if ($user === null) { ... } // must be logged in
// validate $body, run it past SpamGuard, then:
Comments::create($pagePath, $user['id'], $body);
return Response::redirect($pagePath);
}
return [
'comments' => Comments::forPage($pagePath),
'currentUser' => $user,
// ...csrfField/csrfToken/renderedAt, same as any other form sidecar
];</code></pre>
<p>Then include the reusable partial, <code>novaconium/pages/_partials/comments/thread.twig</code> — a <code>_</code>-prefixed segment, so it's never itself routable (see <a class="icon-link" href="/admin/docs/routing">{{ icons.link() }}Routing</a>'s reserved-segments rule), the same convention <code>_layout/</code> already uses:</p>
<pre><code class="nohighlight">{% verbatim %}{% if comments is defined %}
{% include '_partials/comments/thread.twig' %}
{% endif %}{% endverbatim %}</code></pre>
<p>Guarding the include with <code>comments is defined</code> — as <code>App/pages/blog/_layout/layout.twig</code> does — means a layout shared by both comment-enabled and sidecar-less pages can include it unconditionally without breaking the pages that never set the key. The partial itself expects <code>comments</code>, <code>currentUser</code>, <code>csrfField</code>/<code>csrfToken</code>, and <code>renderedAt</code> in context — the same shape returned above — and renders the thread plus a honeypot-carrying submit form (mirroring the contact form's spam prevention, see <a class="icon-link" href="/admin/docs/forms">{{ icons.email() }}Forms</a>) when someone's logged in, or a "log in to comment" link otherwise.</p>
<h2>Schema</h2>
<p>Ships as a framework migration, <code>novaconium/migrations/0004_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 %}
+2 -1
View File
@@ -4,7 +4,7 @@
{% block title %}Docs{% endblock %} {% block title %}Docs{% endblock %}
{% block description %}Framework documentation: routing, sidecars, forms, libraries, database, session, content index, XML sitemap, RSS feeds, admin authentication, draft pages, media manager, 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 %}
@@ -43,6 +43,7 @@
<li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li> <li><a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a> — assign a page or section to a user or group from its sidecar with <code>Lib\Access</code>; public by default, static pages always public.</li>
<li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li> <li><a class="icon-link" href="/admin/docs/drafts">{{ icons.lock() }}Draft pages</a> — let an admin preview a page before the public can see it, reusing the same auth check.</li>
<li><a class="icon-link" href="/admin/docs/media-manager">{{ icons.tag() }}Media manager</a> — upload, browse, and delete files under <code>public/uploads/</code> from <code>/admin/media</code>.</li> <li><a class="icon-link" href="/admin/docs/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>
+1
View File
@@ -16,6 +16,7 @@
<li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li> <li><a class="icon-link" href="/admin/docs">{{ icons.book() }}Project docs</a></li>
<li><a class="icon-link" href="/admin/media">{{ icons.tag() }}Media</a></li> <li><a class="icon-link" href="/admin/media">{{ icons.tag() }}Media</a></li>
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %} {% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/users">{{ icons.users() }}Users</a></li>{% endif %}
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/comments">{{ icons.users() }}Comments</a></li>{% endif %}
{% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %} {% if admin_auth_enabled %}<li><a class="icon-link" href="/admin/logout">{{ icons.external_link() }}Logout</a></li>{% endif %}
</ul> </ul>
</article> </article>
+62 -6
View File
@@ -5,6 +5,7 @@ use App\Response;
use Lib\Csrf; use Lib\Csrf;
use Lib\Db; use Lib\Db;
use Lib\Input; use Lib\Input;
use Lib\Mailer;
use Lib\Session; use Lib\Session;
use Lib\Validate; use Lib\Validate;
@@ -35,6 +36,32 @@ $activeAdminCount = static fn (): int => (int) Db::query(
"SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'" "SELECT COUNT(*) FROM users WHERE is_disabled = 0 AND role = 'admin'"
)->fetchColumn(); )->fetchColumn();
// Issues a fresh verification token (bin2hex(random_bytes(32)), same
// pattern as Lib\Csrf::token()) for the given user id, emails the link via
// Lib\Mailer::sendMail() (log-file by default, MailJet if configured — see
// /admin/docs/admin-auth), and returns the token for callers that don't
// need it. Used by both the create action (new non-bootstrap users) and
// the resend_verification/email actions below.
$issueVerification = static function (int $id, string $email): void {
$token = bin2hex(random_bytes(32));
$expiresAt = gmdate('Y-m-d\TH:i:s\Z', time() + 86400);
Db::query(
'UPDATE users SET verified_at = NULL, verification_token = ?, verification_token_expires_at = ? WHERE id = ?',
[$token, $expiresAt, $id]
);
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$verifyUrl = "{$scheme}://{$host}/verify-email?token={$token}";
(new Mailer())->sendMail(
$email,
'Verify your account',
"Confirm your email address to activate your account:\n\n{$verifyUrl}\n\nThis link expires in 24 hours."
);
};
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!Csrf::verify(Input::post('csrf_token'))) { if (!Csrf::verify(Input::post('csrf_token'))) {
Session::flash('users_error', 'Your session expired before submitting — please try again.'); Session::flash('users_error', 'Your session expired before submitting — please try again.');
@@ -83,10 +110,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// /admin/docs/access-control. // /admin/docs/access-control.
$wasFirstUser = !AdminAuth::hasUsers(); $wasFirstUser = !AdminAuth::hasUsers();
$role = $wasFirstUser ? 'admin' : 'registered'; $role = $wasFirstUser ? 'admin' : 'registered';
$now = gmdate('Y-m-d\TH:i:s\Z');
// The first user is auto-verified — there's no other admin to
// have vouched for them, and the very next line logs them in
// immediately, which a null verified_at would otherwise block
// (see AdminAuth::attempt() and /admin/docs/admin-auth). Every
// subsequent account starts unverified and gets a verification
// email instead, via $issueVerification below.
Db::query( Db::query(
'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)', 'INSERT INTO users (username, email, password_hash, role, user_group, is_disabled, created_at, verified_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)',
[$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, gmdate('Y-m-d\TH:i:s\Z')] [$username, $email, password_hash($password, PASSWORD_DEFAULT), $role, $group, $now, $wasFirstUser ? $now : null]
); );
// Creating the first user is what closes the open setup gate — // Creating the first user is what closes the open setup gate —
@@ -94,9 +128,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// bounce them to the login form they were just typing into. // bounce them to the login form they were just typing into.
if ($wasFirstUser) { if ($wasFirstUser) {
AdminAuth::attempt($username, $password); AdminAuth::attempt($username, $password);
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created.");
} else {
$id = (int) Db::query('SELECT id FROM users WHERE username = ?', [$username])->fetchColumn();
$issueVerification($id, $email);
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created — a verification email was sent to {$email}; they can't log in until it's confirmed.");
} }
Session::flash('users_notice', "User \u{201C}{$username}\u{201D} created.");
} }
} elseif ($action === 'disable' || $action === 'enable') { } elseif ($action === 'disable' || $action === 'enable') {
$id = (int) Input::post('id', '0'); $id = (int) Input::post('id', '0');
@@ -155,8 +192,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif ($emailExists) { } elseif ($emailExists) {
Session::flash('users_error', 'That email address is already in use.'); Session::flash('users_error', 'That email address is already in use.');
} else { } else {
// A changed address hasn't been proven owned yet — reset
// verification and send a fresh link to the *new* address,
// rather than silently keeping the old address's verified
// status attached to an unconfirmed one. $issueVerification
// also sets verified_at back to NULL, so the account can't log
// in again until this new address is confirmed.
Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]); Db::query('UPDATE users SET email = ? WHERE id = ?', [$email, $id]);
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D}."); $issueVerification($id, $email);
Session::flash('users_notice', "Email changed for \u{201C}{$target['username']}\u{201D} — a verification email was sent to {$email}; they can't log in again until it's confirmed.");
}
} elseif ($action === 'resend_verification') {
$id = (int) Input::post('id', '0');
$target = Db::query('SELECT id, username, email, verified_at FROM users WHERE id = ?', [$id])->fetch(PDO::FETCH_ASSOC);
if ($target === false) {
Session::flash('users_error', 'No such user.');
} elseif ($target['verified_at'] !== null) {
Session::flash('users_error', "\u{201C}{$target['username']}\u{201D} is already verified.");
} else {
$issueVerification($id, $target['email']);
Session::flash('users_notice', "Verification email resent to \u{201C}{$target['username']}\u{201D}.");
} }
} elseif ($action === 'group') { } elseif ($action === 'group') {
$id = (int) Input::post('id', '0'); $id = (int) Input::post('id', '0');
@@ -192,7 +248,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
return [ return [
'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC), 'users' => Db::query('SELECT id, username, email, role, user_group, is_disabled, created_at, verified_at FROM users ORDER BY username')->fetchAll(PDO::FETCH_ASSOC),
'currentUserId' => AdminAuth::currentUser()['id'] ?? null, 'currentUserId' => AdminAuth::currentUser()['id'] ?? null,
'notice' => Session::getFlash('users_notice'), 'notice' => Session::getFlash('users_notice'),
'error' => Session::getFlash('users_error'), 'error' => Session::getFlash('users_error'),
+11 -1
View File
@@ -12,7 +12,7 @@
<article> <article>
<h1 class="icon-heading">{{ icons.users() }}Users</h1> <h1 class="icon-heading">{{ icons.users() }}Users</h1>
<p>Accounts that can log in at <a href="/admin/login">/admin/login</a>. The first user created is the <strong>admin</strong>; everyone after is <strong>registered</strong> — able to log in and see whatever pages <code>Lib\Access</code> assigns to their account or group (see <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a>), but not the admin area. A disabled user can't log in, and any session they already had is locked out on its next request.</p> <p>Accounts that can log in at <a href="/admin/login">/admin/login</a>. The first user created is the <strong>admin</strong>; everyone after is <strong>registered</strong> — able to log in and see whatever pages <code>Lib\Access</code> assigns to their account or group (see <a class="icon-link" href="/admin/docs/access-control">{{ icons.lock() }}Access control</a>), but not the admin area. A disabled user can't log in, and any session they already had is locked out on its next request. Every user after the first must also verify their email before they can log in at all — they're sent a verification link on creation (or resend it below), and changing an account's email requires re-verifying the new address.</p>
{% if notice %} {% if notice %}
<p><strong>{{ notice }}</strong></p> <p><strong>{{ notice }}</strong></p>
@@ -34,6 +34,7 @@
<th>Group</th> <th>Group</th>
<th>Created</th> <th>Created</th>
<th>Status</th> <th>Status</th>
<th>Verified</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -46,6 +47,7 @@
<td>{{ user.user_group ?: '—' }}</td> <td>{{ user.user_group ?: '—' }}</td>
<td>{{ user.created_at }}</td> <td>{{ user.created_at }}</td>
<td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td> <td>{{ user.is_disabled ? 'Disabled' : 'Active' }}</td>
<td>{{ user.verified_at ? 'Verified' : 'Unverified' }}</td>
<td> <td>
<form method="post" action="/admin/users"> <form method="post" action="/admin/users">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}"> <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
@@ -53,6 +55,14 @@
<input type="hidden" name="action" value="{{ user.is_disabled ? 'enable' : 'disable' }}"> <input type="hidden" name="action" value="{{ user.is_disabled ? 'enable' : 'disable' }}">
<button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button> <button type="submit">{{ user.is_disabled ? 'Enable' : 'Disable' }}</button>
</form> </form>
{% if not user.verified_at %}
<form method="post" action="/admin/users">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
<input type="hidden" name="id" value="{{ user.id }}">
<input type="hidden" name="action" value="resend_verification">
<button type="submit">Resend verification</button>
</form>
{% endif %}
<form method="post" action="/admin/users"> <form method="post" action="/admin/users">
<input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}"> <input type="hidden" name="{{ csrfField }}" value="{{ csrfToken }}">
<input type="hidden" name="id" value="{{ user.id }}"> <input type="hidden" name="id" value="{{ user.id }}">
+88
View File
@@ -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(),
];
+30
View File
@@ -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 %}
+16 -10
View File
@@ -110,9 +110,14 @@ final class AdminAuth
/** /**
* Verifies a username/password against the users table and, on * Verifies a username/password against the users table and, on
* success, logs the session in. Disabled users fail exactly like a * success, logs the session in. Disabled and unverified users fail
* wrong password — the response never distinguishes "no such user", * exactly like a wrong password — the response never distinguishes
* "disabled", and "bad password". * "no such user", "disabled", "unverified", and "bad password" (see
* /admin/docs/admin-auth's email verification section). Unset
* verified_at means never verified; the first user ever created and
* every row that existed before this check shipped are backfilled to a
* non-null value (novaconium/migrations/0003_add_users_verification.sql)
* so verification only actually gates accounts created after it.
*/ */
public static function attempt(string $username, string $password): bool public static function attempt(string $username, string $password): bool
{ {
@@ -121,11 +126,11 @@ final class AdminAuth
} }
$user = Db::query( $user = Db::query(
'SELECT id, username, email, password_hash, role, user_group, is_disabled FROM users WHERE username = ?', 'SELECT id, username, email, password_hash, role, user_group, is_disabled, verified_at FROM users WHERE username = ?',
[$username] [$username]
)->fetch(PDO::FETCH_ASSOC); )->fetch(PDO::FETCH_ASSOC);
if ($user === false || (int) $user['is_disabled'] === 1 || !password_verify($password, $user['password_hash'])) { if ($user === false || (int) $user['is_disabled'] === 1 || $user['verified_at'] === null || !password_verify($password, $user['password_hash'])) {
return false; return false;
} }
@@ -159,10 +164,11 @@ final class AdminAuth
/** /**
* The logged-in user's row (id/username/email/role/user_group/ * The logged-in user's row (id/username/email/role/user_group/
* is_disabled — never the password hash), or null. Re-checked against * is_disabled — never the password hash), or null. Re-checked against
* the users table on every request, not just at login, so disabling * the users table on every request, not just at login, so disabling,
* or deleting a user locks their existing session out on their very * deleting, or un-verifying (e.g. an email change reset — see
* next request — no "still logged in until the session expires" * /admin/docs/admin-auth) a user locks their existing session out on
* window. * its very next request — no "still logged in until the session
* expires" window.
* *
* @return array<string, mixed>|null * @return array<string, mixed>|null
*/ */
@@ -180,7 +186,7 @@ final class AdminAuth
} }
$user = Db::query( $user = Db::query(
'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0', 'SELECT id, username, email, role, user_group, is_disabled FROM users WHERE id = ? AND is_disabled = 0 AND verified_at IS NOT NULL',
[$userId] [$userId]
)->fetch(PDO::FETCH_ASSOC); )->fetch(PDO::FETCH_ASSOC);