WebOrbiton
v1.0.0.6

Publisium

227 lines · 13.3 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/site-front.php';
  6. require_once __DIR__ . '/language.php';
  7. require_once __DIR__ . '/site-comments.php';
  8. ​
  9. // Comment systems for articles. Every setting is validated on save and escaped on output.
  10. final class Comments
  11. {
  12. public const PROVIDERS = [
  13. 'builtin' => 'Publisium (built in, no outside service)',
  14. 'disqus' => 'Disqus',
  15. 'commento' => 'Commento / Commento++',
  16. 'remark42' => 'Remark42 (self-hosted)',
  17. 'isso' => 'Isso (self-hosted)',
  18. 'cusdis' => 'Cusdis (cloud or self-hosted)',
  19. 'giscus' => 'Giscus (GitHub Discussions)',
  20. 'utterances' => 'Utterances (GitHub Issues)',
  21. 'hyvor' => 'Hyvor Talk',
  22. 'custom' => 'Your own code (any other service)',
  23. ];
  24. ​
  25. // Which settings each service uses; the settings page shows only these.
  26. public const FIELDS = [
  27. 'builtin' => ['comments_who', 'comments_moderation', 'comments_email'],
  28. 'disqus' => ['comments_shortname'],
  29. 'commento' => ['comments_server_url'],
  30. 'remark42' => ['comments_server_url', 'comments_site_id'],
  31. 'isso' => ['comments_server_url'],
  32. 'cusdis' => ['comments_server_url', 'comments_site_id'],
  33. 'giscus' => ['comments_repo', 'comments_repo_id', 'comments_category', 'comments_category_id', 'comments_mapping', 'comments_server_url'],
  34. 'utterances' => ['comments_repo', 'comments_mapping'],
  35. 'hyvor' => ['comments_site_id'],
  36. 'custom' => ['comments_custom_html'],
  37. ];
  38. ​
  39. public const DEFAULT_SERVERS = [
  40. 'commento' => 'https://cdn.commento.io',
  41. 'cusdis' => 'https://cusdis.com',
  42. 'giscus' => 'https://giscus.app',
  43. ];
  44. ​
  45. public const MAPPINGS = ['pathname', 'url', 'title', 'og:title'];
  46. ​
  47. // Cleans the posted comment settings. Anything that doesn't fit its format is dropped.
  48. public static function sanitizeSettings(array $post): array
  49. {
  50. $provider = array_key_exists((string) ($post['comments_provider'] ?? ''), self::PROVIDERS) ? (string) $post['comments_provider'] : 'disqus';
  51. $text = static fn(string $key, int $max): string => mb_substr(trim(str_replace(["\r", "\n"], ' ', (string) ($post[$key] ?? ''))), 0, $max);
  52. $match = static fn(string $value, string $pattern): string => preg_match($pattern, $value) === 1 ? $value : '';
  53. ​
  54. return [
  55. 'comments_enabled' => isset($post['comments_enabled']) ? '1' : '0',
  56. 'comments_provider' => $provider,
  57. 'comments_shortname' => $match(strtolower($text('comments_shortname', 64)), '/^[a-z0-9-]{1,64}$/'),
  58. 'comments_server_url' => self::cleanServerUrl($text('comments_server_url', 255)),
  59. 'comments_site_id' => $match($text('comments_site_id', 100), '/^[A-Za-z0-9_.:-]{1,100}$/'),
  60. 'comments_repo' => $match($text('comments_repo', 140), '/^[A-Za-z0-9_.-]{1,39}\/[A-Za-z0-9_.-]{1,100}$/'),
  61. 'comments_repo_id' => $match($text('comments_repo_id', 64), '/^[A-Za-z0-9_=-]{1,64}$/'),
  62. 'comments_category' => $text('comments_category', 100),
  63. 'comments_category_id' => $match($text('comments_category_id', 64), '/^[A-Za-z0-9_=-]{1,64}$/'),
  64. 'comments_mapping' => in_array($post['comments_mapping'] ?? '', self::MAPPINGS, true) ? (string) $post['comments_mapping'] : 'pathname',
  65. 'comments_custom_html' => (string) ($post['comments_custom_html'] ?? ''),
  66. 'comments_consent' => in_array($post['comments_consent'] ?? '', ['none', 'analytics', 'marketing'], true) ? (string) $post['comments_consent'] : 'marketing',
  67. 'comments_load' => ($post['comments_load'] ?? '') === 'click' ? 'click' : 'auto',
  68. 'comments_who' => array_key_exists((string) ($post['comments_who'] ?? ''), SiteComments::WHO) ? (string) $post['comments_who'] : 'users',
  69. 'comments_moderation' => array_key_exists((string) ($post['comments_moderation'] ?? ''), SiteComments::MODERATION) ? (string) $post['comments_moderation'] : 'all',
  70. 'comments_email' => array_key_exists((string) ($post['comments_email'] ?? ''), SiteComments::EMAIL) ? (string) $post['comments_email'] : 'off',
  71. ];
  72. }
  73. ​
  74. public static function cleanServerUrl(string $url): string
  75. {
  76. $url = rtrim(trim($url), '/');
  77. if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) {
  78. return '';
  79. }
  80. $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
  81. if (!in_array($scheme, ['https', 'http'], true) || preg_match('/["\'<>\s\\\\]/', $url) === 1) {
  82. return '';
  83. }
  84. ​
  85. return $url;
  86. }
  87. ​
  88. public static function isConfigured(array $settings): bool
  89. {
  90. if (($settings['comments_provider'] ?? '') === 'builtin') {
  91. return true;
  92. }
  93. ​
  94. return self::embedCode($settings, ['url' => '', 'id' => 'x', 'title' => '', 'slug' => 'x', 'lang' => 'en']) !== '';
  95. }
  96. ​
  97. // The comments box under an article: the service's code sits in a <template> and site.js
  98. // loads it once consent (if needed) and the visitor's click (if needed) allow it.
  99. public static function render(array $settings, array $article): string
  100. {
  101. if (($settings['comments_provider'] ?? '') === 'builtin') {
  102. return SiteComments::render($settings, $article);
  103. }
  104. ​
  105. $page = [
  106. 'url' => rtrim((string) Config::get('APP_URL', ''), '/') . SiteFront::articleUrl((string) $article['slug'], ''),
  107. 'id' => 'article-' . (int) $article['id'],
  108. 'title' => (string) $article['title'],
  109. 'slug' => (string) $article['slug'],
  110. 'lang' => Languages::htmlLang(),
  111. ];
  112. ​
  113. $code = self::embedCode($settings, $page);
  114. if ($code === '') {
  115. return '<p class="comments-placeholder">' . htmlspecialchars(Language::get('comments_coming_soon', 'Comments are coming soon.')) . '</p>';
  116. }
  117. ​
  118. $consent = ($settings['consent_manager_enabled'] ?? '0') === '1' ? (string) ($settings['comments_consent'] ?? 'marketing') : 'none';
  119. $consent = in_array($consent, ['analytics', 'marketing'], true) ? $consent : '';
  120. $load = ($settings['comments_load'] ?? 'auto') === 'click' ? 'click' : 'auto';
  121. ​
  122. return '<div class="comments-embed" data-consent="' . $consent . '" data-load="' . $load . '">'
  123. . '<template class="comments-embed-code">' . str_ireplace('</template', '<\/template', $code) . '</template>'
  124. . '<div class="comments-embed-target"></div>'
  125. . '<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>'
  126. . '<button type="button" class="comments-embed-consent">' . htmlspecialchars(Language::get('comments_consent_button', 'Cookie settings')) . '</button></div>'
  127. . '<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>'
  128. . '<button type="button" class="comments-embed-load">' . htmlspecialchars(Language::get('comments_load_button', 'Show comments')) . '</button></div>'
  129. . '</div>';
  130. }
  131. ​
  132. private static function embedCode(array $settings, array $page): string
  133. {
  134. $e =static fn(string $value): string => htmlspecialchars($value, ENT_QUOTES);
  135. $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);
  136. $provider = (string) ($settings['comments_provider'] ?? '');
  137. $server = self::cleanServerUrl((string) ($settings['comments_server_url'] ?? ''));
  138. $server = $server !== '' ? $server : (self::DEFAULT_SERVERS[$provider] ?? '');
  139. $siteId = (string) ($settings['comments_site_id'] ?? '');
  140. $repo = (string) ($settings['comments_repo'] ?? '');
  141. $mapping = in_array($settings['comments_mapping'] ?? '', self::MAPPINGS, true) ? (string) $settings['comments_mapping'] : 'pathname';
  142. ​
  143. switch ($provider) {
  144. case 'disqus':
  145. $shortname = (string) ($settings['comments_shortname'] ?? '');
  146. if (preg_match('/^[a-z0-9-]{1,64}$/', $shortname) !== 1) {
  147. return '';
  148. }
  149. return '<div id="disqus_thread"></div><script>'
  150. . 'var disqus_config = function () { this.page.url = ' . $js($page['url']) . '; this.page.identifier = ' . $js($page['id']) . '; this.page.title = ' . $js($page['title']) . '; };'
  151. . '(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); })();'
  152. . '</script>';
  153. ​
  154. case 'commento':
  155. if ($server === '') {
  156. return '';
  157. }
  158. return '<div id="commento"></div><script defer src="' . $e($server . '/js/commento.js') . '" data-page-id="' . $e($page['slug']) . '"></script>';
  159. ​
  160. case 'remark42':
  161. if ($server === '' || $siteId === '') {
  162. return '';
  163. }
  164. return '<div id="remark42"></div><script>'
  165. . '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" };'
  166. . '(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);'
  167. . '</script>';
  168. ​
  169. case 'isso':
  170. if ($server === '') {
  171. return '';
  172. }
  173. return '<script data-isso="' . $e($server . '/') . '" data-isso-lang="' . $e($page['lang']) . '" src="' . $e($server . '/js/embed.min.js') . '"></script>'
  174. . '<section id="isso-thread" data-isso-id="' . $e($page['id']) . '" data-title="' . $e($page['title']) . '"></section>';
  175. ​
  176. case 'cusdis':
  177. if ($server === '' || $siteId === '') {
  178. return '';
  179. }
  180. 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>'
  181. . '<script async defer src="' . $e($server . '/js/cusdis.es.js') . '"></script>';
  182. ​
  183. case 'giscus':
  184. $repoId = (string) ($settings['comments_repo_id'] ?? '');
  185. $categoryId = (string) ($settings['comments_category_id'] ?? '');
  186. if ($server === '' || $repo === '' || $repoId === '' || $categoryId === '') {
  187. return '';
  188. }
  189. return '<script src="' . $e($server . '/client.js') . '" data-repo="' . $e($repo) . '" data-repo-id="' . $e($repoId) . '"'
  190. . ' data-category="' . $e((string) ($settings['comments_category'] ?? '')) . '" data-category-id="' . $e($categoryId) . '"'
  191. . ' data-mapping="' . $e($mapping) . '" data-strict="0" data-reactions-enabled="1" data-emit-metadata="0" data-input-position="top"'
  192. . ' data-theme="preferred_color_scheme" data-lang="' . $e(substr($page['lang'], 0, 2)) . '" data-loading="lazy" crossorigin="anonymous" async></script>';
  193. ​
  194. case 'utterances':
  195. if ($repo === '') {
  196. return '';
  197. }
  198. return '<script src="https://utteranc.es/client.js" repo="' . $e($repo) . '" issue-term="' . $e($mapping) . '" theme="preferred-color-scheme" crossorigin="anonymous" async></script>';
  199. ​
  200. case 'hyvor':
  201. if ($siteId === '') {
  202. return '';
  203. }
  204. return '<script async src="https://talk.hyvor.com/embed/embed.js" type="module"></script>'
  205. . '<hyvor-talk-comments website-id="' . $e($siteId) . '" page-id="' . $e($page['id']) . '"></hyvor-talk-comments>';
  206. ​
  207. case 'custom':
  208. $template = trim((string) ($settings['comments_custom_html'] ?? ''));
  209. if ($template === '') {
  210. return '';
  211. }
  212. // Values are HTML-escaped and made safe for JavaScript strings (quotes, backslashes,
  213. // backticks and ${), so they can't break out of the code they are placed in.
  214. $safe = static fn(string $value): string => str_replace(['\\', '`', '${', "\r", "\n"], ['&#92;', '&#96;', '&#36;{', ' ', ' '], htmlspecialchars($value, ENT_QUOTES));
  215. return strtr($template, [
  216. '{{url}}' => $safe($page['url']),
  217. '{{id}}' => $safe($page['id']),
  218. '{{title}}' => $safe($page['title']),
  219. '{{slug}}' => $safe($page['slug']),
  220. '{{lang}}' => $safe($page['lang']),
  221. ]);
  222. }
  223. ​
  224. return '';
  225. }
  226. }
  227. ​