Fix review findings; consolidate pre-release migrations
Bug fixes: - Deleting a user with comments no longer 500s: remove the user's comments first (covers old DBs) and add ON DELETE CASCADE to the FK. - Drop 'svg' from the default media upload allowlist (stored-XSS vector for files served directly from public/uploads/). - Content index now reindexes on page deletion: track routable page count in content_index_meta and treat a count change as stale, since the newest-mtime check alone can't see a removal. - Dev router (public/router.php) preserves the query string across the canonical trailing-slash redirect, matching .htaccess. - Fix stale worked-example reference in the comments thread partial. Migrations: since v2 is unreleased with no live databases, fold the incremental ALTERs into the base migrations rather than shipping them separately: verification columns into 0002_create_users.sql, source_count into 0001_create_content_index.sql; renumber comments to 0003. Update all code/doc references to the removed/renamed files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -111,8 +111,13 @@ return [
|
|||||||
// against the uploaded filename's extension; media_upload_max_bytes
|
// against the uploaded filename's extension; media_upload_max_bytes
|
||||||
// caps a single file's size (checked against both $_FILES' reported
|
// caps a single file's size (checked against both $_FILES' reported
|
||||||
// size and PHP's own upload_max_filesize/post_max_size ini limits,
|
// size and PHP's own upload_max_filesize/post_max_size ini limits,
|
||||||
// see /admin/docs/media-manager).
|
// see /admin/docs/media-manager). 'svg' is deliberately NOT in this
|
||||||
'media_upload_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'txt', 'zip'],
|
// 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,
|
'media_upload_max_bytes' => 10 * 1024 * 1024,
|
||||||
|
|
||||||
// Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's
|
// Lib\Mailer's transactional-mail driver (see /admin/docs/admin-auth's
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ CREATE VIRTUAL TABLE IF NOT EXISTS content_search USING fts5(route UNINDEXED, ti
|
|||||||
CREATE TABLE IF NOT EXISTS content_index_meta (
|
CREATE TABLE IF NOT EXISTS content_index_meta (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
newest_source_mtime INTEGER NOT NULL,
|
newest_source_mtime INTEGER NOT NULL,
|
||||||
|
source_count INTEGER NOT NULL DEFAULT 0,
|
||||||
indexed_at TEXT NOT NULL
|
indexed_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
role TEXT NOT NULL DEFAULT 'registered',
|
role TEXT NOT NULL DEFAULT 'registered',
|
||||||
user_group TEXT NOT NULL DEFAULT '',
|
user_group TEXT NOT NULL DEFAULT '',
|
||||||
is_disabled INTEGER NOT NULL DEFAULT 0,
|
is_disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
verified_at TEXT,
|
||||||
|
verification_token TEXT,
|
||||||
|
verification_token_expires_at TEXT
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
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;
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
CREATE TABLE comments (
|
CREATE TABLE comments (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
page_path TEXT NOT NULL,
|
page_path TEXT NOT NULL,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
is_hidden INTEGER NOT NULL DEFAULT 0,
|
is_hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
(App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and
|
(App\AdminAuth::currentUser()), 'csrfField'/'csrfToken', and
|
||||||
'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See
|
'renderedAt' (Lib\SpamGuard::renderedAt()) in its context. See
|
||||||
/admin/docs/comments for the full sidecar contract this expects, and
|
/admin/docs/comments for the full sidecar contract this expects, and
|
||||||
App/pages/blog/hello-world/index.php for a worked example. A page
|
App/pages/blog/comments-demo/index.php for a worked example. A page
|
||||||
with no 'comments' key never includes this at all — see the
|
with no 'comments' key never includes this at all — see the
|
||||||
surrounding {% if comments is defined %} in blog/_layout/layout.twig.
|
surrounding {% if comments is defined %} in blog/_layout/layout.twig.
|
||||||
#}
|
#}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ return [
|
|||||||
|
|
||||||
<h2>Email verification</h2>
|
<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>
|
<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>
|
<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>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>
|
||||||
|
|||||||
@@ -48,5 +48,5 @@ return [
|
|||||||
|
|
||||||
<h2>Schema</h2>
|
<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>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -175,6 +175,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// "keep the account but shut it out", so delete is for
|
// "keep the account but shut it out", so delete is for
|
||||||
// accounts that shouldn't exist at all. Any live session dies
|
// accounts that shouldn't exist at all. Any live session dies
|
||||||
// on its next request (currentUser() re-checks the row).
|
// on its next request (currentUser() re-checks the row).
|
||||||
|
//
|
||||||
|
// Remove the user's comments first: comments.user_id has a
|
||||||
|
// NOT NULL foreign key to users(id) (0003_create_comments.sql)
|
||||||
|
// and Lib\Db runs PRAGMA foreign_keys = ON, so deleting a user
|
||||||
|
// who has ever commented would otherwise raise a FOREIGN KEY
|
||||||
|
// constraint violation and 500. Fresh installs also get
|
||||||
|
// ON DELETE CASCADE on the FK, but this keeps already-migrated
|
||||||
|
// databases (whose FK predates that) working too.
|
||||||
|
Db::query('DELETE FROM comments WHERE user_id = ?', [$id]);
|
||||||
Db::query('DELETE FROM users WHERE id = ?', [$id]);
|
Db::query('DELETE FROM users WHERE id = ?', [$id]);
|
||||||
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
Session::flash('users_notice', "User \u{201C}{$target['username']}\u{201D} deleted.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,10 +114,11 @@ final class AdminAuth
|
|||||||
* exactly like a wrong password — the response never distinguishes
|
* exactly like a wrong password — the response never distinguishes
|
||||||
* "no such user", "disabled", "unverified", and "bad password" (see
|
* "no such user", "disabled", "unverified", and "bad password" (see
|
||||||
* /admin/docs/admin-auth's email verification section). Unset
|
* /admin/docs/admin-auth's email verification section). Unset
|
||||||
* verified_at means never verified; the first user ever created and
|
* verified_at means never verified (the verified_at/verification_token
|
||||||
* every row that existed before this check shipped are backfilled to a
|
* columns are part of novaconium/migrations/0002_create_users.sql); the
|
||||||
* non-null value (novaconium/migrations/0003_add_users_verification.sql)
|
* first user ever created is inserted already-verified (see
|
||||||
* so verification only actually gates accounts created after it.
|
* /admin/users and bin/create-admin-user.php), so verification only
|
||||||
|
* actually gates accounts created after it.
|
||||||
*/
|
*/
|
||||||
public static function attempt(string $username, string $password): bool
|
public static function attempt(string $username, string $password): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,12 +104,21 @@ final class ContentIndexer
|
|||||||
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
|
$insertSearch = $pdo->prepare('INSERT INTO content_search (route, title, body) VALUES (?, ?, ?)');
|
||||||
|
|
||||||
$newestMtime = 0;
|
$newestMtime = 0;
|
||||||
|
$sourceCount = 0;
|
||||||
|
|
||||||
foreach ($routes as $dir) {
|
foreach ($routes as $dir) {
|
||||||
if (in_array($dir, $config['draft_routes'], true)) {
|
if (in_array($dir, $config['draft_routes'], true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count every non-draft routable page, regardless of
|
||||||
|
// whether it ends up indexed (a noindex or Response-only
|
||||||
|
// page still counts) — this is a stable fingerprint of the
|
||||||
|
// routable page *set*, so isStale() can notice a deletion,
|
||||||
|
// which the newest-mtime check alone can't (deleting a page
|
||||||
|
// only ever lowers the max mtime, never raises it).
|
||||||
|
$sourceCount++;
|
||||||
|
|
||||||
$mtime = self::sourceMtime($config['pages_dirs'], $dir);
|
$mtime = self::sourceMtime($config['pages_dirs'], $dir);
|
||||||
$newestMtime = max($newestMtime, $mtime);
|
$newestMtime = max($newestMtime, $mtime);
|
||||||
|
|
||||||
@@ -140,8 +149,8 @@ final class ContentIndexer
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pdo->prepare('DELETE FROM content_index_meta')->execute();
|
$pdo->prepare('DELETE FROM content_index_meta')->execute();
|
||||||
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, indexed_at) VALUES (1, ?, ?)')
|
$pdo->prepare('INSERT INTO content_index_meta (id, newest_source_mtime, source_count, indexed_at) VALUES (1, ?, ?, ?)')
|
||||||
->execute([$newestMtime, gmdate('Y-m-d\TH:i:s\Z')]);
|
->execute([$newestMtime, $sourceCount, gmdate('Y-m-d\TH:i:s\Z')]);
|
||||||
|
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -166,17 +175,26 @@ final class ContentIndexer
|
|||||||
{
|
{
|
||||||
$pdo = Db::connection();
|
$pdo = Db::connection();
|
||||||
|
|
||||||
$meta = $pdo->query('SELECT newest_source_mtime FROM content_index_meta WHERE id = 1')->fetch();
|
$meta = $pdo->query('SELECT newest_source_mtime, source_count FROM content_index_meta WHERE id = 1')->fetch();
|
||||||
if ($meta === false) {
|
if ($meta === false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$newest = 0;
|
$newest = 0;
|
||||||
|
$count = 0;
|
||||||
foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) {
|
foreach (Overlay::listPageDirs($config['pages_dirs']) as $dir) {
|
||||||
|
if (in_array($dir, $config['draft_routes'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
$newest = max($newest, self::sourceMtime($config['pages_dirs'], $dir));
|
$newest = max($newest, self::sourceMtime($config['pages_dirs'], $dir));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $newest > (int) $meta['newest_source_mtime'];
|
// A newer source file means an edit/addition; a changed page count
|
||||||
|
// means a page was deleted (or a draft toggled) — the mtime check
|
||||||
|
// alone can't see a deletion, since removing a page only lowers the
|
||||||
|
// max mtime. Either signal means the index is stale.
|
||||||
|
return $newest > (int) $meta['newest_source_mtime'] || $count !== (int) $meta['source_count'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+5
-1
@@ -5,7 +5,11 @@
|
|||||||
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
$uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
|
||||||
|
|
||||||
if ($uri !== '/' && str_ends_with($uri, '/')) {
|
if ($uri !== '/' && str_ends_with($uri, '/')) {
|
||||||
header('Location: ' . rtrim($uri, '/'), true, 301);
|
// Preserve the query string across the canonical redirect — Apache's
|
||||||
|
// .htaccess does this automatically, so dev must too or post/redirect
|
||||||
|
// flows like /contact/?sent=1 lose their flags under `php -S`.
|
||||||
|
$query = parse_url($_SERVER['REQUEST_URI'], PHP_URL_QUERY);
|
||||||
|
header('Location: ' . rtrim($uri, '/') . ($query !== null ? '?' . $query : ''), true, 301);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user