WebOrbiton
v2.0.0.0

Publisium

336 lines · 15.8 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/language.php';
  7. require_once __DIR__ . '/site-front.php';
  8. require_once __DIR__ . '/user-auth.php';
  9. require_once __DIR__ . '/antibot.php';
  10. require_once __DIR__ . '/csrf.php';
  11. ​
  12. // 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).
  13. // Comments are plain text: no HTML or links are ever rendered.
  14. final class SiteComments
  15. {
  16. public const WHO = [
  17. 'users' => 'Only readers who are logged in',
  18. 'everyone' => 'Everyone (guests type a name and a security code)',
  19. ];
  20. ​
  21. public const MODERATION = [
  22. 'all' => 'Approve every comment before it appears',
  23. 'guests' => 'Approve comments from guests, publish logged-in readers right away',
  24. 'none' => 'Publish right away (you can still hide or delete them)',
  25. ];
  26. ​
  27. public const EMAIL = [
  28. 'off' => 'Don’t ask for an email address',
  29. 'required' => 'Guests must enter an email address (only your team sees it)',
  30. ];
  31. ​
  32. public const MAX_NAME = 60;
  33. public const MAX_EMAIL = 190;
  34. public const MAX_BODY = 3000;
  35. private const MAX_LINKS = 2;
  36. private const MAX_SHOWN = 500;
  37. private const FLASH_KEY = 'site_comments_flash';
  38. ​
  39. public static function isActive(): bool
  40. {
  41. try {
  42. $statement = Database::site()->query("SELECT setting_value FROM site_settings WHERE setting_key = 'comments_provider' LIMIT 1");
  43. ​
  44. return (string) $statement->fetchColumn() === 'builtin';
  45. } catch (PDOException $exception) {
  46. return false;
  47. }
  48. }
  49. ​
  50. public static function pendingCount(): int
  51. {
  52. try {
  53. return (int) Database::site()->query("SELECT COUNT(*) FROM article_comments WHERE status = 'pending'")->fetchColumn();
  54. } catch (PDOException $exception) {
  55. return 0;
  56. }
  57. }
  58. ​
  59. public static function render(array $settings, array $article): string
  60. {
  61. $articleId = (int) $article['id'];
  62. $text = static fn(string $key, string $default): string => htmlspecialchars(Language::get($key, $default), ENT_QUOTES);
  63. ​
  64. $flash = $_SESSION[self::FLASH_KEY] ?? null;
  65. if (is_array($flash) && (int) ($flash['article'] ?? 0) === $articleId) {
  66. unset($_SESSION[self::FLASH_KEY]);
  67. } else {
  68. $flash = null;
  69. }
  70. ​
  71. $html = '<div class="site-comments" id="comments">';
  72. ​
  73. $comments = self::approvedFor($articleId);
  74. if ($comments === []) {
  75. $html .= '<p class="site-comments-empty">' . $text('site_comments_empty', 'No comments yet. Be the first to share your thoughts.') . '</p>';
  76. } else {
  77. $replies = [];
  78. foreach ($comments as $comment) {
  79. if ($comment['parent_id'] !== null) {
  80. $replies[(int) $comment['parent_id']][] = $comment;
  81. }
  82. }
  83. $html .= '<ol class="site-comments-list">';
  84. foreach ($comments as $comment) {
  85. if ($comment['parent_id'] !== null) {
  86. continue;
  87. }
  88. $html .= '<li>' . self::commentHtml($comment, true);
  89. if (isset($replies[(int) $comment['id']])) {
  90. $html .= '<ol class="site-comments-replies">';
  91. foreach ($replies[(int) $comment['id']] as $reply) {
  92. $html .= '<li>' . self::commentHtml($reply, false) . '</li>';
  93. }
  94. $html .= '</ol>';
  95. }
  96. $html .= '</li>';
  97. }
  98. $html .= '</ol>';
  99. }
  100. ​
  101. $html .= self::formHtml($settings, $articleId, is_array($flash) ? $flash : null);
  102. ​
  103. return $html . '</div>';
  104. }
  105. ​
  106. private static function approvedFor(int $articleId): array
  107. {
  108. try {
  109. $statement = Database::site()->prepare(
  110. "SELECT id, parent_id, user_account_id, author_name, body, created_at FROM article_comments
  111. WHERE article_id = :article AND status = 'approved'
  112. ORDER BY created_at ASC, id ASC LIMIT " . self::MAX_SHOWN
  113. );
  114. $statement->execute(['article' => $articleId]);
  115. ​
  116. return $statement->fetchAll();
  117. } catch (PDOException $exception) {
  118. return [];
  119. }
  120. }
  121. ​
  122. private static function commentHtml(array $comment, bool $canReply): string
  123. {
  124. $id = (int) $comment['id'];
  125. $name = htmlspecialchars((string) $comment['author_name']);
  126. $time = strtotime((string) $comment['created_at']) ?: time();
  127. ​
  128. return '<article class="site-comment" id="comment-' . $id . '">'
  129. . '<header><strong>' . $name . '</strong>'
  130. . '<time datetime="' . date('c', $time) . '">' . htmlspecialchars(SiteFront::formatDate($time)) . '</time></header>'
  131. . '<div class="site-comment-body">' . self::formatBody((string) $comment['body']) . '</div>'
  132. . ($canReply ? '<button type="button" class="site-comment-reply" data-comment-id="' . $id . '" data-comment-name="' . $name . '" hidden>'
  133. . htmlspecialchars(Language::get('site_comments_reply', 'Reply')) . '</button>' : '')
  134. . '</article>';
  135. }
  136. ​
  137. public static function formatBody(string $body): string
  138. {
  139. return nl2br(htmlspecialchars($body), false);
  140. }
  141. ​
  142. private static function formHtml(array $settings, int $articleId, ?array $flash): string
  143. {
  144. $text = static fn(string $key, string $default): string => htmlspecialchars(Language::get($key, $default), ENT_QUOTES);
  145. $user = UserAuth::user();
  146. $guestsAllowed = ($settings['comments_who'] ?? 'users') === 'everyone';
  147. $emailRequired = ($settings['comments_email'] ?? 'off') === 'required';
  148. $old = is_array($flash['old'] ?? null) ? $flash['old'] : [];
  149. ​
  150. $html = '<div class="site-comments-form" id="comment-form">';
  151. if ($flash !== null) {
  152. $html .= '<div class="site-comments-' . ($flash['type'] === 'success' ? 'success' : 'error') . '" role="status">' . htmlspecialchars((string) $flash['message']) . '</div>';
  153. }
  154. ​
  155. if ($user === null && !$guestsAllowed) {
  156. return $html . '<p class="site-comments-login">' . $text('site_comments_login_required', 'Log in to join the discussion.')
  157. . ' <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>';
  158. }
  159. ​
  160. $action = rtrim((string) Config::get('APP_BASE_PATH', ''), '/') . '/comment-send.php';
  161. $html .= '<form method="post" action="' . htmlspecialchars($action, ENT_QUOTES) . '">'
  162. . Csrf::field()
  163. . '<input type="hidden" name="article_id" value="' . $articleId . '">'
  164. . '<input type="hidden" name="parent_id" value="" class="site-comments-parent">'
  165. . '<input type="hidden" name="return_to" value="' . htmlspecialchars((string) ($_SERVER['REQUEST_URI'] ?? ''), ENT_QUOTES) . '">'
  166. . '<p class="site-comments-replying" hidden>' . $text('site_comments_replying_to', 'Replying to') . ' <strong></strong> '
  167. . '<button type="button" class="site-comments-cancel">' . $text('site_comments_cancel_reply', 'Cancel') . '</button></p>';
  168. ​
  169. if ($user !== null) {
  170. $html .= '<p class="site-comments-as">' . $text('site_comments_as', 'Commenting as') . ' <strong>' . htmlspecialchars((string) $user['display_name']) . '</strong></p>';
  171. } else {
  172. $html .= AntiBot::field('comment')
  173. . '<label for="comment-name">' . $text('site_comments_name', 'Your name') . '</label>'
  174. . '<input id="comment-name" type="text" name="name" required maxlength="' . self::MAX_NAME . '" autocomplete="name" value="' . htmlspecialchars((string) ($old['name'] ?? ''), ENT_QUOTES) . '">';
  175. if ($emailRequired) {
  176. $html .= '<label for="comment-email">' . $text('site_comments_email', 'Your email') . '</label>'
  177. . '<input id="comment-email" type="email" name="email" required maxlength="' . self::MAX_EMAIL . '" autocomplete="email" value="' . htmlspecialchars((string) ($old['email'] ?? ''), ENT_QUOTES) . '">';
  178. }
  179. }
  180. ​
  181. $html .= '<label for="comment-body">' . $text('site_comments_body', 'Your comment') . '</label>'
  182. . '<textarea id="comment-body" name="body" rows="5" required maxlength="' . self::MAX_BODY . '">' . htmlspecialchars((string) ($old['body'] ?? '')) . '</textarea>';
  183. ​
  184. if ($user === null) {
  185. $html .= '<div class="antibot-box">'
  186. . '<div class="antibot-image">' . AntiBot::image('comment') . '</div>'
  187. . '<label for="comment-antibot-answer">' . $text('site_comments_security_code', 'Security code') . '</label>'
  188. . '<input id="comment-antibot-answer" type="text" name="antibot_answer" required autocomplete="off" maxlength="6" spellcheck="false">'
  189. . '</div>';
  190. }
  191. ​
  192. $privacy = $emailRequired && $user === null
  193. ? $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.')
  194. : $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.');
  195. ​
  196. return $html . '<p class="site-comments-privacy">' . $privacy . '</p>'
  197. . '<button type="submit">' . $text('site_comments_submit', 'Post comment') . '</button>'
  198. . '</form></div>';
  199. }
  200. ​
  201. // Checks and saves a posted comment, then leaves a message for the next page view.
  202. public static function handleSubmission(array $post): int
  203. {
  204. $articleId = (int) ($post['article_id'] ?? 0);
  205. $old = [
  206. 'name' => self::singleLine((string) ($post['name'] ?? ''), self::MAX_NAME),
  207. 'email' => strtolower(self::singleLine((string) ($post['email'] ?? ''), self::MAX_EMAIL)),
  208. 'body' => self::cleanBody((string) ($post['body'] ?? '')),
  209. ];
  210. $fail = static function (string $key, string $default) use ($articleId, $old): int {
  211. $_SESSION[self::FLASH_KEY] = ['article' => $articleId, 'type' => 'error', 'message' => Language::get($key, $default), 'old' => $old];
  212. ​
  213. return $articleId;
  214. };
  215. ​
  216. $settings = SiteFront::settings();
  217. $article = self::openArticle($settings, $articleId);
  218. if ($article === null) {
  219. return $fail('site_comments_closed', 'Comments are closed for this article.');
  220. }
  221. ​
  222. $user = UserAuth::user();
  223. if ($user === null && ($settings['comments_who'] ?? 'users') !== 'everyone') {
  224. return $fail('site_comments_login_required', 'Log in to join the discussion.');
  225. }
  226. ​
  227. $csrfOk = Csrf::verify($post['csrf_token'] ?? null);
  228. $botOk = $user !== null
  229. ? ($post['website'] ?? '') === ''
  230. : AntiBot::verify('comment', $post['antibot_answer'] ?? null, $post['antibot_started'] ?? null, $post['website'] ?? null);
  231. if (!$csrfOk || !$botOk) {
  232. return $fail('site_comments_security_failed', 'The security code was wrong or the form expired. Please try again.');
  233. }
  234. ​
  235. $name = $user !== null ? self::singleLine((string) $user['display_name'], self::MAX_NAME) : $old['name'];
  236. if ($name === '' || mb_strlen($old['body']) < 2) {
  237. return $fail('site_comments_invalid', 'Please enter your name and a comment.');
  238. }
  239. ​
  240. // Only guests are asked; logged-in readers already have an email on their account.
  241. $email = null;
  242. if ($user === null && ($settings['comments_email'] ?? 'off') === 'required') {
  243. if (filter_var($old['email'], FILTER_VALIDATE_EMAIL) === false) {
  244. return $fail('site_comments_email_invalid', 'Please enter a valid email address.');
  245. }
  246. $email = $old['email'];
  247. }
  248. ​
  249. require_once __DIR__ . '/login-throttle.php';
  250. $identifier = $user !== null ? 'user:' . (int) $user['id'] : 'guest:' . session_id();
  251. if (LoginThrottle::secondsUntilAllowed('comment', $identifier) > 0) {
  252. return $fail('site_comments_rate_limited', 'You are posting too fast. Please wait a few minutes and try again.');
  253. }
  254. ​
  255. $db = Database::site();
  256. $parentId = null;
  257. $requestedParent = (int) ($post['parent_id'] ?? 0);
  258. if ($requestedParent > 0) {
  259. $parent = $db->prepare("SELECT id, parent_id FROM article_comments WHERE id = :id AND article_id = :article AND status = 'approved' LIMIT 1");
  260. $parent->execute(['id' => $requestedParent, 'article' => $articleId]);
  261. $parentRow = $parent->fetch();
  262. if ($parentRow) {
  263. // Replies stay one level deep: a reply to a reply joins the same thread.
  264. $parentId = $parentRow['parent_id'] !== null ? (int) $parentRow['parent_id'] : (int) $parentRow['id'];
  265. }
  266. }
  267. ​
  268. $moderation = $settings['comments_moderation'] ?? 'all';
  269. $needsReview = $moderation === 'all'
  270. || ($moderation === 'guests' && $user === null)
  271. || preg_match_all('#https?://|www\.#i', $old['body']) > self::MAX_LINKS;
  272. $status = $needsReview ? 'pending' : 'approved';
  273. ​
  274. $db->prepare(
  275. '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)'
  276. )->execute([
  277. 'article' => $articleId,
  278. 'parent' => $parentId,
  279. 'user' => $user !== null ? (int) $user['id'] : null,
  280. 'name' => $name,
  281. 'email' => $email,
  282. 'body' => $old['body'],
  283. 'status' => $status,
  284. ]);
  285. LoginThrottle::recordFailure('comment', $identifier);
  286. ​
  287. $_SESSION[self::FLASH_KEY] = [
  288. 'article' => $articleId,
  289. 'type' => 'success',
  290. 'message' => $status === 'pending'
  291. ? Language::get('site_comments_pending', 'Thanks! Your comment will appear after a moderator approves it.')
  292. : Language::get('site_comments_published', 'Thanks! Your comment is published.'),
  293. ];
  294. ​
  295. return $articleId;
  296. }
  297. ​
  298. // The article must be published, have comments turned on, and not be behind a paywall for this reader.
  299. private static function openArticle(array $settings, int $articleId): ?array
  300. {
  301. if ($articleId <= 0 || $settings['comments_enabled'] !== '1' || $settings['comments_provider'] !== 'builtin') {
  302. return null;
  303. }
  304. ​
  305. $statement = Database::site()->prepare("SELECT id, slug, access_type FROM articles WHERE id = :id AND status = 'published' AND deleted_at IS NULL LIMIT 1");
  306. $statement->execute(['id' => $articleId]);
  307. $article = $statement->fetch();
  308. if (!$article) {
  309. return null;
  310. }
  311. ​
  312. if ($article['access_type'] === 'paid') {
  313. $user = UserAuth::user();
  314. if ($user === null || !UserAuth::hasActiveSubscription((int) $user['id'])) {
  315. return null;
  316. }
  317. }
  318. ​
  319. return $article;
  320. }
  321. ​
  322. private static function cleanBody(string $body): string
  323. {
  324. $body = str_replace(["\r\n", "\r"], "\n", $body);
  325. $body = (string) preg_replace('/[\x00-\x08\x0B-\x1F\x7F]+/u', '', $body);
  326. $body = (string) preg_replace("/\n{3,}/", "\n\n", $body);
  327. ​
  328. return mb_substr(trim($body), 0, self::MAX_BODY);
  329. }
  330. ​
  331. private static function singleLine(string $value, int $limit): string
  332. {
  333. return mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value)), 0, $limit);
  334. }
  335. }
  336. ​