Files
novaconium/novaconium/lib/Comments.php
T
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

85 lines
3.1 KiB
PHP

<?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);
}
}