v2.0.0.1
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
- require_once __DIR__ . '/language.php';
- require_once __DIR__ . '/site-front.php';
- require_once __DIR__ . '/user-auth.php';
- require_once __DIR__ . '/antibot.php';
- require_once __DIR__ . '/csrf.php';
- require_once __DIR__ . '/author-profile.php';
-
- // Publisium's own comments. Stored in article_comments with a name and the text (an email only when the site requires it, never an IP).
- // Comments are plain text: no HTML or links are ever rendered.
- final class SiteComments
- {
- public const WHO = [
- 'users' => 'Only readers who are logged in',
- 'everyone' => 'Everyone (guests type a name and a security code)',
- ];
-
- public const MODERATION = [
- 'all' => 'Approve every comment before it appears',
- 'guests' => 'Approve comments from guests, publish logged-in readers right away',
- 'none' => 'Publish right away (you can still hide or delete them)',
- ];
-
- public const EMAIL = [
- 'off' => 'Don’t ask for an email address',
- 'required' => 'Guests must enter an email address (only your team sees it)',
- ];
-
- public const MAX_NAME = 60;
- public const MAX_EMAIL = 190;
- public const MAX_BODY = 3000;
- private const MAX_LINKS = 2;
- private const MAX_SHOWN = 500;
- private const FLASH_KEY = 'site_comments_flash';
-
- public static function isActive(): bool
- {
- try {
- $statement = Database::site()->query("SELECT setting_value FROM site_settings WHERE setting_key = 'comments_provider' LIMIT 1");
-
- return (string) $statement->fetchColumn() === 'builtin';
- } catch (PDOException $exception) {
- return false;
- }
- }
-
- public static function pendingCount(): int
- {
- try {
- return (int) Database::site()->query("SELECT COUNT(*) FROM article_comments WHERE status = 'pending'")->fetchColumn();
- } catch (PDOException $exception) {
- return 0;
- }
- }
-
- public static function render(array $settings, array $article): string
- {
- $articleId = (int) $article['id'];
- $text = static fn(string $key, string $default): string => htmlspecialchars(Language::get($key, $default), ENT_QUOTES);
-
- $flash = $_SESSION[self::FLASH_KEY] ?? null;
- if (is_array($flash) && (int) ($flash['article'] ?? 0) === $articleId) {
- unset($_SESSION[self::FLASH_KEY]);
- } else {
- $flash = null;
- }
-
- $html = '<div class="site-comments" id="comments">';
-
- $comments = self::approvedFor($articleId);
- if ($comments === []) {
- $html .= '<p class="site-comments-empty">' . $text('site_comments_empty', 'No comments yet. Be the first to share your thoughts.') . '</p>';
- } else {
- $replies = [];
- foreach ($comments as $comment) {
- if ($comment['parent_id'] !== null) {
- $replies[(int) $comment['parent_id']][] = $comment;
- }
- }
- $html .= '<ol class="site-comments-list">';
- foreach ($comments as $comment) {
- if ($comment['parent_id'] !== null) {
- continue;
- }
- $html .= '<li>' . self::commentHtml($comment, true);
- if (isset($replies[(int) $comment['id']])) {
- $html .= '<ol class="site-comments-replies">';
- foreach ($replies[(int) $comment['id']] as $reply) {
- $html .= '<li>' . self::commentHtml($reply, false) . '</li>';
- }
- $html .= '</ol>';
- }
- $html .= '</li>';
- }
- $html .= '</ol>';
- }
-
- $html .= self::formHtml($settings, $articleId, is_array($flash) ? $flash : null);
-
- return $html . '</div>';
- }
-
- private static function approvedFor(int $articleId): array
- {
- try {
- $statement = Database::site()->prepare(
- "SELECT c.id, c.parent_id, c.user_account_id, t.id AS team_member_id, COALESCE(t.display_name, c.author_name) AS author_name,
- p.slug AS author_slug, c.body, c.created_at
- FROM article_comments c
- LEFT JOIN team_accounts t ON t.id = c.team_account_id AND t.status = 'active'
- LEFT JOIN author_profiles p ON p.account_id = c.team_account_id AND p.is_public = 1
- WHERE c.article_id = :article AND c.status = 'approved'
- ORDER BY c.created_at ASC, c.id ASC LIMIT " . self::MAX_SHOWN
- );
- $statement->execute(['article' => $articleId]);
-
- return $statement->fetchAll();
- } catch (PDOException $exception) {
- return [];
- }
- }
-
- private static function commentHtml(array $comment, bool $canReply): string
- {
- $id = (int) $comment['id'];
- $name = htmlspecialchars((string) $comment['author_name']);
- $time = strtotime((string) $comment['created_at']) ?: time();
- $isTeam = !empty($comment['team_member_id']);
-
- $nameHtml = '<strong>' . $name . '</strong>';
- if ($isTeam && !empty($comment['author_slug']) && AuthorProfile::isEnabled()) {
- $nameHtml = '<strong><a href="' . htmlspecialchars(SiteFront::authorUrl((string) $comment['author_slug']), ENT_QUOTES) . '">' . $name . '</a></strong>';
- }
- if ($isTeam) {
- $teamLabel = htmlspecialchars(Language::get('site_comments_team', 'Team'), ENT_QUOTES);
- $nameHtml .= '<span class="site-comment-team" title="' . $teamLabel . '">'
- . '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>'
- . $teamLabel . '</span>';
- }
-
- return '<article class="site-comment' . ($isTeam ? ' site-comment-by-team' : '') . '" id="comment-' . $id . '">'
- . '<header>' . $nameHtml
- . '<time datetime="' . date('c', $time) . '">' . htmlspecialchars(SiteFront::formatDate($time)) . '</time></header>'
- . '<div class="site-comment-body">' . self::formatBody((string) $comment['body']) . '</div>'
- . ($canReply ? '<button type="button" class="site-comment-reply" data-comment-id="' . $id . '" data-comment-name="' . $name . '" hidden>'
- . htmlspecialchars(Language::get('site_comments_reply', 'Reply')) . '</button>' : '')
- . '</article>';
- }
-
- public static function formatBody(string $body): string
- {
- return nl2br(htmlspecialchars($body), false);
- }
-
- private static function formHtml(array $settings, int $articleId, ?array $flash): string
- {
- $text = static fn(string $key, string $default): string => htmlspecialchars(Language::get($key, $default), ENT_QUOTES);
- $user = UserAuth::user();
- $guestsAllowed = ($settings['comments_who'] ?? 'users') === 'everyone';
- $emailRequired = ($settings['comments_email'] ?? 'off') === 'required';
- $old = is_array($flash['old'] ?? null) ? $flash['old'] : [];
-
- $html = '<div class="site-comments-form" id="comment-form">';
- if ($flash !== null) {
- $html .= '<div class="site-comments-' . ($flash['type'] === 'success' ? 'success' : 'error') . '" role="status">' . htmlspecialchars((string) $flash['message']) . '</div>';
- }
-
- if ($user === null && !$guestsAllowed) {
- return $html . '<p class="site-comments-login">' . $text('site_comments_login_required', 'Log in to join the discussion.')
- . ' <a href="' . htmlspecialchars(rtrim((string) Config::get('APP_BASE_PATH', ''), '/') . '/user-login.php?mode=login', ENT_QUOTES) . '">' . $text('site_comments_login', 'Log in') . '</a></p></div>';
- }
-
- $action = rtrim((string) Config::get('APP_BASE_PATH', ''), '/') . '/comment-send.php';
- $html .= '<form method="post" action="' . htmlspecialchars($action, ENT_QUOTES) . '">'
- . Csrf::field()
- . '<input type="hidden" name="article_id" value="' . $articleId . '">'
- . '<input type="hidden" name="parent_id" value="" class="site-comments-parent">'
- . '<input type="hidden" name="return_to" value="' . htmlspecialchars((string) ($_SERVER['REQUEST_URI'] ?? ''), ENT_QUOTES) . '">'
- . '<p class="site-comments-replying" hidden>' . $text('site_comments_replying_to', 'Replying to') . ' <strong></strong> '
- . '<button type="button" class="site-comments-cancel">' . $text('site_comments_cancel_reply', 'Cancel') . '</button></p>';
-
- if ($user !== null) {
- $html .= '<p class="site-comments-as">' . $text('site_comments_as', 'Commenting as') . ' <strong>' . htmlspecialchars((string) $user['display_name']) . '</strong></p>';
- } else {
- $html .= AntiBot::field('comment')
- . '<label for="comment-name">' . $text('site_comments_name', 'Your name') . '</label>'
- . '<input id="comment-name" type="text" name="name" required maxlength="' . self::MAX_NAME . '" autocomplete="name" value="' . htmlspecialchars((string) ($old['name'] ?? ''), ENT_QUOTES) . '">';
- if ($emailRequired) {
- $html .= '<label for="comment-email">' . $text('site_comments_email', 'Your email') . '</label>'
- . '<input id="comment-email" type="email" name="email" required maxlength="' . self::MAX_EMAIL . '" autocomplete="email" value="' . htmlspecialchars((string) ($old['email'] ?? ''), ENT_QUOTES) . '">';
- }
- }
-
- $html .= '<label for="comment-body">' . $text('site_comments_body', 'Your comment') . '</label>'
- . '<textarea id="comment-body" name="body" rows="5" required maxlength="' . self::MAX_BODY . '">' . htmlspecialchars((string) ($old['body'] ?? '')) . '</textarea>';
-
- if ($user === null) {
- $html .= '<div class="antibot-box">'
- . '<div class="antibot-image">' . AntiBot::image('comment') . '</div>'
- . '<label for="comment-antibot-answer">' . $text('site_comments_security_code', 'Security code') . '</label>'
- . '<input id="comment-antibot-answer" type="text" name="antibot_answer" required autocomplete="off" maxlength="6" spellcheck="false">'
- . '</div>';
- }
-
- $privacy = $emailRequired && $user === null
- ? $text('site_comments_privacy_email', 'Your name and your comment are shown publicly. Your email address is only visible to the site team and is never published. Your IP address is not stored with the comment.')
- : $text('site_comments_privacy', 'Only your name and your comment are saved. No email address is needed, and your IP address is not stored with the comment.');
-
- return $html . '<p class="site-comments-privacy">' . $privacy . '</p>'
- . '<button type="submit">' . $text('site_comments_submit', 'Post comment') . '</button>'
- . '</form></div>';
- }
-
- // Checks and saves a posted comment, then leaves a message for the next page view.
- public static function handleSubmission(array $post): int
- {
- $articleId = (int) ($post['article_id'] ?? 0);
- $old = [
- 'name' => self::singleLine((string) ($post['name'] ?? ''), self::MAX_NAME),
- 'email' => strtolower(self::singleLine((string) ($post['email'] ?? ''), self::MAX_EMAIL)),
- 'body' => self::cleanBody((string) ($post['body'] ?? '')),
- ];
- $fail = static function (string $key, string $default) use ($articleId, $old): int {
- $_SESSION[self::FLASH_KEY] = ['article' => $articleId, 'type' => 'error', 'message' => Language::get($key, $default), 'old' => $old];
-
- return $articleId;
- };
-
- $settings = SiteFront::settings();
- $article = self::openArticle($settings, $articleId);
- if ($article === null) {
- return $fail('site_comments_closed', 'Comments are closed for this article.');
- }
-
- $user = UserAuth::user();
- if ($user === null && ($settings['comments_who'] ?? 'users') !== 'everyone') {
- return $fail('site_comments_login_required', 'Log in to join the discussion.');
- }
-
- $csrfOk = Csrf::verify($post['csrf_token'] ?? null);
- $botOk = $user !== null
- ? ($post['website'] ?? '') === ''
- : AntiBot::verify('comment', $post['antibot_answer'] ?? null, $post['antibot_started'] ?? null, $post['website'] ?? null);
- if (!$csrfOk || !$botOk) {
- return $fail('site_comments_security_failed', 'The security code was wrong or the form expired. Please try again.');
- }
-
- $name = $user !== null ? self::singleLine((string) $user['display_name'], self::MAX_NAME) : $old['name'];
- if ($name === '' || mb_strlen($old['body']) < 2) {
- return $fail('site_comments_invalid', 'Please enter your name and a comment.');
- }
-
- // Only guests are asked; logged-in readers already have an email on their account.
- $email = null;
- if ($user === null && ($settings['comments_email'] ?? 'off') === 'required') {
- if (filter_var($old['email'], FILTER_VALIDATE_EMAIL) === false) {
- return $fail('site_comments_email_invalid', 'Please enter a valid email address.');
- }
- $email = $old['email'];
- }
-
- require_once __DIR__ . '/login-throttle.php';
- $identifier = $user !== null ? 'user:' . (int) $user['id'] : 'guest:' . session_id();
- if (LoginThrottle::secondsUntilAllowed('comment', $identifier) > 0) {
- return $fail('site_comments_rate_limited', 'You are posting too fast. Please wait a few minutes and try again.');
- }
-
- $db = Database::site();
- $parentId = null;
- $requestedParent = (int) ($post['parent_id'] ?? 0);
- if ($requestedParent > 0) {
- $parent = $db->prepare("SELECT id, parent_id FROM article_comments WHERE id = :id AND article_id = :article AND status = 'approved' LIMIT 1");
- $parent->execute(['id' => $requestedParent, 'article' => $articleId]);
- $parentRow = $parent->fetch();
- if ($parentRow) {
- // Replies stay one level deep: a reply to a reply joins the same thread.
- $parentId = $parentRow['parent_id'] !== null ? (int) $parentRow['parent_id'] : (int) $parentRow['id'];
- }
- }
-
- $moderation = $settings['comments_moderation'] ?? 'all';
- $needsReview = $moderation === 'all'
- || ($moderation === 'guests' && $user === null)
- || preg_match_all('#https?://|www\.#i', $old['body']) > self::MAX_LINKS;
- $status = $needsReview ? 'pending' : 'approved';
-
- $db->prepare(
- 'INSERT INTO article_comments (article_id, parent_id, user_account_id, author_name, author_email, body, status) VALUES (:article, :parent, :user, :name, :email, :body, :status)'
- )->execute([
- 'article' => $articleId,
- 'parent' => $parentId,
- 'user' => $user !== null ? (int) $user['id'] : null,
- 'name' => $name,
- 'email' => $email,
- 'body' => $old['body'],
- 'status' => $status,
- ]);
- LoginThrottle::recordFailure('comment', $identifier);
-
- $_SESSION[self::FLASH_KEY] = [
- 'article' => $articleId,
- 'type' => 'success',
- 'message' => $status === 'pending'
- ? Language::get('site_comments_pending', 'Thanks! Your comment will appear after a moderator approves it.')
- : Language::get('site_comments_published', 'Thanks! Your comment is published.'),
- ];
-
- return $articleId;
- }
-
- public static function replyAsTeam(int $commentId, array $teamAccount, string $body, bool $canModerate): array
- {
- $body = self::cleanBody($body);
- if (mb_strlen($body) < 2) {
- return ['Write a reply first.', 'error'];
- }
-
- $db = Database::site();
- $statement = $db->prepare(
- "SELECT c.id, c.parent_id, c.article_id, a.author_id
- FROM article_comments c
- JOIN articles a ON a.id = c.article_id AND a.status = 'published' AND a.deleted_at IS NULL
- WHERE c.id = :id AND c.status = 'approved' LIMIT 1"
- );
- $statement->execute(['id' => $commentId]);
- $parent = $statement->fetch();
-
- if (!$parent) {
- return ['You can only reply to published comments on published articles.', 'error'];
- }
-
- if (!$canModerate && (int) $parent['author_id'] !== (int) $teamAccount['id']) {
- return ['You can reply only to comments on your own articles.', 'error'];
- }
-
- $db->prepare(
- "INSERT INTO article_comments (article_id, parent_id, team_account_id, author_name, body, status) VALUES (:article, :parent, :team, :name, :body, 'approved')"
- )->execute([
- 'article' => (int) $parent['article_id'],
- 'parent' => $parent['parent_id'] !== null ? (int) $parent['parent_id'] : (int) $parent['id'],
- 'team' => (int) $teamAccount['id'],
- 'name' => self::singleLine((string) $teamAccount['display_name'], self::MAX_NAME),
- 'body' => $body,
- ]);
-
- return ['Your reply is published.', 'success'];
- }
-
- // The article must be published, have comments turned on, and not be behind a paywall for this reader.
- private static function openArticle(array $settings, int $articleId): ?array
- {
- if ($articleId <= 0 || $settings['comments_enabled'] !== '1' || $settings['comments_provider'] !== 'builtin') {
- return null;
- }
-
- $statement = Database::site()->prepare("SELECT id, slug, access_type FROM articles WHERE id = :id AND status = 'published' AND deleted_at IS NULL LIMIT 1");
- $statement->execute(['id' => $articleId]);
- $article = $statement->fetch();
- if (!$article) {
- return null;
- }
-
- if ($article['access_type'] === 'paid') {
- $user = UserAuth::user();
- if ($user === null || !UserAuth::hasActiveSubscription((int) $user['id'])) {
- return null;
- }
- }
-
- return $article;
- }
-
- private static function cleanBody(string $body): string
- {
- $body = str_replace(["\r\n", "\r"], "\n", $body);
- $body = (string) preg_replace('/[\x00-\x08\x0B-\x1F\x7F]+/u', '', $body);
- $body = (string) preg_replace("/\n{3,}/", "\n\n", $body);
-
- return mb_substr(trim($body), 0, self::MAX_BODY);
- }
-
- private static function singleLine(string $value, int $limit): string
- {
- return mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value)), 0, $limit);
- }
- }
-