diff --git a/_extensions/s2_blog/BlogUrlBuilder.php b/_extensions/s2_blog/BlogUrlBuilder.php index 596aa39..bba9fc1 100644 --- a/_extensions/s2_blog/BlogUrlBuilder.php +++ b/_extensions/s2_blog/BlogUrlBuilder.php @@ -13,8 +13,9 @@ class BlogUrlBuilder { - protected ?string $blogPath = null; - protected ?string $blogTagsPath = null; + private ?string $blogPath = null; + private ?string $absBlogPath = null; + private ?string $blogTagsPath = null; public function __construct( private readonly UrlBuilder $urlBuilder, @@ -29,6 +30,11 @@ public function main(): string return $this->blogPath ?? $this->blogPath = $this->urlBuilder->link($this->encodedBlogUrl() . '/'); } + public function absMain(): string + { + return $this->absBlogPath ?? $this->absBlogPath = $this->urlBuilder->absLink($this->encodedBlogUrl() . '/'); + } + public function favorite(): string { return $this->main() . rawurlencode($this->favoriteUrl) . '/'; @@ -74,6 +80,11 @@ public function postFromTimestamp(int $createTime, string $url): string return $this->main() . date('Y/m/d/', $createTime) . rawurlencode($url); } + public function absPostFromTimestamp(int $createTime, string $url): string + { + return $this->absMain() . date('Y/m/d/', $createTime) . rawurlencode($url); + } + public function postFromTimestampWithoutPrefix(int $createTime, string $url): string { return $this->encodedBlogUrl() . date('/Y/m/d', $createTime) . '/' . rawurlencode($url); diff --git a/_extensions/s2_blog/Controller/PostPageController.php b/_extensions/s2_blog/Controller/PostPageController.php index d328d96..9b74e49 100644 --- a/_extensions/s2_blog/Controller/PostPageController.php +++ b/_extensions/s2_blog/Controller/PostPageController.php @@ -202,7 +202,7 @@ private function get_post(Request $request, HtmlTemplate $template, int $year, i $template ->putInPlaceholder('meta_description', self::extractMetaDescriptions($row['text'])) ->putInPlaceholder('text', $this->viewer->render('post', $row, 's2_blog')) - ->putInPlaceholder('id', $post_id) + ->putInPlaceholder('id', md5('s2_blog_post_' . $post_id)) ->putInPlaceholder('head_title', s2_htmlencode($row['title'])) ; diff --git a/_extensions/s2_blog/Extension.php b/_extensions/s2_blog/Extension.php index 62b02a8..be4f5b3 100644 --- a/_extensions/s2_blog/Extension.php +++ b/_extensions/s2_blog/Extension.php @@ -9,13 +9,18 @@ namespace s2_extensions\s2_blog; +use Psr\Log\LoggerInterface; use S2\Cms\Asset\AssetPack; use S2\Cms\Config\DynamicConfigProvider; +use S2\Cms\Controller\CommentController; use S2\Cms\Framework\Container; use S2\Cms\Framework\ExtensionInterface; use S2\Cms\Model\Article\ArticleRenderedEvent; use S2\Cms\Model\ArticleProvider; +use S2\Cms\Model\AuthProvider; +use S2\Cms\Model\Comment\CommentStrategyInterface; use S2\Cms\Model\UrlBuilder; +use S2\Cms\Model\User\UserProvider; use S2\Cms\Pdo\DbLayer; use S2\Cms\Queue\QueueHandlerInterface; use S2\Cms\Queue\QueuePublisher; @@ -37,6 +42,7 @@ use s2_extensions\s2_blog\Controller\TagsPageController; use s2_extensions\s2_blog\Controller\YearPageController; use s2_extensions\s2_blog\Model\BlogCommentNotifier; +use s2_extensions\s2_blog\Model\BlogCommentStrategy; use s2_extensions\s2_blog\Model\BlogPlaceholderProvider; use s2_extensions\s2_blog\Model\PostProvider; use s2_extensions\s2_blog\Service\PostIndexer; @@ -256,6 +262,29 @@ public function buildContainer(Container $container): void ); }); + $container->set(BlogCommentStrategy::class, function (Container $container) { + return new BlogCommentStrategy( + $container->get(DbLayer::class), + $container->get(BlogCommentNotifier::class), + ); + }, [CommentStrategyInterface::class]); + $container->set('s2_blog.comment_controller', function (Container $container) { + /** @var DynamicConfigProvider $provider */ + $provider = $container->get(DynamicConfigProvider::class); + return new CommentController( + $container->get(AuthProvider::class), + $container->get(UserProvider::class), + $container->get(BlogCommentStrategy::class), + $container->get('comments_translator'), + $container->get(UrlBuilder::class), + $container->get(HtmlTemplateProvider::class), + $container->get(Viewer::class), + $container->get(LoggerInterface::class), + $provider->get('S2_ENABLED_COMMENTS') === '1', + $provider->get('S2_PREMODERATION') === '1', + ); + }); + $container->set(PostProvider::class, function (Container $container) { return new PostProvider( $container->get(DbLayer::class), @@ -266,8 +295,8 @@ public function buildContainer(Container $container): void $container->set(BlogCommentNotifier::class, function (Container $container) { return new BlogCommentNotifier( $container->get(DbLayer::class), + $container->get(UrlBuilder::class), $container->get(BlogUrlBuilder::class), - $container->getParameter('base_url'), ); }); @@ -406,23 +435,89 @@ public function registerRoutes(RouteCollection $routes, Container $container): v $priority = 1; if ($s2BlogUrl !== '') { - $routes->add('blog_main', new Route($s2BlogUrl . '{slash}', ['_controller' => MainPageController::class, 'page' => 0], options: ['utf8' => true]), $priority); + $routes->add('blog_main', new Route( + $s2BlogUrl . '{slash}', + ['_controller' => MainPageController::class, 'page' => 0], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); } else { - $routes->add('blog_main', new Route('/', ['_controller' => MainPageController::class, 'page' => 0, 'slash' => '/'], options: ['utf8' => true]), $priority); + $routes->add('blog_main', new Route( + '/', + ['_controller' => MainPageController::class, 'page' => 0, 'slash' => '/'], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); } - $routes->add('blog_main_pages', new Route($s2BlogUrl . '/skip/{page<\d+>}', ['_controller' => MainPageController::class, 'slash' => '/'], options: ['utf8' => true]), $priority); - - $routes->add('blog_rss', new Route($s2BlogUrl . '/rss.xml', ['_controller' => BlogRss::class], options: ['utf8' => true]), $priority); - $routes->add('blog_sitemap', new Route($s2BlogUrl . '/sitemap.xml', ['_controller' => Sitemap::class], options: ['utf8' => true]), $priority); - - $routes->add('blog_favorite', new Route($s2BlogUrl . '/' . $favoriteUrl . '{slash}', ['_controller' => FavoritePageController::class], options: ['utf8' => true]), $priority); - - $routes->add('blog_tags', new Route($s2BlogUrl . '/' . $tagsUrl . '{slash}', ['_controller' => TagsPageController::class], options: ['utf8' => true]), $priority); - $routes->add('blog_tag', new Route($s2BlogUrl . '/' . $tagsUrl . '/{tag}{slash}', ['_controller' => TagPageController::class], options: ['utf8' => true]), $priority); - - $routes->add('blog_year', new Route($s2BlogUrl . '/{year<\d+>}/', ['_controller' => YearPageController::class], options: ['utf8' => true]), $priority); - $routes->add('blog_month', new Route($s2BlogUrl . '/{year<\d+>}/{month<\d+>}/', ['_controller' => MonthPageController::class], options: ['utf8' => true]), $priority); - $routes->add('blog_day', new Route($s2BlogUrl . '/{year<\d+>}/{month<\d+>}/{day<\d+>}/', ['_controller' => DayPageController::class], options: ['utf8' => true]), $priority); - $routes->add('blog_post', new Route($s2BlogUrl . '/{year<\d+>}/{month<\d+>}/{day<\d+>}/{url}', ['_controller' => PostPageController::class], options: ['utf8' => true]), $priority); + $routes->add('blog_main_pages', new Route( + $s2BlogUrl . '/skip/{page<\d+>}', + ['_controller' => MainPageController::class, 'slash' => '/'], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + + $routes->add('blog_rss', new Route( + $s2BlogUrl . '/rss.xml', + ['_controller' => BlogRss::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + $routes->add('blog_sitemap', new Route( + $s2BlogUrl . '/sitemap.xml', + ['_controller' => Sitemap::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + + $routes->add('blog_favorite', new Route( + $s2BlogUrl . '/' . $favoriteUrl . '{slash}', + ['_controller' => FavoritePageController::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + + $routes->add('blog_tags', new Route( + $s2BlogUrl . '/' . $tagsUrl . '{slash}', + ['_controller' => TagsPageController::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + $routes->add('blog_tag', new Route( + $s2BlogUrl . '/' . $tagsUrl . '/{tag}{slash}', + ['_controller' => TagPageController::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + + $routes->add('blog_year', new Route( + $s2BlogUrl . '/{year<\d+>}/', + ['_controller' => YearPageController::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + $routes->add('blog_month', new Route( + $s2BlogUrl . '/{year<\d+>}/{month<\d+>}/', + ['_controller' => MonthPageController::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + $routes->add('blog_day', new Route( + $s2BlogUrl . '/{year<\d+>}/{month<\d+>}/{day<\d+>}/', + ['_controller' => DayPageController::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + $routes->add('blog_post', new Route( + $s2BlogUrl . '/{year<\d+>}/{month<\d+>}/{day<\d+>}/{url}', + ['_controller' => PostPageController::class], + options: ['utf8' => true], + methods: ['GET'], + ), $priority); + $routes->add('blog_comment', new Route( + $s2BlogUrl . '/{year<\d+>}/{month<\d+>}/{day<\d+>}/{url}', + ['_controller' => 's2_blog.comment_controller'], + options: ['utf8' => true], + methods: ['POST'], + ), $priority); } } diff --git a/_extensions/s2_blog/Model/BlogCommentNotifier.php b/_extensions/s2_blog/Model/BlogCommentNotifier.php index a8effda..e9193b8 100644 --- a/_extensions/s2_blog/Model/BlogCommentNotifier.php +++ b/_extensions/s2_blog/Model/BlogCommentNotifier.php @@ -9,18 +9,32 @@ namespace s2_extensions\s2_blog\Model; +use S2\Cms\Model\UrlBuilder; use S2\Cms\Pdo\DbLayer; +use S2\Cms\Pdo\DbLayerException; use s2_extensions\s2_blog\BlogUrlBuilder; +/** + * 1. Sends notifications on new comments: + * - Retrieves information about the comment and associated post. + * - Sends the comment to commentators who subscribed to this post. + * - Generates an unsubscribe link. + * - Marks the comment as sent. + * + * 2. Unsubscribes commentators by parameters from the unsubscribe links. + */ readonly class BlogCommentNotifier { public function __construct( private DbLayer $dbLayer, + private UrlBuilder $urlBuilder, private BlogUrlBuilder $blogUrlBuilder, - private string $baseUrl, ) { } + /** + * @throws DbLayerException + */ public function notify(int $commentId): void { /** @@ -55,41 +69,40 @@ public function notify(int $commentId): void } // Getting some info about the post commented - $query = [ + $result = $this->dbLayer->buildAndQuery([ 'SELECT' => 'title, create_time, url', 'FROM' => 's2_blog_posts', - 'WHERE' => 'id = ' . $comment['post_id'] . ' AND published = 1 AND commented = 1' - ]; - $result = $this->dbLayer->buildAndQuery($query); + 'WHERE' => 'id = :post_id AND published = 1 AND commented = 1' + ], [ + 'post_id' => $comment['post_id'] + ]); $post = $this->dbLayer->fetchAssoc($result); if (!$post) { return; } - $link = $this->blogUrlBuilder->postFromTimestamp($post['create_time'], $post['url']); + $link = $this->blogUrlBuilder->absPostFromTimestamp($post['create_time'], $post['url']); // Fetching receivers' names and addresses - $query = [ - 'SELECT' => 'id, nick, email, ip, time', - 'FROM' => 's2_blog_comments', - 'WHERE' => 'post_id = ' . $comment['post_id'] . ' AND subscribed = 1 AND shown = 1 AND email <> \'' . $this->dbLayer->escape($comment['email']) . '\'' - ]; - $result = $this->dbLayer->buildAndQuery($query); + $allReceivers = $this->getCommentReceivers($comment['post_id'], $comment['email'], '<>'); + // Group by email, taking last records $receivers = []; - while ($receiver = $this->dbLayer->fetchAssoc($result)) { + foreach ($allReceivers as $receiver) { $receivers[$receiver['email']] = $receiver; } + $message = s2_bbcode_to_mail($comment['text']); + foreach ($receivers as $receiver) { - $hash = md5($receiver['id'] . $receiver['ip'] . $receiver['nick'] . $receiver['email'] . $receiver['time']); + $unsubscribeLink = $this->urlBuilder->rawAbsLink('/comment_unsubscribe', [ + 'mail=' . urlencode($receiver['email']), + 'id=' . $comment['post_id'], + 'code=' . $receiver['hash'], + ]); - $unsubscribeLink = $this->baseUrl - . '/comment.php?mail=' . urlencode($receiver['email']) - . '&id=' . $comment['post_id'] . '.s2_blog' - . '&unsubscribe=' . base_convert(substr($hash, 0, 16), 16, 36); - s2_mail_comment($receiver['nick'], $receiver['email'], $comment['text'], $post['title'], $link, $comment['nick'], $unsubscribeLink); + s2_mail_comment($receiver['nick'], $receiver['email'], $message, $post['title'], $link, $comment['nick'], $unsubscribeLink); } // Toggle sent mark @@ -100,4 +113,56 @@ public function notify(int $commentId): void ]; $this->dbLayer->buildAndQuery($query); } + + /** + * @throws DbLayerException + */ + public function unsubscribe(int $postId, string $email, string $code): bool + { + $receivers = $this->getCommentReceivers($postId, $email, '='); + + foreach ($receivers as $receiver) { + if ($code === $receiver['hash']) { + $this->dbLayer->buildAndQuery([ + 'UPDATE' => 's2_blog_comments', + 'SET' => 'subscribed = 0', + 'WHERE' => 'post_id = :post_id and subscribed = 1 and email = :email' + ], [ + 'post_id' => $postId, + 'email' => $email, + ]); + + return true; + } + } + + return false; + } + + /** + * @throws DbLayerException + */ + private function getCommentReceivers(int $postId, string $email, string $operation): array + { + if (!\in_array($operation, ['=', '<>'], true)) { + throw new \InvalidArgumentException(sprintf('Invalid operation "%s".', $operation)); + } + + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'id, nick, email, ip, time', + 'FROM' => 's2_blog_comments', + 'WHERE' => 'post_id = :post_id AND subscribed = 1 AND shown = 1 AND email ' . $operation . ' :email' + ], [ + 'post_id' => $postId, + 'email' => $email, + ]); + + $receivers = $this->dbLayer->fetchAssocAll($result); + foreach ($receivers as &$receiver) { + $receiver['hash'] = substr(base_convert(md5('s2_blog_comments' . serialize($receiver)), 16, 36), 0, 13); + } + unset($receiver); + + return $receivers; + } } diff --git a/_extensions/s2_blog/Model/BlogCommentStrategy.php b/_extensions/s2_blog/Model/BlogCommentStrategy.php new file mode 100644 index 0000000..2574798 --- /dev/null +++ b/_extensions/s2_blog/Model/BlogCommentStrategy.php @@ -0,0 +1,176 @@ +attributes->get('year')); + $month = (int)($request->attributes->get('month')); // Note: "01" is not parsed with getInt() correctly + $day = (int)($request->attributes->get('day')); + $url = $request->attributes->get('url'); + + $startTime = mktime(0, 0, 0, $month, $day, $year); + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'id, title', + 'FROM' => 's2_blog_posts AS p', + 'WHERE' => 'create_time < :end_time AND create_time >= :start_time AND url = :url AND published = 1 AND commented = 1', + ], [ + 'end_time' => $startTime + 86400, + 'start_time' => $startTime, + 'url' => $url, + ]); + + $post = $this->dbLayer->fetchAssoc($result); + if (\is_array($post)) { + return new TargetDto($post['id'], $post['title']); + } + return null; + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function getTargetById(int $targetId): ?TargetDto + { + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'id, title', + 'FROM' => 's2_blog_posts AS p', + 'WHERE' => 'id = :id', + ], [ + 'id' => $targetId, + ]); + + $post = $this->dbLayer->fetchAssoc($result); + if (\is_array($post)) { + return new TargetDto($post['id'], $post['title']); + } + return null; + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function save(int $targetId, string $name, string $email, bool $showEmail, bool $subscribed, string $text, string $ip): int + { + $this->dbLayer->buildAndQuery([ + 'INSERT' => 'post_id, time, ip, nick, email, show_email, subscribed, sent, shown, good, text', + 'INTO' => 's2_blog_comments', + 'VALUES' => ':post_id, :time, :ip, :nick, :email, :show_email, :subscribed, :sent, :shown, 0, :text' + ], [ + 'post_id' => $targetId, + 'time' => time(), + 'ip' => $ip, + 'nick' => $name, + 'email' => $email, + 'show_email' => $showEmail ? 1 : 0, + 'subscribed' => $subscribed ? 1 : 0, + 'sent' => 0, + 'shown' => 0, + 'text' => $text, + ]); + + return (int)$this->dbLayer->insertId(); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function notifySubscribers(int $commentId): void + { + $this->commentNotifier->notify($commentId); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function getHashForPublishedComment(int $targetId): ?string + { + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'COUNT(id)', + 'FROM' => 's2_blog_comments', + 'WHERE' => 'post_id = ' . $targetId . ' AND shown = 1' + ]); + + $num = $this->dbLayer->result($result); + + return $num ? (string)$num : null; + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function getRecentComment(string $hash, string $ip): ?CommentDto + { + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'id, post_id AS target_id, email, text, nick AS name', + 'FROM' => 's2_blog_comments', + 'WHERE' => 'ip = :ip AND shown = 0 AND sent = 0 AND time >= :time', + 'ORDER BY' => 'time DESC', + ], [ + 'ip' => $ip, + 'time' => time() - 5 * 60, // 5 minutes + ]); + + foreach ($this->dbLayer->fetchAssocAll($result) as $comment) { + if ($hash === CommentController::commentHash($comment['id'], $comment['target_id'], $comment['email'], $ip, \get_class($this))) { + return new CommentDto($comment['id'], $comment['target_id'], $comment['name'], $comment['email'], $comment['text']); + } + } + + return null; + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function publishComment(int $commentId): void + { + $this->dbLayer->buildAndQuery([ + 'UPDATE' => 's2_blog_comments', + 'SET' => 'shown = 1', + 'WHERE' => 'id = :id', + ], ['id' => $commentId]); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function unsubscribe(int $targetId, string $email, string $code): bool + { + return $this->commentNotifier->unsubscribe($targetId, $email, $code); + } +} diff --git a/_extensions/s2_blog/Model/PostProvider.php b/_extensions/s2_blog/Model/PostProvider.php index 6b7a52f..79199ca 100644 --- a/_extensions/s2_blog/Model/PostProvider.php +++ b/_extensions/s2_blog/Model/PostProvider.php @@ -237,7 +237,7 @@ public function getCommentNum(int $postId, bool $includeHidden): int $result = $this->dbLayer->buildAndQuery([ 'SELECT' => 'COUNT(*)', 'FROM' => 's2_blog_comments', - 'WHERE' => 'post_id = :post_id' . ($includeHidden ? '' : ' AND shown = 0'), + 'WHERE' => 'post_id = :post_id' . ($includeHidden ? '' : ' AND shown = 1'), ], ['post_id' => $postId]); return (int)$this->dbLayer->result($result); diff --git a/_include/comments.php b/_include/comments.php index 252dbf6..4fcef99 100644 --- a/_include/comments.php +++ b/_include/comments.php @@ -66,17 +66,17 @@ function s2_mail_comment ($name, $email, $text, $title, $url, $auth_name, $unsub } } - mail($email, $subject, $message, $headers); + mail($email, $subject, $message, $headers); } // // Sends comments to subscribed users // -function s2_mail_moderator ($name, $email, $text, $title, $url, $auth_name, $auth_email) +function s2_mail_moderator ($name, $email, $text, $title, $url, $authorName, $authorEmail) { $message = Lang::get('Email moderator pattern', 'comments'); $message = str_replace(array('', '', '', '<url>', '<text>'), - array($name, $auth_name, $title, $url, $text), $message); + array($name, $authorName, $title, $url, $text), $message); // Make sure all linebreaks are CRLF in message (and strip out any NULL bytes) $message = str_replace(array("\n", "\0"), array("\r\n", ''), $message); @@ -89,7 +89,7 @@ function s2_mail_moderator ($name, $email, $text, $title, $url, $auth_name, $aut $sender = S2_WEBMASTER ? "=?UTF-8?B?".base64_encode(S2_WEBMASTER)."?=".' <'.$sender_email.'>' : $sender_email; // Author email - $from = trim($auth_name) ? "=?UTF-8?B?".base64_encode($auth_name)."?=".' <'.$auth_email.'>' : $auth_email; + $from = trim($authorName) ? "=?UTF-8?B?".base64_encode($authorName)."?=".' <'.$authorEmail.'>' : $authorEmail; $headers = 'From: '.$sender."\r\n". // One cannot use the real author email in "From:" header due to DMARC. Use our one. 'Sender: '.$from."\r\n". // Let's use the real author email at least here. diff --git a/_include/functions.php b/_include/functions.php index 500705a..b8fac0c 100644 --- a/_include/functions.php +++ b/_include/functions.php @@ -285,12 +285,9 @@ function _s2_remove_bad_characters(mixed &$data, array $bad_utf8_chars): void { // function s2_is_valid_email($email) { - $return = ($hook = s2_hook('fn_is_valid_email_start')) ? eval($hook) : null; - if ($return != null) - return $return; - - if (strlen($email) > 80) + if (strlen($email) > 80) { return false; + } return preg_match('/^(([^<>()[\]\\.,;:\s@"\']+(\.[^<>()[\]\\.,;:\s@"\']+)*)|("[^"\']+"))@((\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\])|(([a-zA-Z\d\-]+\.)+[a-zA-Z]{2,}))$/', $email); } diff --git a/_include/src/Admin/AdminExtension.php b/_include/src/Admin/AdminExtension.php index 84b5895..03977a9 100644 --- a/_include/src/Admin/AdminExtension.php +++ b/_include/src/Admin/AdminExtension.php @@ -66,16 +66,6 @@ public function buildContainer(Container $container): void ); }); - // Helpers - $container->set(CommentNotifier::class, function (Container $container) { - return new CommentNotifier( - $container->get(DbLayer::class), - $container->get(ArticleProvider::class), - $container->get(UrlBuilder::class), - $container->getParameter('base_url'), - ); - }); - // AdminYard services $container->set(TypeTransformer::class, function (Container $container) { return new TypeTransformer(); @@ -230,6 +220,7 @@ public function buildContainer(Container $container): void $container->get(TemplateRenderer::class), $container->get(Translator::class), $container->getParameter('base_path'), + $container->getParameter('url_prefix'), $container->getParameter('cookie_name'), $container->getParameter('force_admin_https'), (int)$provider->get('S2_LOGIN_TIMEOUT'), @@ -333,10 +324,8 @@ public function buildContainer(Container $container): void }, [DashboardStatProviderInterface::class]); $container->set(PathToAdminEntityConverter::class, function (Container $container) { - $provider = $container->get(DynamicConfigProvider::class); return new PathToAdminEntityConverter( - $container->get(DbLayer::class), - $provider->get('S2_USE_HIERARCHY') === '1', + $container->get(ArticleProvider::class), ); }); diff --git a/_include/src/Admin/PathToAdminEntityConverter.php b/_include/src/Admin/PathToAdminEntityConverter.php index 435bdc1..1dd0a45 100644 --- a/_include/src/Admin/PathToAdminEntityConverter.php +++ b/_include/src/Admin/PathToAdminEntityConverter.php @@ -11,53 +11,29 @@ use S2\AdminYard\Config\FieldConfig; use S2\Cms\Model\ArticleProvider; -use S2\Cms\Pdo\DbLayer; +use S2\Cms\Pdo\DbLayerException; readonly class PathToAdminEntityConverter { public function __construct( - private DbLayer $dbLayer, - private bool $useHierarchy, + private ArticleProvider $articleProvider, ) { } + /** + * @throws DbLayerException + */ public function getQueryParams(string $path): ?array { if ($path === '/') { return null; } - $pathArray = explode('/', $path); // e.g. []/[dir1]/[dir2]/[dir3]/[file1] - - // Remove last empty element - if ($pathArray[\count($pathArray) - 1] === '') { - unset($pathArray[\count($pathArray) - 1]); - } - - if (!$this->useHierarchy) { - $pathArray = [$pathArray[1]]; - } - - $id = ArticleProvider::ROOT_ID; - - // Walking through page parents - foreach ($pathArray as $pathItem) { - $query = [ - 'SELECT' => 'a.id', - 'FROM' => 'articles AS a', - 'WHERE' => 'url = :url' . ($this->useHierarchy ? ' AND parent_id = :id' : '') - ]; - $result = $this->dbLayer->buildAndQuery($query, [ - 'url' => $pathItem, - ...$this->useHierarchy ? ['id' => $id] : [] - ]); - - $id = $this->dbLayer->result($result); - if (!$id) { - return null; - } + $data = $this->articleProvider->articleFromPath($path, false); + if ($data === null) { + return null; } - return ['entity' => 'Article', 'action' => FieldConfig::ACTION_EDIT, 'id' => $id]; + return ['entity' => 'Article', 'action' => FieldConfig::ACTION_EDIT, 'id' => $data['id']]; } } diff --git a/_include/src/CmsExtension.php b/_include/src/CmsExtension.php index 3c556af..4d806e8 100644 --- a/_include/src/CmsExtension.php +++ b/_include/src/CmsExtension.php @@ -12,6 +12,9 @@ use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use S2\Cms\Config\DynamicConfigProvider; +use S2\Cms\Controller\CommentController; +use S2\Cms\Controller\CommentSentController; +use S2\Cms\Controller\CommentUnsubscribeController; use S2\Cms\Controller\NotFoundController; use S2\Cms\Controller\PageCommon; use S2\Cms\Controller\PageFavorite; @@ -28,10 +31,15 @@ use S2\Cms\Layout\LayoutMatcherFactory; use S2\Cms\Logger\Logger; use S2\Cms\Model\ArticleProvider; +use S2\Cms\Model\AuthProvider; +use S2\Cms\Model\Comment\ArticleCommentStrategy; +use S2\Cms\Model\Comment\CommentStrategyInterface; +use S2\Cms\Model\CommentNotifier; use S2\Cms\Model\CommentProvider; use S2\Cms\Model\ExtensionCache; use S2\Cms\Model\TagsProvider; use S2\Cms\Model\UrlBuilder; +use S2\Cms\Model\User\UserProvider; use S2\Cms\Pdo\DbLayer; use S2\Cms\Pdo\DbLayerPostgres; use S2\Cms\Pdo\DbLayerSqlite; @@ -346,6 +354,81 @@ public function buildContainer(Container $container): void ); }); + $container->set(CommentNotifier::class, function (Container $container) { + return new CommentNotifier( + $container->get(DbLayer::class), + $container->get(ArticleProvider::class), + $container->get(UrlBuilder::class), + ); + }); + + $container->set('comments_translator', function (Container $container) { + /** @var ExtensibleTranslator $translator */ + $translator = $container->get('translator'); + $translator->load('comments', function (string $lang) { + return require __DIR__ . '/../../_lang/' . $lang . '/comments.php'; + }); + + return $translator; + }); + + $container->set(ArticleCommentStrategy::class, function (Container $container) { + return new ArticleCommentStrategy( + $container->get(DbLayer::class), + $container->get(ArticleProvider::class), + $container->get(CommentNotifier::class), + ); + }, [CommentStrategyInterface::class]); + + $container->set(AuthProvider::class, function (Container $container) { + return new AuthProvider( + $container->get(DbLayer::class), + $container->getParameter('cookie_name'), + ); + }); + + $container->set(UserProvider::class, function (Container $container) { + return new UserProvider( + $container->get(DbLayer::class), + ); + }); + + $container->set(CommentController::class, function (Container $container) { + /** @var DynamicConfigProvider $provider */ + $provider = $container->get(DynamicConfigProvider::class); + return new CommentController( + $container->get(AuthProvider::class), + $container->get(UserProvider::class), + $container->get(ArticleCommentStrategy::class), + $container->get('comments_translator'), + $container->get(UrlBuilder::class), + $container->get(HtmlTemplateProvider::class), + $container->get(Viewer::class), + $container->get(LoggerInterface::class), + $provider->get('S2_ENABLED_COMMENTS') === '1', + $provider->get('S2_PREMODERATION') === '1', + ); + }); + + $container->set(CommentSentController::class, function (Container $container) { + return new CommentSentController( + $container->get(AuthProvider::class), + $container->get(UserProvider::class), + $container->get('comments_translator'), + $container->get(UrlBuilder::class), + $container->get(HtmlTemplateProvider::class), + ...$container->getByTag(CommentStrategyInterface::class) + ); + }); + + $container->set(CommentUnsubscribeController::class, function (Container $container) { + return new CommentUnsubscribeController( + $container->get('comments_translator'), + $container->get(HtmlTemplateProvider::class), + ...$container->getByTag(CommentStrategyInterface::class) + ); + }); + $container->set(Rss::class, function (Container $container) { /** @var DynamicConfigProvider $provider */ $provider = $container->get(DynamicConfigProvider::class); @@ -483,11 +566,53 @@ public function registerRoutes(RouteCollection $routes, Container $container): v $favoriteUrl = $configProvider->get('S2_FAVORITE_URL'); $tagsUrl = $configProvider->get('S2_TAGS_URL'); - $routes->add('rss', new Route('/rss.xml', ['_controller' => Rss::class])); - $routes->add('sitemap', new Route('/sitemap.xml', ['_controller' => Sitemap::class])); - $routes->add('favorite', new Route('/' . $favoriteUrl . '{slash</?>}', ['_controller' => PageFavorite::class], options: ['utf8' => true])); - $routes->add('tags', new Route('/' . $tagsUrl . '{slash</?>}', ['_controller' => PageTags::class], options: ['utf8' => true])); - $routes->add('tag', new Route('/' . $tagsUrl . '/{name}{slash</?>}', ['_controller' => PageTag::class], options: ['utf8' => true])); - $routes->add('common', new Route('/{path<.*>}', ['_controller' => PageCommon::class]), -1); // -1 for last route + $routes->add('rss', new Route( + '/rss.xml', + ['_controller' => Rss::class], + methods: ['GET'] + )); + $routes->add('sitemap', new Route( + '/sitemap.xml', + ['_controller' => Sitemap::class], + methods: ['GET'] + )); + $routes->add('favorite', new Route( + '/' . $favoriteUrl . '{slash</?>}', + ['_controller' => PageFavorite::class], + options: ['utf8' => true], + methods: ['GET'] + )); + $routes->add('tags', new Route( + '/' . $tagsUrl . '{slash</?>}', + ['_controller' => PageTags::class], + options: ['utf8' => true], + methods: ['GET'] + )); + $routes->add('tag', new Route( + '/' . $tagsUrl . '/{name}{slash</?>}', + ['_controller' => PageTag::class], + options: ['utf8' => true], + methods: ['GET'] + )); + $routes->add('common', new Route( + '/{path<.*>}', + ['_controller' => PageCommon::class], + methods: ['GET'] + ), -1); // -1 for last route + $routes->add('comment_sent', new Route( + '/comment_sent', + ['_controller' => CommentSentController::class], + methods: ['GET'] + )); + $routes->add('comment_unsubscribe', new Route( + '/comment_unsubscribe', + ['_controller' => CommentUnsubscribeController::class], + methods: ['GET'] + )); + $routes->add('comment', new Route( + '/{path<.*>}', + ['_controller' => CommentController::class], + methods: ['POST'] + ), -1); // -1 for last route } } diff --git a/_include/src/Controller/CommentController.php b/_include/src/Controller/CommentController.php new file mode 100644 index 0000000..7418fa6 --- /dev/null +++ b/_include/src/Controller/CommentController.php @@ -0,0 +1,216 @@ +<?php +/** + * @copyright 2007-2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Controller; + +use Psr\Log\LoggerInterface; +use S2\Cms\Framework\ControllerInterface; +use S2\Cms\Model\AuthProvider; +use S2\Cms\Model\Comment\CommentStrategyInterface; +use S2\Cms\Model\UrlBuilder; +use S2\Cms\Model\User\UserProvider; +use S2\Cms\Pdo\DbLayerException; +use S2\Cms\Template\HtmlTemplateProvider; +use S2\Cms\Template\Viewer; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\Translation\TranslatorInterface; + +readonly class CommentController implements ControllerInterface +{ + public function __construct( + private AuthProvider $authProvider, + private UserProvider $userProvider, + private CommentStrategyInterface $commentStrategy, + private TranslatorInterface $translator, + private UrlBuilder $urlBuilder, + private HtmlTemplateProvider $templateProvider, + private Viewer $viewer, + private LoggerInterface $logger, + private bool $commentsEnabled, + private bool $premoderationEnabled, + ) { + } + + private const S2_MAX_COMMENT_BYTES = 65535; + + public static function commentHash(int $commentId, int $targetId, string $email, string $ip, string $strategyClass): string + { + return md5(serialize([$commentId, $targetId, $email, $ip, $strategyClass])); + } + + /** + * @throws DbLayerException + */ + public function handle(Request $request): Response + { + $showEmail = $request->request->get('show_email', false) !== false; + $subscribed = $request->request->get('subscribed', false) !== false; + $id = $request->request->getString('id', ''); + if (!preg_match('#^[0-9a-f]{32}$#', $id)) { + $id = ''; + } + + /** + * Input validation + */ + $errors = []; + + if (!$this->commentsEnabled) { + $errors[] = $this->translator->trans('disabled'); + } + + $text = $request->request->get('text', ''); + $text = trim($text); + if ($text === '') { + $errors[] = $this->translator->trans('missing_text'); + } + if (\strlen($text) > self::S2_MAX_COMMENT_BYTES) { + $errors[] = sprintf($this->translator->trans('long_text'), self::S2_MAX_COMMENT_BYTES); + } elseif (self::linkCount($text) > 0) { + $errors[] = $this->translator->trans('links_in_text'); + } + + $email = $request->request->get('email', ''); + $email = trim($email); + if (!s2_is_valid_email($email)) { + $errors[] = $this->translator->trans('email'); + } + + $name = $request->request->get('name', ''); + $name = trim($name); + if ($name === '') { + $errors[] = $this->translator->trans('missing_nick'); + } elseif (mb_strlen($name) > 50) { + $errors[] = $this->translator->trans('long_nick'); + } + + if (\count($errors) === 0 && !self::checkCommentQuestion($request->request->get('key', ''), $request->request->get('question', ''))) { + $errors[] = $this->translator->trans('question'); + } + + if ($request->request->get('preview') !== null) { + // Handling "Preview" button + $text_preview = '<p>' . $this->translator->trans('Comment preview info') . '</p>' . "\n" . + $this->viewer->render('comment', [ + 'text' => $text, + 'nick' => $name, + 'time' => time(), + 'email' => $email, + 'show_email' => $showEmail, + ]); + + $template = $this->templateProvider->getTemplate('service.php'); + + $template + ->putInPlaceholder('head_title', $this->translator->trans('Comment preview')) + ->putInPlaceholder('title', $this->translator->trans('Comment preview')) + ->putInPlaceholder('text', $text_preview) + ->putInPlaceholder('id', $id) + ->putInPlaceholder('commented', true) + ->putInPlaceholder('comment_form', compact('name', 'email', 'showEmail', 'subscribed', 'text')) + ; + + return $template->toHttpResponse(); + } + + // What are we going to comment? + $target = $this->commentStrategy->getTargetByRequest($request); + $path = $request->getPathInfo(); + + if (empty($errors) && $target === null) { + $errors[] = $this->translator->trans('no_item'); + } + + if (!empty($errors)) { + $errorText = '<p>' . $this->translator->trans('Error message') . '</p><ul>'; + foreach ($errors as $error) { + $errorText .= '<li>' . $error . '</li>'; + } + $errorText .= '</ul>'; + + $template = $this->templateProvider->getTemplate('service.php'); + + $template + ->putInPlaceholder('head_title', '❌ ' . $this->translator->trans('Error')) + ->putInPlaceholder('title', '<span class="icon-error">✖</span>' . $this->translator->trans('Error')) + ->putInPlaceholder('text', $errorText . ($target !== null ? '<p>' . $this->translator->trans('Fix error') . '</p>' : '')) + ->putInPlaceholder('id', $id) + ->putInPlaceholder('commented', $target !== null) // can be commented, i.e. render comment form + ->putInPlaceholder('comment_form', compact('name', 'email', 'showEmail', 'subscribed', 'text')) + ; + + $this->logger->notice('Comment was not saved due to errors.', [ + 'errors' => $errors, + 'path' => $path, + ]); + return $template->toHttpResponse(); + } + + $link = $this->urlBuilder->absLink($path); + + /** + * Everything is ok, save and send the comment + */ + + // Detect if there is a user logged in + $isOnline = $this->authProvider->isOnline($email); + + $moderationRequired = $this->premoderationEnabled; + + // Save the comment + $commentId = $this->commentStrategy->save($target->id, $name, $email, $showEmail, $subscribed, $text, (string)$request->getClientIp()); + + $message = s2_bbcode_to_mail($text); + + // Sending the comment to moderators + + foreach ($this->userProvider->getModerators([], $moderationRequired && $isOnline ? [$email] : []) as $moderator) { + s2_mail_moderator($moderator->login, $moderator->email, $message, $target->title, $link, $name, $email); + } + + if (!$moderationRequired) { + // Sending the comment to subscribers + $this->commentStrategy->notifySubscribers($commentId); + $this->commentStrategy->publishComment($commentId); + $hash = $this->commentStrategy->getHashForPublishedComment($target->id); + // Redirect to the last comment + $redirectLink = $this->urlBuilder->link($path) . ($hash !== null ? '#' . $hash : ''); + } else { + $redirectLink = $this->urlBuilder->rawLink('/comment_sent', [ + 'go=' . urlencode($path), + 'sign=' . self::commentHash($commentId, $target->id, $email, (string)$request->getClientIp(), \get_class($this->commentStrategy)), + ]); + } + + $response = new RedirectResponse($redirectLink); + + // Command for client code to clear draft from localStorage + $response->headers->setCookie(Cookie::create('comment_form_sent', $id, httpOnly: false)); + + return $response; + } + + private static function linkCount(string $text): int + { + return preg_match_all('#(https?://\S{2,}?)(?=[\s),\'><\]]|<|>|[.;:](?:\s|$)|$)#u', $text) ?: 0; + } + + + private static function checkCommentQuestion(string $key, string $answer): bool + { + if (\strlen($key) < 21) { + return false; + } + + return ((int)($key[10] . $key[12]) + (int)($key[20]) === (int)trim($answer)); + } +} diff --git a/_include/src/Controller/CommentSentController.php b/_include/src/Controller/CommentSentController.php new file mode 100644 index 0000000..9d31060 --- /dev/null +++ b/_include/src/Controller/CommentSentController.php @@ -0,0 +1,92 @@ +<?php +/** + * @copyright 2007-2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Controller; + +use S2\Cms\Framework\ControllerInterface; +use S2\Cms\Model\AuthProvider; +use S2\Cms\Model\Comment\CommentStrategyInterface; +use S2\Cms\Model\UrlBuilder; +use S2\Cms\Model\User\UserProvider; +use S2\Cms\Pdo\DbLayerException; +use S2\Cms\Template\HtmlTemplateProvider; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Outputs "comment saved" message (used if the pre-moderation mode is enabled) + */ +readonly class CommentSentController implements ControllerInterface +{ + /** + * @var CommentStrategyInterface[] + */ + private array $commentStrategies; + + public function __construct( + private AuthProvider $authProvider, + private UserProvider $userProvider, + private TranslatorInterface $translator, + private UrlBuilder $urlBuilder, + private HtmlTemplateProvider $templateProvider, + CommentStrategyInterface ...$strategies + ) { + $this->commentStrategies = $strategies; + } + + /** + * @throws DbLayerException + */ + public function handle(Request $request): Response + { + $targetPath = $request->get('go', ''); + $commentHash = $request->get('sign', ''); + $moderatorEmail = $this->authProvider->getAuthenticatedModeratorEmail($request); + + foreach ($this->commentStrategies as $commentStrategy) { + $comment = $commentStrategy->getRecentComment($commentHash, (string)$request->getClientIp()); + if ($comment !== null) { + if ($moderatorEmail === $comment->email) { + // We have confirmed that the moderator is the one who has really sent the comment + $commentStrategy->notifySubscribers($comment->id); + $commentStrategy->publishComment($comment->id); + $hash = $commentStrategy->getHashForPublishedComment($comment->targetId); + + // Redirect to the last comment + $redirectLink = $this->urlBuilder->link($targetPath) . ($hash !== null ? '#' . $hash : ''); + + return new RedirectResponse($redirectLink); + } + + $moderators = $this->userProvider->getModerators([$comment->email]); + if (\count($moderators) > 0) { + $link = $this->urlBuilder->absLink($targetPath); + $message = s2_bbcode_to_mail($comment->text); + $target = $commentStrategy->getTargetById($comment->targetId); + foreach ($moderators as $moderator) { + s2_mail_moderator($moderator->login, $moderator->email, $message, $target->title ?? 'unknown item', $link, $comment->name, $comment->email); + } + } + break; + } + } + + $template = $this->templateProvider->getTemplate('service.php'); + + $template + ->putInPlaceholder('head_title', '✅ ' . $this->translator->trans('Comment sent')) + ->putInPlaceholder('title', '<span class="icon-success">✔</span>' . $this->translator->trans('Comment sent')) + ->putInPlaceholder('text', sprintf($this->translator->trans('Comment sent info'), s2_htmlencode($this->urlBuilder->link($targetPath)), $this->urlBuilder->link('/'))) + ; + + return $template->toHttpResponse(); + } +} diff --git a/_include/src/Controller/CommentUnsubscribeController.php b/_include/src/Controller/CommentUnsubscribeController.php new file mode 100644 index 0000000..467a9ee --- /dev/null +++ b/_include/src/Controller/CommentUnsubscribeController.php @@ -0,0 +1,64 @@ +<?php +/** + * @copyright 2007-2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Controller; + +use S2\Cms\Framework\ControllerInterface; +use S2\Cms\Model\Comment\CommentStrategyInterface; +use S2\Cms\Template\HtmlTemplateProvider; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\Translation\TranslatorInterface; + +readonly class CommentUnsubscribeController implements ControllerInterface +{ + /** + * @var CommentStrategyInterface[] + */ + private array $commentStrategies; + + public function __construct( + private TranslatorInterface $translator, + private HtmlTemplateProvider $templateProvider, + CommentStrategyInterface ...$commentStrategies + ) { + $this->commentStrategies = $commentStrategies; + } + + public function handle(Request $request): Response + { + $id = $request->query->get('id'); + $mail = $request->query->get('mail'); + $code = $request->query->get('code'); + + $template = $this->templateProvider->getTemplate('service.php'); + + if (is_numeric($id) && \is_string($mail) && \is_string($code)) { + foreach ($this->commentStrategies as $commentStrategy) { + if ($commentStrategy->unsubscribe((int)$id, $mail, $code)) { + $template + ->putInPlaceholder('head_title', $this->translator->trans('Unsubscribed OK')) + ->putInPlaceholder('title', $this->translator->trans('Unsubscribed OK')) + ->putInPlaceholder('text', $this->translator->trans('Unsubscribed OK info')) + ; + + return $template->toHttpResponse(); + } + } + } + + $template + ->putInPlaceholder('head_title', $this->translator->trans('Unsubscribed failed')) + ->putInPlaceholder('title', $this->translator->trans('Unsubscribed failed')) + ->putInPlaceholder('text', $this->translator->trans('Unsubscribed failed info')) + ; + + return $template->toHttpResponse(); + } +} diff --git a/_include/src/Controller/PageCommon.php b/_include/src/Controller/PageCommon.php index 037cdf6..7ef7e9a 100644 --- a/_include/src/Controller/PageCommon.php +++ b/_include/src/Controller/PageCommon.php @@ -181,7 +181,7 @@ public function handle(Request $request): Response $articleId = (int)$page['id']; $template = $this->htmlTemplateProvider->getTemplate($template_id); $template - ->putInPlaceholder('id', $articleId) // for comments form + ->putInPlaceholder('id', md5('article_' . $articleId)) // for comments form ->putInPlaceholder('meta_keywords', $page['meta_keywords']) ->putInPlaceholder('meta_description', $page['meta_description']) ->putInPlaceholder('excerpt', $page['excerpt']) diff --git a/_include/src/Model/ArticleProvider.php b/_include/src/Model/ArticleProvider.php index 551d8d6..f531a31 100644 --- a/_include/src/Model/ArticleProvider.php +++ b/_include/src/Model/ArticleProvider.php @@ -272,6 +272,50 @@ public function pathFromId(int $id, bool $visibleForAll = false): ?string return $path === '' ? '/' : $path; } + /** + * @throws DbLayerException + */ + public function articleFromPath(string $path, bool $publishedOnly): ?array + { + $pathArray = explode('/', $path); // e.g. []/[dir1]/[dir2]/[dir3]/[file1] + $pathArray = array_map('rawurldecode', $pathArray); + + // Remove the last empty element + if ($pathArray[\count($pathArray) - 1] === '') { + unset($pathArray[\count($pathArray) - 1]); + } + + if (!$this->useHierarchy) { + $pathArray = \count($pathArray) > 0 ? [$pathArray[1]] : ['']; + } + + $id = self::ROOT_ID; + $title = null; + $commented = null; + + // Walking through page parents + foreach ($pathArray as $pathItem) { + $query = [ + 'SELECT' => 'a.id, a.title, a.commented', + 'FROM' => 'articles AS a', + 'WHERE' => 'url = :url' . ($this->useHierarchy ? ' AND parent_id = :id' : '') . ($publishedOnly ? ' AND published = 1' : '') + ]; + $result = $this->dbLayer->buildAndQuery($query, [ + 'url' => $pathItem, + ...$this->useHierarchy ? ['id' => $id] : [] + ]); + + $row = $this->dbLayer->fetchRow($result); + if (!\is_array($row)) { + return null; + } + + [$id, $title, $commented] = $row; + } + + return ['id' => $id, 'title' => $title, 'commented' => $commented]; + } + /** * @throws DbLayerException */ @@ -319,7 +363,7 @@ public function getCommentNum(int $id, bool $includeHidden): int $result = $this->dbLayer->buildAndQuery([ 'SELECT' => 'COUNT(*)', 'FROM' => 'art_comments', - 'WHERE' => 'article_id = :article_id' . ($includeHidden ? '' : ' AND shown = 0'), + 'WHERE' => 'article_id = :article_id' . ($includeHidden ? '' : ' AND shown = 1'), ], ['article_id' => $id]); return (int)$this->dbLayer->result($result); diff --git a/_include/src/Model/AuthManager.php b/_include/src/Model/AuthManager.php index 7636377..7e96a13 100644 --- a/_include/src/Model/AuthManager.php +++ b/_include/src/Model/AuthManager.php @@ -38,6 +38,7 @@ public function __construct( private TemplateRenderer $templateRenderer, private Translator $translator, private string $basePath, + private string $urlPrefix, private string $cookieName, private bool $forceAdminHttps, private int $loginTimeoutMinutes, @@ -74,12 +75,7 @@ public function checkAuth(Request $request): ?Response path: $this->basePath . '/_admin/', secure: $this->forceAdminHttps, )); - $response->headers->setCookie(Cookie::create( - name: $this->cookieName . '_c', - value: '', - path: $this->basePath . '/comment.php', - secure: false, - )); + $response->headers->setCookie($this->createCommentCookie('')); return $response; } @@ -206,12 +202,7 @@ private function authenticateUser(Request $request, string $challenge): ?Respons path: $this->basePath . '/_admin/', secure: $this->forceAdminHttps, )); - $response->headers->setCookie(Cookie::create( - name: $this->cookieName . '_c', - value: '', - path: $this->basePath . '/comment.php', - secure: false, - )); + $response->headers->setCookie($this->createCommentCookie('')); return $response; } @@ -358,13 +349,7 @@ private function successLogin(Request $request, $login, $challenge): JsonRespons path: $this->basePath . '/_admin/', secure: $this->forceAdminHttps, )); - $response->headers->setCookie(Cookie::create( - name: $this->cookieName . '_c', - value: $commentCookie, - expire: $time + $this->cookieExpireTimeout(), - path: $this->basePath . '/comment.php', - secure: false, - )); + $response->headers->setCookie($this->createCommentCookie($commentCookie)); return $response; } @@ -489,4 +474,19 @@ private function checkAndUpdateCurrentUserChallenge(Request $request, string $ch return self::CHALLENGE_STATUS_OK; } + + /** + * Special cookie to mark that a user is logged in. + * If this user has a permission, his comment will be published even in pre-moderation mode. + */ + private function createCommentCookie(string $value): Cookie + { + return Cookie::create( + name: $this->cookieName . '_c', + value: $value, + expire: $value !== '' ? $this->cookieExpireTimeout() + time() : 0, + path: $this->basePath . ($this->urlPrefix === '' ? '/comment_sent' : '/'), + secure: false, + ); + } } diff --git a/_include/src/Model/AuthProvider.php b/_include/src/Model/AuthProvider.php new file mode 100644 index 0000000..5cd2da5 --- /dev/null +++ b/_include/src/Model/AuthProvider.php @@ -0,0 +1,73 @@ +<?php +/** + * @copyright 2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Model; + +use S2\Cms\Pdo\DbLayer; +use S2\Cms\Pdo\DbLayerException; +use Symfony\Component\HttpFoundation\Request; + +readonly class AuthProvider +{ + public function __construct( + private DbLayer $dbLayer, + private string $cookieName + ) { + } + + /** + * @throws DbLayerException + */ + public function isOnline(string $email): bool + { + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'count(*)', + 'FROM' => 'users AS u', + 'JOINS' => [ + [ + 'INNER JOIN' => 'users_online AS o', + 'ON' => 'o.login = u.login' + ], + ], + 'WHERE' => 'u.email = :email' + ], [ + 'email' => $email, + ]); + + $isOnline = $this->dbLayer->result($result) > 0; + + return $isOnline; + } + + /** + * @throws DbLayerException + */ + public function getAuthenticatedModeratorEmail(Request $request): ?string + { + $cookie = $request->cookies->get($this->cookieName . '_c', ''); + + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'email', + 'FROM' => 'users AS u', + 'JOINS' => [ + [ + 'INNER JOIN' => 'users_online AS o', + 'ON' => 'o.login = u.login' + ], + ], + 'WHERE' => 'u.edit_comments = 1 AND o.comment_cookie = :cookie' + ], [ + 'cookie' => $cookie, + ]); + + $email = $this->dbLayer->result($result); + + return \is_string($email) && $email !== '' ? $email : null; + } +} diff --git a/_include/src/Model/Comment/ArticleCommentStrategy.php b/_include/src/Model/Comment/ArticleCommentStrategy.php new file mode 100644 index 0000000..2a92c70 --- /dev/null +++ b/_include/src/Model/Comment/ArticleCommentStrategy.php @@ -0,0 +1,156 @@ +<?php +/** + * @copyright 2007-2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Model\Comment; + +use S2\Cms\Controller\CommentController; +use S2\Cms\Model\ArticleProvider; +use S2\Cms\Model\CommentNotifier; +use S2\Cms\Pdo\DbLayer; +use S2\Cms\Pdo\DbLayerException; +use Symfony\Component\HttpFoundation\Request; + +readonly class ArticleCommentStrategy implements CommentStrategyInterface +{ + public function __construct( + private DbLayer $dbLayer, + private ArticleProvider $articleProvider, + private CommentNotifier $commentNotifier, + ) { + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function getTargetByRequest(Request $request): ?TargetDto + { + $path = $request->getPathInfo(); + + $article = $this->articleProvider->articleFromPath($path, true); + + if ($article !== null && $article['commented'] === 0) { + return null; + } + return new TargetDto($article['id'], $article['title']); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function getTargetById(int $targetId): ?TargetDto + { + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'id, title', + 'FROM' => 'articles', + 'WHERE' => 'id = :id', + ], ['id' => $targetId]); + + $article = $this->dbLayer->fetchAssoc($result); + + if (!\is_array($article)) { + return null; + } + return new TargetDto($article['id'], $article['title']); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function save(int $targetId, string $name, string $email, bool $showEmail, bool $subscribed, string $text, string $ip): int + { + $this->dbLayer->buildAndQuery([ + 'INSERT' => 'article_id, time, ip, nick, email, show_email, subscribed, sent, shown, good, text', + 'INTO' => 'art_comments', + 'VALUES' => ':article_id, :time, :ip, :nick, :email, :show_email, :subscribed, :sent, :shown, 0, :text' + ], [ + 'article_id' => $targetId, + 'time' => time(), + 'ip' => $ip, + 'nick' => $name, + 'email' => $email, + 'show_email' => $showEmail ? 1 : 0, + 'subscribed' => $subscribed ? 1 : 0, + 'sent' => 0, + 'shown' => 0, + 'text' => $text, + ]); + + return (int)$this->dbLayer->insertId(); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function notifySubscribers(int $commentId): void + { + $this->commentNotifier->notify($commentId); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function getHashForPublishedComment(int $targetId): ?string + { + $num = $this->articleProvider->getCommentNum($targetId, false); + + return $num > 0 ? (string)$num : null; + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function getRecentComment(string $hash, string $ip): ?CommentDto + { + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'id, article_id AS target_id, email, text, nick AS name', + 'FROM' => 'art_comments', + 'WHERE' => 'ip = :ip AND shown = 0 AND sent = 0 AND time >= :time', + 'ORDER BY' => 'time DESC', + ], [ + 'ip' => $ip, + 'time' => time() - 5 * 60, // 5 minutes + ]); + + foreach ($this->dbLayer->fetchAssocAll($result) as $comment) { + if ($hash === CommentController::commentHash($comment['id'], $comment['target_id'], $comment['email'], $ip, \get_class($this))) { + return new CommentDto($comment['id'], $comment['target_id'], $comment['name'], $comment['email'], $comment['text']); + } + } + + return null; + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function publishComment(int $commentId): void + { + $this->dbLayer->buildAndQuery([ + 'UPDATE' => 'art_comments', + 'SET' => 'shown = 1', + 'WHERE' => 'id = :id', + ], ['id' => $commentId]); + } + + /** + * {@inheritdoc} + * @throws DbLayerException + */ + public function unsubscribe(int $targetId, string $email, string $code): bool + { + return $this->commentNotifier->unsubscribe($targetId, $email, $code); + } +} diff --git a/_include/src/Model/Comment/CommentDto.php b/_include/src/Model/Comment/CommentDto.php new file mode 100644 index 0000000..0396c78 --- /dev/null +++ b/_include/src/Model/Comment/CommentDto.php @@ -0,0 +1,22 @@ +<?php +/** + * @copyright 2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Model\Comment; + +readonly class CommentDto +{ + public function __construct( + public int $id, + public int $targetId, + public string $name, + public string $email, + public string $text, + ) { + } +} diff --git a/_include/src/Model/Comment/CommentStrategyInterface.php b/_include/src/Model/Comment/CommentStrategyInterface.php new file mode 100644 index 0000000..40015da --- /dev/null +++ b/_include/src/Model/Comment/CommentStrategyInterface.php @@ -0,0 +1,34 @@ +<?php +/** + * @copyright 2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Model\Comment; + +use Symfony\Component\HttpFoundation\Request; + +interface CommentStrategyInterface +{ + /** + * @return TargetDto|null Info about the entity to be commented + */ + public function getTargetByRequest(Request $request): ?TargetDto; + + public function getTargetById(int $targetId): ?TargetDto; + + public function save(int $targetId, string $name, string $email, bool $showEmail, bool $subscribed, string $text, string $ip): int; + + public function notifySubscribers(int $commentId): void; + + public function getHashForPublishedComment(int $targetId): ?string; + + public function getRecentComment(string $hash, string $ip): ?CommentDto; + + public function publishComment(int $commentId); + + public function unsubscribe(int $targetId, string $email, string $code): bool; +} diff --git a/_include/src/Model/Comment/TargetDto.php b/_include/src/Model/Comment/TargetDto.php new file mode 100644 index 0000000..66fe253 --- /dev/null +++ b/_include/src/Model/Comment/TargetDto.php @@ -0,0 +1,19 @@ +<?php +/** + * @copyright 2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Model\Comment; + +readonly class TargetDto +{ + public function __construct( + public int $id, + public string $title, + ) { + } +} diff --git a/_include/src/Model/CommentNotifier.php b/_include/src/Model/CommentNotifier.php index 2d3d87a..30f2f7c 100644 --- a/_include/src/Model/CommentNotifier.php +++ b/_include/src/Model/CommentNotifier.php @@ -13,8 +13,13 @@ use S2\Cms\Pdo\DbLayerException; /** - * Retrieves information about the comment and associated article and sends the comment to subscribed commentators. - * It also generates an unsubscribe link and marks the comment as sent. + * 1. Sends notifications on new comments: + * - Retrieves information about the comment and associated article. + * - Sends the comment to commentators who subscribed to this article. + * - Generates an unsubscribe link. + * - Marks the comment as sent. + * + * 2. Unsubscribes commentators by parameters from the unsubscribe links. */ readonly class CommentNotifier { @@ -22,7 +27,6 @@ public function __construct( private DbLayer $dbLayer, private ArticleProvider $articleProvider, private UrlBuilder $urlBuilder, - private string $baseUrl, ) { } @@ -63,12 +67,13 @@ public function notify(int $commentId): void } // Getting some info about the article commented - $query = [ + $result = $this->dbLayer->buildAndQuery([ 'SELECT' => 'title, parent_id, url', 'FROM' => 'articles', - 'WHERE' => 'id = ' . $comment['article_id'] . ' AND published = 1 AND commented = 1' - ]; - $result = $this->dbLayer->buildAndQuery($query); + 'WHERE' => 'id = :article_id AND published = 1 AND commented = 1' + ], [ + 'article_id' => $comment['article_id'], + ]); $article = $this->dbLayer->fetchAssoc($result); if (!$article) { @@ -84,26 +89,24 @@ public function notify(int $commentId): void $link = $this->urlBuilder->absLink($path . '/' . rawurlencode($article['url'])); // Fetching receivers' names and addresses - $query = [ - 'SELECT' => 'id, nick, email, ip, time', - 'FROM' => 'art_comments', - 'WHERE' => 'article_id = ' . $comment['article_id'] . ' AND subscribed = 1 AND shown = 1 AND email <> \'' . $this->dbLayer->escape($comment['email']) . '\'' - ]; - $result = $this->dbLayer->buildAndQuery($query); + $allReceivers = $this->getCommentReceivers($comment['article_id'], $comment['email'], '<>'); + // Group by email, taking last records $receivers = []; - while ($receiver = $this->dbLayer->fetchAssoc($result)) { + foreach ($allReceivers as $receiver) { $receivers[$receiver['email']] = $receiver; } + $message = s2_bbcode_to_mail($comment['text']); + foreach ($receivers as $receiver) { - $hash = md5($receiver['id'] . $receiver['ip'] . $receiver['nick'] . $receiver['email'] . $receiver['time']); + $unsubscribeLink = $this->urlBuilder->rawAbsLink('/comment_unsubscribe', [ + 'mail=' . urlencode($receiver['email']), + 'id=' . $comment['article_id'], + 'code=' . $receiver['hash'], + ]); - $unsubscribeLink = $this->baseUrl - . '/comment.php?mail=' . urlencode($receiver['email']) - . '&id=' . $comment['article_id'] - . '.&unsubscribe=' . base_convert(substr($hash, 0, 16), 16, 36); - s2_mail_comment($receiver['nick'], $receiver['email'], $comment['text'], $article['title'], $link, $comment['nick'], $unsubscribeLink); + s2_mail_comment($receiver['nick'], $receiver['email'], $message, $article['title'], $link, $comment['nick'], $unsubscribeLink); } // Toggle sent mark @@ -114,4 +117,56 @@ public function notify(int $commentId): void ]; $this->dbLayer->buildAndQuery($query); } + + /** + * @throws DbLayerException + */ + public function unsubscribe(int $articleId, string $email, string $code): bool + { + $receivers = $this->getCommentReceivers($articleId, $email, '='); + + foreach ($receivers as $receiver) { + if ($code === $receiver['hash']) { + $this->dbLayer->buildAndQuery([ + 'UPDATE' => 'art_comments', + 'SET' => 'subscribed = 0', + 'WHERE' => 'article_id = :article_id and subscribed = 1 and email = :email' + ], [ + 'article_id' => $articleId, + 'email' => $email, + ]); + + return true; + } + } + + return false; + } + + /** + * @throws DbLayerException + */ + private function getCommentReceivers(int $articleId, string $email, string $operation): array + { + if (!\in_array($operation, ['=', '<>'], true)) { + throw new \InvalidArgumentException(sprintf('Invalid operation "%s".', $operation)); + } + + $result = $this->dbLayer->buildAndQuery([ + 'SELECT' => 'id, nick, email, ip, time', + 'FROM' => 'art_comments', + 'WHERE' => 'article_id = :article_id AND subscribed = 1 AND shown = 1 AND email ' . $operation . ' :email' + ], [ + 'article_id' => $articleId, + 'email' => $email, + ]); + + $receivers = $this->dbLayer->fetchAssocAll($result); + foreach ($receivers as &$receiver) { + $receiver['hash'] = substr(base_convert(md5('art_comments' . serialize($receiver)), 16, 36), 0, 13); + } + unset($receiver); + + return $receivers; + } } diff --git a/_include/src/Model/UrlBuilder.php b/_include/src/Model/UrlBuilder.php index 6aeb62f..8cd227f 100644 --- a/_include/src/Model/UrlBuilder.php +++ b/_include/src/Model/UrlBuilder.php @@ -18,28 +18,45 @@ public function __construct( ) { } + /** + * @return string HTML-escaped relative link + */ public function link(string $path = '', array $params = []): string { return $this->basePath . $this->getRelativeUrl($path, $params); } + /** + * @return string Raw relative link, suitable for headers + */ + public function rawLink(string $path = '', array $params = []): string + { + return $this->basePath . $this->getRelativeUrl($path, $params, '&'); + } + + /** + * @return string HTML-escaped full link with protocol and domain + */ public function absLink(string $path = '', array $params = []): string { return $this->baseUrl . $this->getRelativeUrl($path, $params); } - public function hasPrefix(): bool + /** + * @return string Raw full link with protocol and domain, suitable for headers + */ + public function rawAbsLink(string $path = '', array $params = []): string { - return $this->urlPrefix !== ''; + return $this->baseUrl . $this->getRelativeUrl($path, $params, '&'); } - private function getRelativeUrl(string $path, array $params): string + public function hasPrefix(): bool { - return $this->urlPrefix . $path . (!empty($params) ? ($this->urlPrefix ? '&' : '?') . implode('&', $params) : ''); + return $this->urlPrefix !== ''; } - public function linkToFile(string $filePath): string + private function getRelativeUrl(string $path, array $params, string $amp = '&'): string { - return $this->basePath . $filePath; + return $this->urlPrefix . $path . (!empty($params) ? ($this->urlPrefix ? $amp : '?') . implode($amp, $params) : ''); } } diff --git a/_include/src/Model/User/Moderator.php b/_include/src/Model/User/Moderator.php new file mode 100644 index 0000000..d924aac --- /dev/null +++ b/_include/src/Model/User/Moderator.php @@ -0,0 +1,19 @@ +<?php +/** + * @copyright 2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Model\User; + +readonly class Moderator +{ + public function __construct( + public string $login, + public string $email, + ) { + } +} diff --git a/_include/src/Model/User/UserProvider.php b/_include/src/Model/User/UserProvider.php new file mode 100644 index 0000000..ec543bc --- /dev/null +++ b/_include/src/Model/User/UserProvider.php @@ -0,0 +1,58 @@ +<?php +/** + * @copyright 2024 Roman Parpalak + * @license https://opensource.org/license/mit MIT + * @package S2 + */ + +declare(strict_types=1); + +namespace S2\Cms\Model\User; + +use S2\Cms\Pdo\DbLayer; +use S2\Cms\Pdo\DbLayerException; + +readonly class UserProvider +{ + public function __construct( + private DbLayer $dbLayer + ) { + } + + /** + * @return Moderator[] + * @throws DbLayerException + */ + public function getModerators(array $includeEmails = [], array $excludeEmails = []): array + { + $query = [ + 'SELECT' => 'login, email', + 'FROM' => 'users', + 'WHERE' => 'hide_comments = 1 AND email <> \'\'' + ]; + $params = []; + + if (\count($includeEmails) > 0) { + $keys = []; + foreach ($includeEmails as $key => $email) { + $keys[] = ':email' . $key; + $params['email' . $key] = $email; + } + $query['WHERE'] .= ' AND email IN (' . implode(',', $keys) . ')'; + } + + foreach ($excludeEmails as $key => $email) { + $query['WHERE'] .= ' AND email <> :no_email' . $key; + $params['no_email' . $key] = $email; + } + + $result = $this->dbLayer->buildAndQuery($query, $params); + + $moderators = []; + while ($moderatorRow = $this->dbLayer->fetchAssoc($result)) { + $moderators[] = new Moderator($moderatorRow['login'], $moderatorRow['email']); + } + + return $moderators; + } +} diff --git a/_include/src/Template/HtmlTemplate.php b/_include/src/Template/HtmlTemplate.php index e733715..5868537 100644 --- a/_include/src/Template/HtmlTemplate.php +++ b/_include/src/Template/HtmlTemplate.php @@ -111,7 +111,7 @@ public function toHttpResponse(): Response if (!empty($this->page['commented']) && $this->dynamicConfigProvider->get('S2_ENABLED_COMMENTS') === '1') { $comment_array = [ - 'id' => $this->page['id'] . '.' . ($this->page['class'] ?? '') + 'id' => $this->page['id'], ]; if (!empty($this->page['comment_form']) && \is_array($this->page['comment_form'])) { @@ -123,7 +123,7 @@ public function toHttpResponse(): Response $replace['<!-- s2_comment_form -->'] = $this->viewer->render('comment_form', [ ...$comment_array, 'syntaxHelpItems' => $event->syntaxHelpItems, - 'action' => $this->urlBuilder->linkToFile('/comment.php'), + 'action' => '', ]); } else { $replace['<!-- s2_comment_form -->'] = ''; diff --git a/_lang/English/comments.php b/_lang/English/comments.php index f9b4db6..f373cfd 100644 --- a/_lang/English/comments.php +++ b/_lang/English/comments.php @@ -32,7 +32,7 @@ <text> ---------------------------------------------------------------------- -This e-mail was sent automatically. If you reply, the author +This e-mail has been sent automatically. If you reply, the author of the site will receive your answer. To unsubscribe, follow the link <unsubscribe>', @@ -62,7 +62,7 @@ 'links_in_text' => 'Remove http:// or https:// from links. Author will add links to the article if they are valuable.', 'long_nick' => 'Is your name length more than 50 symbols? It is something strange...', 'question' => 'You gave the wrong answer to the question. Try again.', - 'email' => 'Invalid e-mail. Please enter the correct e-mail, and the author of the site will contact you if it is needed. If you clear the «Show to other visitors» checkbox, your e-mail will not be shown.', + 'email' => 'Invalid e-mail. Please enter the correct e-mail, and the author of the site will contact you if it is needed. If you clear the “Show to other visitors” checkbox, your e-mail will not be shown.', 'disabled' => 'Sorry, but you cannot send comments to this site at this moment. Try it later.', 'no_item' => 'Because of an error the destination page cannot be detected. Go to the page you have commented and try again (you can copy and paste the comment text).', diff --git a/_tests/_support/AcceptanceTester.php b/_tests/_support/AcceptanceTester.php index 02eca1d..027f537 100644 --- a/_tests/_support/AcceptanceTester.php +++ b/_tests/_support/AcceptanceTester.php @@ -61,6 +61,7 @@ public function canWriteComment(bool $premoderation = false): void $name = 'Roman 🌞'; $I->fillField('name', $name); $I->fillField('email', 'roman@example.com'); + $I->checkOption('subscribed'); $I->fillField('text', 'This is my first comment! 👪🐶'); $text = $I->grabTextFrom('p#qsp'); preg_match('#(\d\d)\+(\d)#', $text, $matches); @@ -76,6 +77,19 @@ public function canWriteComment(bool $premoderation = false): void } } + public function sendComment(string $name, string $email, string $text): void + { + $I = $this; + + $I->fillField('name', $name); + $I->fillField('email', $email); + $I->fillField('text', $text); + $text = $I->grabTextFrom('p#qsp'); + preg_match('#(\d\d)\+(\d)#', $text, $matches); + $I->fillField('question', (int)$matches[1] + (int)$matches[2]); + $I->click('submit'); + } + public function login(string $username = 'admin', string $userpass = ''): void { $I = $this; @@ -117,15 +131,13 @@ public function changeSetting(string $paramName, int|string|bool $value): void $I->amOnPage('/_admin/index.php?entity=Config&action=list'); $I->seeResponseCodeIsSuccessful(); - // $I->submitForm('//form[contains(@action, \'S2_PREMODERATION\')]', ['value' => true]); - $I->submitForm('form[action="?entity=Config&action=patch&field=value&name=' . $paramName . '"]', [ 'value' => $value, ]); $I->seeResponseCodeIsSuccessful(); } - public function clearEmail(): void + public function clearEmails(): void { $fi = new FilesystemIterator($this->getEmailDir(), FilesystemIterator::SKIP_DOTS); foreach ($fi as $f) { diff --git a/_tests/acceptance/InstallCest.php b/_tests/acceptance/InstallCest.php index 35e3c7f..9f58815 100644 --- a/_tests/acceptance/InstallCest.php +++ b/_tests/acceptance/InstallCest.php @@ -43,6 +43,16 @@ public function runTest(AcceptanceTester $I): void } } + private static function getCookieName(): string + { + static $s2_cookie_name = null; + if ($s2_cookie_name === null) { + include __DIR__ . '/../../config.test.php'; + } + + return $s2_cookie_name; + } + /** * @throws \JsonException */ @@ -72,6 +82,7 @@ protected function tryToTest(AcceptanceTester $I, Example $example): void $this->testRssAndSitemap($I); $this->testAdminTagListAndEdit($I); $this->testAdminCommentManagement($I); + $this->testETag($I); } private function testHierarchyRedirects(AcceptanceTester $I): void @@ -367,7 +378,7 @@ private function testBlogExtension(AcceptanceTester $I): void $I->amOnPage('/blog'); $I->seeResponseCodeIs(301); $I->followRedirect(); - $I->seeCurrentUrlEquals( self::URL_PREFIX . '/blog/'); + $I->seeCurrentUrlEquals(self::URL_PREFIX . '/blog/'); $I->seeResponseCodeIsSuccessful(); $I->see('New Blog Post Title'); $I->see('New blog post'); @@ -465,13 +476,14 @@ private function testAdminTagListAndEdit(AcceptanceTester $I): void private function testAdminCommentManagement(AcceptanceTester $I): void { - $I->sendAjaxPostRequest('/_admin/index.php?entity=Comment&action=list'); + $I->amOnPage('/_admin/index.php?entity=Comment&action=list'); $I->see('This is my first comment!'); $I->changeSetting('S2_PREMODERATION', true); $I->changeSetting('S2_WEBMASTER_EMAIL', 'webmaster@example.com'); $I->changeSetting('S2_WEBMASTER', 'Webmaster Name'); + // Set moderator email $I->amOnPage('/_admin/index.php?entity=User&action=list'); $I->seeResponseCodeIsSuccessful(); $I->submitForm('form[action="?entity=User&action=patch&field=email&id=' . 1 . '"]', [ @@ -480,10 +492,71 @@ private function testAdminCommentManagement(AcceptanceTester $I): void $I->seeResponseCodeIsSuccessful(); $I->see('{"success":true}'); - $I->clearEmail(); + $this->testComments($I, '/section1/new_page1', 'New Page Title', 'Some new page text', 3, 'Comment', 'article_id'); + $this->testComments($I, '/blog/2023/08/12/new_post1', 'New Blog Post Title', 'New blog post', 1, 'BlogComment', 'post_id'); + } + + private function testETag(AcceptanceTester $I): void + { + // Disable comments + $I->changeSetting('S2_SHOW_COMMENTS', false); + $I->changeSetting('S2_ENABLED_COMMENTS', false); + + // Test <!-- s2_last_comments --> and <!-- s2_last_discussions --> placeholders when comments are disabled + $I->amOnPage('/'); + $I->seeResponseCodeIsSuccessful(); + // Check conditional get when the comment form is disabled. Otherwise, there are some random tokens. + // Last comments must be also hidden. $I->amOnPage('/section1/new_page1'); - $I->see('Some new page text'); + $headers = $I->grabHeaders(); + $I->haveHttpHeader('If-None-Match', $headers['ETag'][0]); + $I->amOnPage('/section1/new_page1'); + $I->seeResponseCodeIs(304); + } + + private function assertJsonResponseContains(AcceptanceTester $I, array $path, string $needle): void + { + $response = json_decode($I->grabPageSource(), true, 512, JSON_THROW_ON_ERROR); + foreach ($path as $value) { + $I->assertArrayHasKey($value, $response); + $response = $response[$value]; + } + $I->assertStringContainsString($needle, $response); + } + + private function testComments( + AcceptanceTester $I, + string $publicUrl, + string $pageTitle, + string $pageText, + int $targetId, + string $commentEntity, + string $targetIdName + ): void { + /** + * Empty form validation and preview + */ + $I->amOnPage($publicUrl); + $I->click('submit'); + $I->see('You have forgotten to enter the comment text.'); + $I->see('Invalid e-mail. Please enter the correct e-mail'); + $I->see('You have forgotten to enter your name.'); + + $I->fillField('name', 'Tester Name'); + $I->fillField('email', 'tester@example.com'); + $I->fillField('text', 'This is a test comment'); + $I->click('preview'); + $I->see('Your comment has not been saved yet!'); + $I->see('Tester Name'); + $I->see('This is a test comment'); + + /** + * Testing that a comment with unknown email <roman@example.com> is not published when pre-moderation is enabled + */ + $I->clearEmails(); + $I->amOnPage($publicUrl); + $I->see($pageText); $I->canWriteComment(true); $emails = $I->getEmails(); @@ -491,7 +564,7 @@ private function testAdminCommentManagement(AcceptanceTester $I): void // Two asserts to skip variable "Date" header $I->assertStringContainsString('To: admin@example.com' . "\r\n" . - 'Subject: =?UTF-8?B?Q29tbWVudCB0byBodHRwOi8vbG9jYWxob3N0Ojg4ODEvaW5kZXgucGhwPy9zZWN0aW9uMS9uZXdfcGFnZTE=?=' . "\r\n" . + 'Subject: =?UTF-8?B?' . base64_encode('Comment to http://localhost:8881/index.php?' . $publicUrl) . '?=' . "\r\n" . 'From: =?UTF-8?B?V2VibWFzdGVyIE5hbWU=?= <webmaster@example.com>' . "\r\n" . 'Sender: =?UTF-8?B?Um9tYW4g8J+Mng==?= <roman@example.com>' . "\r\n" . 'Date: ', $emails[0]); @@ -507,9 +580,9 @@ private function testAdminCommentManagement(AcceptanceTester $I): void '' . "\r\n" . 'You have received this e-mail, because you are the moderator.' . "\r\n" . 'A new comment on' . "\r\n" . - '“New Page Title”,' . "\r\n" . + '“' . $pageTitle . '”,' . "\r\n" . 'has been received. You can find it here:' . "\r\n" . - 'http://localhost:8881/index.php?/section1/new_page1' . "\r\n" . + 'http://localhost:8881/index.php?' . $publicUrl . "\r\n" . '' . "\r\n" . 'Roman 🌞 is the comment author.' . "\r\n" . '' . "\r\n" . @@ -521,33 +594,203 @@ private function testAdminCommentManagement(AcceptanceTester $I): void 'of the comment will receive your answer.' . "\r\n" . '', $emails[0]); - // TODO check showing and hiding comments - // TODO check deleting comments + /** + * Testing that a comment with known email <admin@example.com> is published when pre-moderation is enabled + * and user is logged in + */ + $I->clearEmails(); + $I->amOnPage($publicUrl); + $I->see($pageText); - // Disable comments - $I->changeSetting('S2_SHOW_COMMENTS', false); - $I->changeSetting('S2_ENABLED_COMMENTS', false); + $I->sendComment('Moderator', 'admin@example.com', 'This is a comment from a moderator.'); + $I->seeResponseCodeIs(200); + $I->dontSee('Your comment has been successfully sent. It will be published after the verification.'); + $I->see('Moderator wrote:'); + $I->see('This is a comment from a moderator.'); - // Test <!-- s2_last_comments --> and <!-- s2_last_discussions --> placeholders when comments are disabled - $I->amOnPage('/'); - $I->seeResponseCodeIsSuccessful(); + // Email to subscribed user + $emails = $I->getEmails(); + $I->assertCount(1, $emails); + $I->assertStringContainsString('To: roman@example.com' . "\r\n" . + 'Subject: =?UTF-8?B?' . base64_encode('Comment to http://localhost:8881/index.php?' . $publicUrl) . '?=' . "\r\n" . + 'From: =?UTF-8?B?' . base64_encode('Webmaster Name') . '?= <webmaster@example.com>' . "\r\n" . + 'Date: ', $emails[0]); + $I->assertStringContainsString(' +0000' . "\r\n" . + 'MIME-Version: 1.0' . "\r\n" . + 'Content-transfer-encoding: 8bit' . "\r\n" . + 'Content-type: text/plain; charset=utf-8' . "\r\n" . + 'X-Mailer: S2 Mailer' . "\r\n" . + 'List-Unsubscribe: <http://localhost:8881/index.php?/comment_unsubscribe&mail=roman%40example.com&id=' . $targetId . '&code=' + , $emails[0]); + $I->assertStringContainsString( + 'Reply-To: =?UTF-8?B?' . base64_encode('Webmaster Name') . '?= <webmaster@example.com>' . "\r\n" . + '' . "\r\n" . + 'Hello, Roman 🌞.' . "\r\n" . + '' . "\r\n" . + 'You have received this e-mail, because you have subscribed for the article' . "\r\n" . + '“' . $pageTitle . '”,' . "\r\n" . + 'located at the address:' . "\r\n" . + 'http://localhost:8881/index.php?' . $publicUrl . "\r\n" . + '' . "\r\n" . + 'The author of the new comment is Moderator.' . "\r\n" . + '' . "\r\n" . + '----------------------------------------------------------------------' . "\r\n" . + 'This is a comment from a moderator.' . "\r\n" . + '----------------------------------------------------------------------' . "\r\n" . + '' . "\r\n" . + 'This e-mail has been sent automatically. If you reply, the author' . "\r\n" . + 'of the site will receive your answer. To unsubscribe, follow the link' . "\r\n" . + '' . "\r\n" . + 'http://localhost:8881/index.php?/comment_unsubscribe&mail=roman%40example.com&id=' . $targetId . '&code=' + , $emails[0]); + + /** + * Testing that a comment with known email <admin@example.com> is not published when pre-moderation is enabled + * and user is not logged in + */ + $commentCookie = $I->grabCookie(self::getCookieName() . '_c'); + $I->setCookie(self::getCookieName() . '_c', 'wrong_value'); + + $I->clearEmails(); + $I->amOnPage($publicUrl); + $I->see($pageText); + $I->sendComment('Moderator2', 'admin@example.com', 'This is a comment from a moderator2.'); + $I->seeResponseCodeIs(200); + $I->see('Your comment has been successfully sent. It will be published after the verification.'); + $I->dontSee('Moderator2 wrote:'); + $I->dontSee('This is a comment from a moderator2.'); - // Check conditional get when the comment form is disabled. Otherwise, there are some random tokens. - // Last comments must be also hidden. - $I->amOnPage('/section1/new_page1'); - $headers = $I->grabHeaders(); - $I->haveHttpHeader('If-None-Match', $headers['ETag'][0]); - $I->amOnPage('/section1/new_page1'); - $I->seeResponseCodeIs(304); - } + $emails = $I->getEmails(); + $I->assertCount(1, $emails); + $I->assertStringContainsString('To: admin@example.com' . "\r\n" . + 'Subject: =?UTF-8?B?' . base64_encode('Comment to http://localhost:8881/index.php?' . $publicUrl) . '?=' . "\r\n" . + 'From: =?UTF-8?B?V2VibWFzdGVyIE5hbWU=?= <webmaster@example.com>' . "\r\n" . + 'Sender: =?UTF-8?B?TW9kZXJhdG9yMg==?= <admin@example.com>' . "\r\n" . + 'Date: ', $emails[0]); - private function assertJsonResponseContains(AcceptanceTester $I, array $path, string $needle): void - { - $response = json_decode($I->grabPageSource(), true, 512, JSON_THROW_ON_ERROR); - foreach ($path as $value) { - $I->assertArrayHasKey($value, $response); - $response = $response[$value]; - } - $I->assertStringContainsString($needle, $response); + $I->assertStringContainsString(' +0000' . "\r\n" . + 'MIME-Version: 1.0' . "\r\n" . + 'Content-transfer-encoding: 8bit' . "\r\n" . + 'Content-type: text/plain; charset=utf-8' . "\r\n" . + 'X-Mailer: S2 Mailer' . "\r\n" . + 'Reply-To: =?UTF-8?B?TW9kZXJhdG9yMg==?= <admin@example.com>' . "\r\n" . + '' . "\r\n" . + 'Hello, admin.' . "\r\n" . + '' . "\r\n" . + 'You have received this e-mail, because you are the moderator.' . "\r\n" . + 'A new comment on' . "\r\n" . + '“' . $pageTitle . '”,' . "\r\n" . + 'has been received. You can find it here:' . "\r\n" . + 'http://localhost:8881/index.php?' . $publicUrl . "\r\n" . + '' . "\r\n" . + 'Moderator2 is the comment author.' . "\r\n" . + '' . "\r\n" . + '----------------------------------------------------------------------' . "\r\n" . + 'This is a comment from a moderator2.' . "\r\n" . + '----------------------------------------------------------------------' . "\r\n" . + '' . "\r\n" . + 'This e-mail has been sent automatically. If you reply, the author' . "\r\n" . + 'of the comment will receive your answer.' . "\r\n" . + '', $emails[0]); + + + /** + * Check comment notifications to subscribers after moderation approval + */ + $I->clearEmails(); + $I->amOnPage('/_admin/index.php?entity=' . $commentEntity . '&action=list&' . $targetIdName . '=' . $targetId); + $I->submitForm('form[action="?entity=' . $commentEntity . '&action=patch&field=shown&id=4"]', [ + 'shown' => 'on', + ]); + + $emails = $I->getEmails(); + $I->assertCount(1, $emails); + $I->assertStringContainsString('To: roman@example.com' . "\r\n" . + 'Subject: =?UTF-8?B?' . base64_encode('Comment to http://localhost:8881/index.php?' . $publicUrl) . '?=' . "\r\n" . + 'From: =?UTF-8?B?' . base64_encode('Webmaster Name') . '?= <webmaster@example.com>' . "\r\n" . + 'Date: ', $emails[0]); + $I->assertStringContainsString( + 'Hello, Roman 🌞.' . "\r\n" . + '' . "\r\n" . + 'You have received this e-mail, because you have subscribed for the article' . "\r\n" . + '“' . $pageTitle . '”,' . "\r\n" . + 'located at the address:' . "\r\n" . + 'http://localhost:8881/index.php?' . $publicUrl . "\r\n" . + '' . "\r\n" . + 'The author of the new comment is Moderator2.' . "\r\n" . + '' . "\r\n" . + '----------------------------------------------------------------------' . "\r\n" . + 'This is a comment from a moderator2.' . "\r\n" . + '----------------------------------------------------------------------' . "\r\n" . + '' . "\r\n" . + 'This e-mail has been sent automatically. If you reply, the author' . "\r\n" . + 'of the site will receive your answer. To unsubscribe, follow the link' . "\r\n" . + '' . "\r\n" . + 'http://localhost:8881/index.php?/comment_unsubscribe&mail=roman%40example.com&id=' . $targetId . '&code=' + , $emails[0]); + + $I->assertEquals(1, preg_match('#List-Unsubscribe: <([^<]+)>#', $emails[0], $matches)); + $unsubscribeLink = $matches[1]; + + $I->amOnPage($publicUrl); + $I->see('Moderator2 wrote:'); + $I->see('This is a comment from a moderator2.'); + + + /** + * Test hiding + */ + $I->amOnPage('/_admin/index.php?entity=' . $commentEntity . '&action=list&' . $targetIdName . '=' . $targetId); + $I->uncheckOption('form[action="?entity=' . $commentEntity . '&action=patch&field=shown&id=4"] input[name="shown"]'); + $I->submitForm('form[action="?entity=' . $commentEntity . '&action=patch&field=shown&id=4"]', []); + $I->amOnPage($publicUrl); + $I->dontSee('Moderator2 wrote:'); + $I->dontSee('This is a comment from a moderator2.'); + + /** + * Test no emails on republication + */ + $I->clearEmails(); + $I->amOnPage('/_admin/index.php?entity=' . $commentEntity . '&action=list&' . $targetIdName . '=' . $targetId); + $I->submitForm('form[action="?entity=' . $commentEntity . '&action=patch&field=shown&id=4"]', [ + 'shown' => 'on', + ]); + $I->amOnPage($publicUrl); + $I->see('Moderator2 wrote:'); + $I->see('This is a comment from a moderator2.'); + $I->assertCount(0, $I->getEmails()); + + /** + * Test unsubscribing + */ + $I->amOnPage($unsubscribeLink); + $I->seeResponseCodeIs(200); + $I->see('You have been successfully unsubscribed from mailing comments.'); + + $I->amOnPage($unsubscribeLink); + $I->seeResponseCodeIs(200); + $I->see('Probably, you followed an incorrect or outdated link.'); + + /** + * Test no emails after unsubscribe + */ + $I->clearEmails(); + $I->amOnPage($publicUrl); + $I->setCookie(self::getCookieName() . '_c', $commentCookie); + $I->sendComment('Moderator3', 'admin@example.com', 'This is a comment from a moderator3.'); + $I->see('Moderator3 wrote:'); + $I->see('This is a comment from a moderator3.'); + $I->assertCount(0, $I->getEmails()); + + /** + * Test deleting + */ + $I->amOnPage('/_admin/index.php?entity=' . $commentEntity . '&action=list&' . $targetIdName . '=' . $targetId); + $onClickHandler = $I->grabAttributeFrom('[href="?entity=' . $commentEntity . '&action=delete&id=5"]', 'onclick'); + $csrfToken = substr($onClickHandler, strrpos($onClickHandler, 'csrf_token=') + 11, 40); + $I->sendAjaxPostRequest('/_admin/index.php?entity=' . $commentEntity . '&action=delete&id=5', ['csrf_token' => $csrfToken]); + $I->amOnPage($publicUrl); + $I->dontSee('Moderator3 wrote:'); + $I->dontSee('This is a comment from a moderator3.'); } } diff --git a/test_sh b/test_sh index 4682844..a740e52 100644 --- a/test_sh +++ b/test_sh @@ -18,7 +18,7 @@ APP_ENV=test \ nohup php \ -d "max_execution_time=-1" \ -d "opcache.revalidate_freq=0" \ - -d "sendmail_path=_tests/_resources/sendmail.php --dir\\=_tests/_output/email/" \ + -d "sendmail_path=$(realpath $(dirname "$0")/_tests/_resources/sendmail.php) --dir\\=$(realpath $(dirname "$0")/_tests/_output/email/)" \ -S localhost:8881 >/dev/null 2>&1 & serverPID=$!