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>
This commit is contained in:
@@ -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 %}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Demonstrates attaching Lib\Comments to a page — see /admin/docs/comments.
|
||||||
|
// This is the one post under App/pages/blog/ with a sidecar, specifically
|
||||||
|
// so it can carry a live comment thread; giving it one is what excludes
|
||||||
|
// it from the static HTML cache (Renderer::render() only ever caches
|
||||||
|
// sidecar-less pages) — every other post stays sidecar-less/cached and
|
||||||
|
// has no thread, since blog/_layout/layout.twig only includes one when
|
||||||
|
// 'comments' is present in the returned context.
|
||||||
|
|
||||||
|
use App\AdminAuth;
|
||||||
|
use App\Response;
|
||||||
|
use Lib\Comments;
|
||||||
|
use Lib\Csrf;
|
||||||
|
use Lib\FormValidator;
|
||||||
|
use Lib\Input;
|
||||||
|
use Lib\SpamGuard;
|
||||||
|
|
||||||
|
$pagePath = Comments::currentPagePath();
|
||||||
|
$user = AdminAuth::currentUser();
|
||||||
|
$spamGuard = new SpamGuard();
|
||||||
|
$commentError = null;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!Csrf::verify(Input::post('csrf_token'))) {
|
||||||
|
return Response::redirect($pagePath . '?error=security');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return Response::redirect($pagePath . '?error=login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = Input::post('body', '');
|
||||||
|
$validator = (new FormValidator())
|
||||||
|
->required($body, 'body', 'Enter a comment.')
|
||||||
|
->maxLength($body, 'body', 2000, 'Comments are 2000 characters max.');
|
||||||
|
|
||||||
|
if ($validator->passes()) {
|
||||||
|
// Same "bot gets an identical response" reasoning as the contact
|
||||||
|
// form — see App/pages/contact/index.php. Only the insert is
|
||||||
|
// skipped for spam.
|
||||||
|
if (!$spamGuard->isSpam(Input::post())) {
|
||||||
|
Comments::create($pagePath, $user['id'], $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::redirect($pagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentError = $validator->errors()['body'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'comments' => Comments::forPage($pagePath),
|
||||||
|
'currentUser' => $user,
|
||||||
|
'commentError' => $commentError,
|
||||||
|
'renderedAt' => $spamGuard->renderedAt(),
|
||||||
|
'csrfField' => Csrf::fieldName(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
];
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends layout %}
|
||||||
|
|
||||||
|
{% import '_layout/icons.twig' as icons %}
|
||||||
|
|
||||||
|
{% block title %}Comments Demo{% endblock %}
|
||||||
|
{% block description %}A worked example of Lib\Comments — this post has its own sidecar, unlike every other post here, specifically so it can carry a live comment thread.{% endblock %}
|
||||||
|
|
||||||
|
{% block robots %}index, follow{% endblock %}
|
||||||
|
{% block tags %}comments, meta{% endblock %}
|
||||||
|
{% block canonical %}{{ request_path|default('/') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block og_type %}article{% endblock %}
|
||||||
|
{% block og_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block og_description %}{{ block('description') }}{% endblock %}
|
||||||
|
{% block og_url %}{{ block('canonical') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block twitter_card %}summary{% endblock %}
|
||||||
|
{% block twitter_title %}{{ block('title') }}{% endblock %}
|
||||||
|
{% block twitter_description %}{{ block('description') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block blog_content %}
|
||||||
|
<h1>Comments Demo</h1>
|
||||||
|
|
||||||
|
<p>Unlike every other post under <code>App/pages/blog/</code>, this one has its own <code>index.php</code> sidecar — <code>App/pages/blog/comments-demo/index.php</code> — which reads and writes a <code>comments</code> table via <code>Lib\Comments</code> and returns a <code>comments</code> key in its context. <code>App/pages/blog/_layout/layout.twig</code> only includes the comment-thread partial (<code>novaconium/pages/_partials/comments/thread.twig</code>) when that key is present, so this is the only post here with a thread below.</p>
|
||||||
|
|
||||||
|
<p>Having a sidecar means this page is never served from the static HTML cache the way its sidecar-less siblings are (see <a class="icon-link" href="/admin/docs/caching">{{ icons.book() }}Static caching</a>) — an explicit, per-page tradeoff you accept the moment a page needs comments. See <a class="icon-link" href="/admin/docs/comments">{{ icons.users() }}Comments</a> for the full write-up of <code>Lib\Comments</code>, including why comments are tied to real logged-in accounts rather than anonymous name/email fields, and why they're auto-approved with after-the-fact moderation at <code>/admin/comments</code> rather than a pending queue.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
<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 & 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 & 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>
|
||||||
|
|||||||
+52
-28
@@ -78,34 +78,6 @@ Done) — 2026-07-14 for the first three, 2026-07-15 for Media/file manager.
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
### 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
|
### Ecommerce functionality
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
@@ -158,6 +130,58 @@ _Nothing yet._
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### In-house comments
|
||||||
|
|
||||||
|
- **Type:** Feature
|
||||||
|
- **Status:** Done
|
||||||
|
- **Priority:** Medium
|
||||||
|
- **Depends on:** Admin login & user management (Done), SQLite groundwork (Done)
|
||||||
|
- **Added:** 2026-07-14
|
||||||
|
- **Shipped:** 2026-07-15
|
||||||
|
|
||||||
|
A self-hosted comment thread — no third-party service (Disqus, Commento,
|
||||||
|
etc.) — `Lib\Comments` (`novaconium/lib/Comments.php`), a plain static
|
||||||
|
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 are
|
||||||
|
tied to a real logged-in account, never anonymous name/email fields —
|
||||||
|
`App\AdminAuth::currentUser()` already excludes disabled and unverified
|
||||||
|
accounts (see Email verification below), so there's nothing further to
|
||||||
|
check before accepting a comment from whoever it returns.
|
||||||
|
|
||||||
|
**Framework-level, not `App/migrations/`.** The original backlog draft
|
||||||
|
sketched the migration under `App/migrations/`, but comments — like Admin
|
||||||
|
auth and Media manager — is reusable framework functionality, not project
|
||||||
|
content, so the migration (`novaconium/migrations/0004_create_comments.sql`),
|
||||||
|
the class, and the moderation UI (`novaconium/pages/admin/comments/`) all
|
||||||
|
live under `novaconium/`, consistent with every other feature of this
|
||||||
|
shape.
|
||||||
|
|
||||||
|
**Moderation model: auto-approve, then moderate.** A comment is visible
|
||||||
|
the instant it's posted — no pending queue — since only a verified
|
||||||
|
account can post one at all, there's no anonymous-spam vector to pre-vet
|
||||||
|
against. An admin can hide (excluded at the query level, not just
|
||||||
|
visually) or permanently delete any comment after the fact at
|
||||||
|
`/admin/comments`, mirroring how `/admin/users` disables rather than
|
||||||
|
pre-vets accounts. Submission still runs through `Lib\SpamGuard`'s
|
||||||
|
honeypot/timing check and `Lib\Csrf`, same as the contact form.
|
||||||
|
|
||||||
|
**A page needs its own sidecar to use it.** This codebase has no
|
||||||
|
client-side JS/fetch anywhere — every dynamic feature is a plain
|
||||||
|
server-rendered POST form — so a comment thread is no different: giving a
|
||||||
|
page a sidecar is what excludes it from the static HTML cache (only
|
||||||
|
sidecar-less pages are ever cached), an explicit per-page tradeoff the
|
||||||
|
same as `/admin/media` and `/search`. `App/pages/blog/comments-demo/` is
|
||||||
|
the one post under `App/pages/blog/` with a sidecar, added specifically
|
||||||
|
to demonstrate the pattern without disturbing the other posts (several of
|
||||||
|
which reference `hello-world` by name as the canonical *sidecar-less*
|
||||||
|
example — retrofitting it instead would have made those references
|
||||||
|
wrong). The reusable Twig half is
|
||||||
|
`novaconium/pages/_partials/comments/thread.twig` — a `_`-prefixed,
|
||||||
|
never-routable partial, included with `{% if comments is defined %}` so
|
||||||
|
a layout shared by comment-enabled and sidecar-less pages alike can
|
||||||
|
include it unconditionally. Documented at `/admin/docs/comments`.
|
||||||
|
|
||||||
### Email verification for user accounts
|
### Email verification for user accounts
|
||||||
|
|
||||||
- **Type:** Feature
|
- **Type:** Feature
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user