64defe7f74
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>
85 lines
3.1 KiB
PHP
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);
|
|
}
|
|
}
|