v1.0.0.6
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/site-front.php';
- require_once __DIR__ . '/language.php';
- require_once __DIR__ . '/site-comments.php';
-
- // Comment systems for articles. Every setting is validated on save and escaped on output.
- final class Comments
- {
- public const PROVIDERS = [
- 'builtin' => 'Publisium (built in, no outside service)',
- 'disqus' => 'Disqus',
- 'commento' => 'Commento / Commento++',
- 'remark42' => 'Remark42 (self-hosted)',
- 'isso' => 'Isso (self-hosted)',
- 'cusdis' => 'Cusdis (cloud or self-hosted)',
- 'giscus' => 'Giscus (GitHub Discussions)',
- 'utterances' => 'Utterances (GitHub Issues)',
- 'hyvor' => 'Hyvor Talk',
- 'custom' => 'Your own code (any other service)',
- ];
-
- // Which settings each service uses; the settings page shows only these.
- public const FIELDS = [
- 'builtin' => ['comments_who', 'comments_moderation', 'comments_email'],
- 'disqus' => ['comments_shortname'],
- 'commento' => ['comments_server_url'],
- 'remark42' => ['comments_server_url', 'comments_site_id'],
- 'isso' => ['comments_server_url'],
- 'cusdis' => ['comments_server_url', 'comments_site_id'],
- 'giscus' => ['comments_repo', 'comments_repo_id', 'comments_category', 'comments_category_id', 'comments_mapping', 'comments_server_url'],
- 'utterances' => ['comments_repo', 'comments_mapping'],
- 'hyvor' => ['comments_site_id'],
- 'custom' => ['comments_custom_html'],
- ];
-
- public const DEFAULT_SERVERS = [
- 'commento' => 'https://cdn.commento.io',
- 'cusdis' => 'https://cusdis.com',
- 'giscus' => 'https://giscus.app',
- ];
-
- public const MAPPINGS = ['pathname', 'url', 'title', 'og:title'];
-
- // Cleans the posted comment settings. Anything that doesn't fit its format is dropped.
- public static function sanitizeSettings(array $post): array
- {
- $provider = array_key_exists((string) ($post['comments_provider'] ?? ''), self::PROVIDERS) ? (string) $post['comments_provider'] : 'disqus';
- $text = static fn(string $key, int $max): string => mb_substr(trim(str_replace(["\r", "\n"], ' ', (string) ($post[$key] ?? ''))), 0, $max);
- $match = static fn(string $value, string $pattern): string => preg_match($pattern, $value) === 1 ? $value : '';
-
- return [
- 'comments_enabled' => isset($post['comments_enabled']) ? '1' : '0',
- 'comments_provider' => $provider,
- 'comments_shortname' => $match(strtolower($text('comments_shortname', 64)), '/^[a-z0-9-]{1,64}$/'),
- 'comments_server_url' => self::cleanServerUrl($text('comments_server_url', 255)),
- 'comments_site_id' => $match($text('comments_site_id', 100), '/^[A-Za-z0-9_.:-]{1,100}$/'),
- 'comments_repo' => $match($text('comments_repo', 140), '/^[A-Za-z0-9_.-]{1,39}\/[A-Za-z0-9_.-]{1,100}$/'),
- 'comments_repo_id' => $match($text('comments_repo_id', 64), '/^[A-Za-z0-9_=-]{1,64}$/'),
- 'comments_category' => $text('comments_category', 100),
- 'comments_category_id' => $match($text('comments_category_id', 64), '/^[A-Za-z0-9_=-]{1,64}$/'),
- 'comments_mapping' => in_array($post['comments_mapping'] ?? '', self::MAPPINGS, true) ? (string) $post['comments_mapping'] : 'pathname',
- 'comments_custom_html' => (string) ($post['comments_custom_html'] ?? ''),
- 'comments_consent' => in_array($post['comments_consent'] ?? '', ['none', 'analytics', 'marketing'], true) ? (string) $post['comments_consent'] : 'marketing',
- 'comments_load' => ($post['comments_load'] ?? '') === 'click' ? 'click' : 'auto',
- 'comments_who' => array_key_exists((string) ($post['comments_who'] ?? ''), SiteComments::WHO) ? (string) $post['comments_who'] : 'users',
- 'comments_moderation' => array_key_exists((string) ($post['comments_moderation'] ?? ''), SiteComments::MODERATION) ? (string) $post['comments_moderation'] : 'all',
- 'comments_email' => array_key_exists((string) ($post['comments_email'] ?? ''), SiteComments::EMAIL) ? (string) $post['comments_email'] : 'off',
- ];
- }
-
- public static function cleanServerUrl(string $url): string
- {
- $url = rtrim(trim($url), '/');
- if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
- return '';
- }
- $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
- if (!in_array($scheme, ['https', 'http'], true) || preg_match('/["\'<>\s\\\\]/', $url) === 1) {
- return '';
- }
-
- return $url;
- }
-
- public static function isConfigured(array $settings): bool
- {
- if (($settings['comments_provider'] ?? '') === 'builtin') {
- return true;
- }
-
- return self::embedCode($settings, ['url' => '', 'id' => 'x', 'title' => '', 'slug' => 'x', 'lang' => 'en']) !== '';
- }
-
- // The comments box under an article: the service's code sits in a <template> and site.js
- // loads it once consent (if needed) and the visitor's click (if needed) allow it.
- public static function render(array $settings, array $article): string
- {
- if (($settings['comments_provider'] ?? '') === 'builtin') {
- return SiteComments::render($settings, $article);
- }
-
- $page = [
- 'url' => rtrim((string) Config::get('APP_URL', ''), '/') . SiteFront::articleUrl((string) $article['slug'], ''),
- 'id' => 'article-' . (int) $article['id'],
- 'title' => (string) $article['title'],
- 'slug' => (string) $article['slug'],
- 'lang' => Languages::htmlLang(),
- ];
-
- $code = self::embedCode($settings, $page);
- if ($code === '') {
- return '<p class="comments-placeholder">' . htmlspecialchars(Language::get('comments_coming_soon', 'Comments are coming soon.')) . '</p>';
- }
-
- $consent = ($settings['consent_manager_enabled'] ?? '0') === '1' ? (string) ($settings['comments_consent'] ?? 'marketing') : 'none';
- $consent = in_array($consent, ['analytics', 'marketing'], true) ? $consent : '';
- $load = ($settings['comments_load'] ?? 'auto') === 'click' ? 'click' : 'auto';
-
- return '<div class="comments-embed" data-consent="' . $consent . '" data-load="' . $load . '">'
- . '<template class="comments-embed-code">' . str_ireplace('</template', '<\/template', $code) . '</template>'
- . '<div class="comments-embed-target"></div>'
- . '<div class="comments-embed-notice" hidden><p>' . htmlspecialchars(Language::get('comments_consent_text', 'Comments come from an outside service that uses cookies. Allow them in the cookie settings to read and write comments.')) . '</p>'
- . '<button type="button" class="comments-embed-consent">' . htmlspecialchars(Language::get('comments_consent_button', 'Cookie settings')) . '</button></div>'
- . '<div class="comments-embed-click" hidden><p>' . htmlspecialchars(Language::get('comments_load_note', 'Comments are loaded from an outside service only when you ask for them.')) . '</p>'
- . '<button type="button" class="comments-embed-load">' . htmlspecialchars(Language::get('comments_load_button', 'Show comments')) . '</button></div>'
- . '</div>';
- }
-
- private static function embedCode(array $settings, array $page): string
- {
- $e =static fn(string $value): string => htmlspecialchars($value, ENT_QUOTES);
- $js = static fn(string $value): string => (string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
- $provider = (string) ($settings['comments_provider'] ?? '');
- $server = self::cleanServerUrl((string) ($settings['comments_server_url'] ?? ''));
- $server = $server !== '' ? $server : (self::DEFAULT_SERVERS[$provider] ?? '');
- $siteId = (string) ($settings['comments_site_id'] ?? '');
- $repo = (string) ($settings['comments_repo'] ?? '');
- $mapping = in_array($settings['comments_mapping'] ?? '', self::MAPPINGS, true) ? (string) $settings['comments_mapping'] : 'pathname';
-
- switch ($provider) {
- case 'disqus':
- $shortname = (string) ($settings['comments_shortname'] ?? '');
- if (preg_match('/^[a-z0-9-]{1,64}$/', $shortname) !== 1) {
- return '';
- }
- return '<div id="disqus_thread"></div><script>'
- . 'var disqus_config = function () { this.page.url = ' . $js($page['url']) . '; this.page.identifier = ' . $js($page['id']) . '; this.page.title = ' . $js($page['title']) . '; };'
- . '(function () { var s = document.createElement("script"); s.src = "https://' . $shortname . '.disqus.com/embed.js"; s.setAttribute("data-timestamp", String(+new Date())); (document.head || document.body).appendChild(s); })();'
- . '</script>';
-
- case 'commento':
- if ($server === '') {
- return '';
- }
- return '<div id="commento"></div><script defer src="' . $e($server . '/js/commento.js') . '" data-page-id="' . $e($page['slug']) . '"></script>';
-
- case 'remark42':
- if ($server === '' || $siteId === '') {
- return '';
- }
- return '<div id="remark42"></div><script>'
- . 'var remark_config = { host: ' . $js($server) . ', site_id: ' . $js($siteId) . ', url: ' . $js($page['url']) . ', page_title: ' . $js($page['title']) . ', components: ["embed"], theme: document.body.getAttribute("data-theme") === "dark" ? "dark" : "light" };'
- . '(function (c) { for (var i = 0; i < c.length; i++) { var d = document, s = d.createElement("script"); s.src = remark_config.host + "/web/" + c[i] + ".js"; s.defer = true; (d.head || d.body).appendChild(s); } })(remark_config.components);'
- . '</script>';
-
- case 'isso':
- if ($server === '') {
- return '';
- }
- return '<script data-isso="' . $e($server . '/') . '" data-isso-lang="' . $e($page['lang']) . '" src="' . $e($server . '/js/embed.min.js') . '"></script>'
- . '<section id="isso-thread" data-isso-id="' . $e($page['id']) . '" data-title="' . $e($page['title']) . '"></section>';
-
- case 'cusdis':
- if ($server === '' || $siteId === '') {
- return '';
- }
- return '<div id="cusdis_thread" data-host="' . $e($server) . '" data-app-id="' . $e($siteId) . '" data-page-id="' . $e($page['id']) . '" data-page-url="' . $e($page['url']) . '" data-page-title="' . $e($page['title']) . '"></div>'
- . '<script async defer src="' . $e($server . '/js/cusdis.es.js') . '"></script>';
-
- case 'giscus':
- $repoId = (string) ($settings['comments_repo_id'] ?? '');
- $categoryId = (string) ($settings['comments_category_id'] ?? '');
- if ($server === '' || $repo === '' || $repoId === '' || $categoryId === '') {
- return '';
- }
- return '<script src="' . $e($server . '/client.js') . '" data-repo="' . $e($repo) . '" data-repo-id="' . $e($repoId) . '"'
- . ' data-category="' . $e((string) ($settings['comments_category'] ?? '')) . '" data-category-id="' . $e($categoryId) . '"'
- . ' data-mapping="' . $e($mapping) . '" data-strict="0" data-reactions-enabled="1" data-emit-metadata="0" data-input-position="top"'
- . ' data-theme="preferred_color_scheme" data-lang="' . $e(substr($page['lang'], 0, 2)) . '" data-loading="lazy" crossorigin="anonymous" async></script>';
-
- case 'utterances':
- if ($repo === '') {
- return '';
- }
- return '<script src="https://utteranc.es/client.js" repo="' . $e($repo) . '" issue-term="' . $e($mapping) . '" theme="preferred-color-scheme" crossorigin="anonymous" async></script>';
-
- case 'hyvor':
- if ($siteId === '') {
- return '';
- }
- return '<script async src="https://talk.hyvor.com/embed/embed.js" type="module"></script>'
- . '<hyvor-talk-comments website-id="' . $e($siteId) . '" page-id="' . $e($page['id']) . '"></hyvor-talk-comments>';
-
- case 'custom':
- $template = trim((string) ($settings['comments_custom_html'] ?? ''));
- if ($template === '') {
- return '';
- }
- // Values are HTML-escaped and made safe for JavaScript strings (quotes, backslashes,
- // backticks and ${), so they can't break out of the code they are placed in.
- $safe = static fn(string $value): string => str_replace(['\\', '`', '${', "\r", "\n"], ['\', '`', '${', ' ', ' '], htmlspecialchars($value, ENT_QUOTES));
- return strtr($template, [
- '{{url}}' => $safe($page['url']),
- '{{id}}' => $safe($page['id']),
- '{{title}}' => $safe($page['title']),
- '{{slug}}' => $safe($page['slug']),
- '{{lang}}' => $safe($page['lang']),
- ]);
- }
-
- return '';
- }
- }
-