WebOrbiton
v1.0.0.6

Publisium

395 lines · 20.7 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/user-auth.php';
  8. require_once __DIR__ . '/includes/language.php';
  9. require_once __DIR__ . '/includes/site-front.php';
  10. require_once __DIR__ . '/includes/block-editor.php';
  11. require_once __DIR__ . '/includes/table-of-contents.php';
  12. require_once __DIR__ . '/includes/avatar.php';
  13. require_once __DIR__ . '/includes/author-profile.php';
  14. require_once __DIR__ . '/includes/tags.php';
  15. require_once __DIR__ . '/includes/auth.php';
  16. require_once __DIR__ . '/includes/read-aloud.php';
  17. require_once __DIR__ . '/includes/currency.php';
  18. require_once __DIR__ . '/includes/comments.php';
  19. ​
  20. $previewId = (int) ($_GET['preview'] ?? 0);
  21. $isPreview = $previewId > 0;
  22. ​
  23. $db = Database::site();
  24. $settings = SiteFront::settings();
  25. ​
  26. $slug = trim((string) ($_GET['slug'] ?? ''));
  27. ​
  28. if (Languages::enabled() && !isset($_GET['publisium_lang']) && preg_match('#^([a-z]{2,3}(?:-[a-z]{2,4})?)/(.*)$#', $slug, $languageMatch) === 1 && in_array($languageMatch[1], Languages::activeCodes(), true)) {
  29. $_GET['publisium_lang'] = $languageMatch[1];
  30. Languages::resetCache();
  31. SiteFront::resetSettings();
  32. $settings = SiteFront::settings();
  33. $languageRest = trim($languageMatch[2], '/');
  34. ​
  35. if ($languageRest === '') {
  36. require __DIR__ . '/index.php';
  37. exit;
  38. }
  39. ​
  40. if (preg_match('/^[a-z0-9-]+\.php$/', $languageRest) === 1 && $languageRest !== 'article.php' && is_file(__DIR__ . '/' . $languageRest)) {
  41. require __DIR__ . '/' . $languageRest;
  42. exit;
  43. }
  44. ​
  45. $slug = $languageRest;
  46. $_GET['slug'] = $languageRest;
  47. }
  48. ​
  49. // Short links (/about, /jan-kowalski, /my-article) all arrive here; send pages and author pages on.
  50. $viaShortLink = false;
  51. if (!$isPreview && $slug !== '') {
  52. $shortTarget = CleanUrls::resolve($slug);
  53. if ($shortTarget !== null) {
  54. $viaShortLink = true;
  55. if ($shortTarget['type'] === 'page' || $shortTarget['type'] === 'author') {
  56. $_GET['slug'] = $shortTarget['slug'];
  57. define('PUBLISIUM_SHORT_LINK', true);
  58. require __DIR__ . '/' . ($shortTarget['type'] === 'page' ? 'page.php' : 'artist.php');
  59. exit;
  60. }
  61. $slug = $shortTarget['slug'];
  62. }
  63. }
  64. ​
  65. if ($isPreview) {
  66. Auth::boot();
  67. } else {
  68. UserAuth::boot();
  69. }
  70. ​
  71. $query = $isPreview
  72. ? "SELECT a.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, a.author_name, '') AS author_name
  73. FROM articles a
  74. LEFT JOIN categories c ON c.id = a.category_id
  75. LEFT JOIN team_accounts t ON t.id = a.author_id
  76. WHERE a.id = :id LIMIT 1"
  77. : "SELECT a.*, c.name AS category_name, c.slug AS category_slug, COALESCE(t.display_name, a.author_name, '') AS author_name
  78. FROM articles a
  79. LEFT JOIN categories c ON c.id = a.category_id
  80. LEFT JOIN team_accounts t ON t.id = a.author_id
  81. WHERE a.slug = :slug AND a.status = 'published' LIMIT 1";
  82. $statement = $db->prepare($query);
  83. $statement->execute($isPreview ? ['id' => $previewId] : ['slug' => $slug]);
  84. $article = $statement->fetch();
  85. ​
  86. if (!$article && !$isPreview && Languages::enabled() && in_array(strtolower($slug), Languages::activeCodes(), true)) {
  87. header('Location: ' . Languages::homeUrl(strtolower($slug)), true, 301);
  88. exit;
  89. }
  90. ​
  91. if ($article && !$isPreview && Languages::current() !== '' && !Languages::hasTranslation('article', (int) $article['id'])) {
  92. header('Location: ' . SiteFront::articleUrl((string) $article['slug'], ''), true, 302);
  93. exit;
  94. }
  95. ​
  96. if (!$article) {
  97. http_response_code(404);
  98. $pageTitle = Language::get('not_found_title', 'Not found') . ' - ' . $settings['site_name'];
  99. $showBanner = false;
  100. require __DIR__ . '/includes/front-header.php';
  101. echo '<p>' . htmlspecialchars(Language::get('article_not_found', 'We couldn’t find this article. It may have been moved or deleted.')) . '</p>';
  102. require __DIR__ . '/includes/front-footer.php';
  103. exit;
  104. }
  105. ​
  106. if (!$isPreview && !$viaShortLink && CleanUrls::slugFor('article', (string) $article['slug']) !== null) {
  107. CleanUrls::redirect(SiteFront::articleUrl((string) $article['slug']));
  108. }
  109. ​
  110. $article = Languages::article($article);
  111. SiteFront::setAdContext('article', (int) $article['id']);
  112. ​
  113. if ($isPreview) {
  114. if (!Auth::check()) {
  115. http_response_code(403);
  116. exit('Preview access requires team login.');
  117. }
  118. ​
  119. $currentTeamUser = Auth::user();
  120. $canPreview = $currentTeamUser !== null
  121. && ((int) $article['author_id'] === (int) $currentTeamUser['id']
  122. || Auth::hasRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF)
  123. || Auth::role() === Auth::ROLE_PROOFREADER);
  124. if (!$canPreview) {
  125. http_response_code(403);
  126. exit('You do not have permission to preview this article.');
  127. }
  128. } else {
  129. $updateViews = $db->prepare('UPDATE articles SET views_count = views_count + 1, updated_at = updated_at WHERE id = :id');
  130. $updateViews->execute(['id' => $article['id']]);
  131. }
  132. ​
  133. $hasSubscription = false;
  134. if ($article['access_type'] === 'paid' && UserAuth::check()) {
  135. $hasSubscription = UserAuth::hasActiveSubscription((int) UserAuth::user()['id']);
  136. }
  137. ​
  138. $isLocked = !$isPreview && $article['access_type'] === 'paid' && !$hasSubscription;
  139. ​
  140. $relatedArticles = [];
  141. $latestArticles = [];
  142. ​
  143. if ($settings['related_articles_enabled'] === '1') {
  144. if (!empty($article['category_id'])) {
  145. $relatedStatement = $db->prepare(
  146. "SELECT a.id, a.title, a.slug, a.cover_image_path FROM articles a WHERE a.status = 'published' AND a.category_id = :category_id AND a.id != :id" . Languages::articleFilterSql('a') . " ORDER BY a.published_at DESC LIMIT 4"
  147. );
  148. $relatedStatement->execute(['category_id' => $article['category_id'], 'id' => $article['id']]);
  149. $relatedArticles = Languages::articles($relatedStatement->fetchAll());
  150. }
  151. ​
  152. $latestStatement = $db->prepare(
  153. "SELECT a.id, a.title, a.slug, a.cover_image_path FROM articles a WHERE a.status = 'published' AND a.id != :id" . Languages::articleFilterSql('a') . " ORDER BY a.published_at DESC LIMIT 4"
  154. );
  155. $latestStatement->execute(['id' => $article['id']]);
  156. $latestArticles = Languages::articles($latestStatement->fetchAll());
  157. }
  158. ​
  159. $pageTitle = ($article['seo_title'] ?: $article['title']) . ' - ' . $settings['site_name'];
  160. $pageDescription = $article['seo_description'] ?: $article['excerpt'];
  161. $showBanner = false;
  162. $ogType = 'article';
  163. $ogImagePath = !empty($article['cover_image_path']) ? $article['cover_image_path'] : null;
  164. $pageType = 'article';
  165. $authorPage = AuthorProfile::isEnabled() ? AuthorProfile::publicSummary((int) $article['author_id']) : null;
  166. $authorPageUrl = $authorPage !== null ? SiteFront::authorUrl((string) $authorPage['slug']) : null;
  167. $ogArticleMeta = [
  168. 'published' => $article['published_at'] ?? null,
  169. 'modified' => $article['updated_at'] ?? null,
  170. 'author' => $authorPageUrl !== null ? SiteFront::absoluteUrl($authorPageUrl) : (string) $article['author_name'],
  171. 'section' => (string) ($article['category_name'] ?? ''),
  172. ];
  173. $pageSchema = !$isPreview && $settings['seo_article_schema_enabled'] === '1'
  174. ? SiteFront::renderArticleSchema($article, AuthorProfile::schemaType((int) $article['author_id']), $authorPageUrl)
  175. : '';
  176. $pageTitle = $isPreview ? 'Preview - ' . $pageTitle : $pageTitle;
  177. ​
  178. require __DIR__ . '/includes/front-header.php';
  179. ​
  180. ?>
  181. <article class="article-view">
  182. ​
  183. <?php if (($settings['breadcrumbs_enabled'] ?? '0') === '1'): ?>
  184. <nav class="article-breadcrumbs" aria-label="<?= htmlspecialchars(Language::get('breadcrumb_label', 'Breadcrumb')) ?>">
  185. <ol>
  186. <li><a href="<?= htmlspecialchars(SiteFront::homeUrl(), ENT_QUOTES) ?>"><?= htmlspecialchars(Language::get('nav_home', 'Home')) ?></a></li>
  187. <?php if (!empty($article['category_name'])): ?>
  188. <li><a href="category.php?slug=<?= urlencode($article['category_slug']) ?>"><?= htmlspecialchars($article['category_name']) ?></a></li>
  189. <?php endif; ?>
  190. <li aria-current="page"><span><?= htmlspecialchars($article['title']) ?></span></li>
  191. </ol>
  192. </nav>
  193. <?php endif; ?>
  194. ​
  195. <?php if (!empty($article['category_name'])): ?>
  196. <a href="category.php?slug=<?= urlencode($article['category_slug']) ?>" class="article-category-link"><?= htmlspecialchars($article['category_name']) ?></a>
  197. <?php endif; ?>
  198. ​
  199. <h1 class="article-title"><?= htmlspecialchars($article['title']) ?></h1>
  200. ​
  201. <div class="article-meta">
  202. <?php if ($article['author_name'] !== ''): ?>
  203. <?= htmlspecialchars(Language::get('article_by', 'By')) ?> <?php if ($authorPage !== null): ?><a href="<?= htmlspecialchars(SiteFront::authorUrl((string) $authorPage['slug']), ENT_QUOTES) ?>"><?= htmlspecialchars($article['author_name']) ?></a><?php else: ?><?= htmlspecialchars($article['author_name']) ?><?php endif; ?>
  204. &middot;
  205. <?php endif; ?>
  206. <?= htmlspecialchars(Language::get('article_published_on', 'Published on')) ?> <?= htmlspecialchars(SiteFront::formatDate((string) $article['published_at'], $settings['article_time_enabled'] === '1')) ?>
  207. <?php if (($settings['article_reading_time_enabled'] ?? '1') === '1'): ?>
  208. &middot; <?= htmlspecialchars(sprintf(Language::get('article_reading_time', '%d min read'), BlockEditor::readingTimeMinutes($article['content_blocks']))) ?>
  209. <?php endif; ?>
  210. </div>
  211. ​
  212. <?= SiteFront::renderShareControl($article) ?>
  213. ​
  214. <?= SiteFront::renderAds('article-top') ?>
  215. ​
  216. <?php if (!empty($article['cover_image_path'])): ?>
  217. <img src="<?= htmlspecialchars($article['cover_image_path']) ?>" alt="" class="article-cover">
  218. <?php endif; ?>
  219. ​
  220. <?php foreach (SiteFront::activeAds('in-article') as $inArticleAd): ?>
  221. <?= SiteFront::renderAd($inArticleAd) ?>
  222. <?php endforeach; ?>
  223. ​
  224. <?php if ($isLocked): ?>
  225. <div class="article-content">
  226. <p class="article-excerpt-locked"><?= htmlspecialchars($article['excerpt']) ?></p>
  227. </div>
  228. <div class="paid-overlay">
  229. <div class="paid-overlay-box">
  230. <span class="badge-paid"><?= htmlspecialchars(Language::get('article_subscribers_only', 'Subscribers only')) ?></span>
  231. ​
  232. <div class="subscription-offer">
  233. <div class="subscription-offer-name"><?= htmlspecialchars($settings['subscription_name']) ?></div>
  234. <div class="subscription-offer-price">
  235. <?php
  236. $offerPrice = Currency::format((int) $settings['subscription_price_cents'], (string) $settings['subscription_currency']);
  237. $offerInterval = (string) $settings['subscription_interval'];
  238. ?>
  239. <?= htmlspecialchars($offerInterval === 'lifetime'
  240. ? $offerPrice . ' · ' . Language::get('subscription_one_time', 'one-time payment')
  241. : $offerPrice . ' / ' . ($offerInterval === 'year' ? Language::get('subscription_per_year', 'year') : Language::get('subscription_per_month', 'month'))) ?>
  242. </div>
  243. <?php if ($settings['subscription_description'] !== ''): ?>
  244. <p class="subscription-offer-description"><?= htmlspecialchars($settings['subscription_description']) ?></p>
  245. <?php endif; ?>
  246. <?php if ($settings['subscription_checkout_url'] !== ''): ?>
  247. <a href="<?= htmlspecialchars(SiteFront::subscriptionCheckoutUrl((string) (UserAuth::user()['email'] ?? ''))) ?>" class="unlock-btn"><?= htmlspecialchars(($settings['subscription_interval'] === 'lifetime' ? Language::get('article_buy_lifetime_button', 'Get lifetime access') : Language::get('article_subscribe_button', 'Subscribe'))) ?></a>
  248. <?php else: ?>
  249. <a href="user-login.php?mode=<?= UserAuth::check() ? 'login' : 'register' ?>" class="unlock-btn"><?= htmlspecialchars(($settings['subscription_interval'] === 'lifetime' ? Language::get('article_buy_lifetime_button', 'Get lifetime access') : Language::get('article_subscribe_button', 'Subscribe'))) ?></a>
  250. <?php endif; ?>
  251. </div>
  252. ​
  253. <?php if (!UserAuth::check() && ($settings['login_enabled'] ?? '1') !== '0'): ?>
  254. <div class="subscription-divider"><?= htmlspecialchars(Language::get('article_already_subscriber', 'Already a subscriber?')) ?></div>
  255. <a href="user-login.php?mode=login" class="unlock-btn unlock-btn-outline"><?= htmlspecialchars(Language::get('article_login_button', 'Log in')) ?></a>
  256. <?php endif; ?>
  257. ​
  258. </div>
  259. </div>
  260. <?php else: ?>
  261. <?php
  262. $middleAdMarker = '<!--publisium-ad-middle-->';
  263. $articleHtml = BlockEditor::renderWithMiddle($article['content_blocks'], $middleAdMarker);
  264. $tocHtml = '';
  265. if (($settings['toc_enabled'] ?? '0') === '1') {
  266. [$articleHtml, $tocItems] = TableOfContents::build($articleHtml);
  267. $tocHtml = TableOfContents::render($tocItems);
  268. }
  269. ?>
  270. <?php $articleHtml = str_replace($middleAdMarker, SiteFront::renderAds('article-middle'), $articleHtml); ?>
  271. <?php $readAloudTracks = Languages::hasTranslation('article', (int) $article['id']) ? [] : ReadAloud::tracksFor($article); ?>
  272. <?php if ($readAloudTracks !== []): ?>
  273. <div class="read-aloud" data-read-aloud data-tracks="<?= htmlspecialchars((string) json_encode($readAloudTracks, JSON_UNESCAPED_SLASHES), ENT_QUOTES) ?>"
  274. data-label-play="<?= htmlspecialchars(Language::get('read_aloud_play', 'Play'), ENT_QUOTES) ?>"
  275. data-label-pause="<?= htmlspecialchars(Language::get('read_aloud_pause', 'Pause'), ENT_QUOTES) ?>">
  276. <button type="button" class="read-aloud-toggle" aria-label="<?= htmlspecialchars(Language::get('read_aloud_play', 'Play')) ?>">
  277. <svg class="read-aloud-icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z" fill="currentColor"/></svg>
  278. <svg class="read-aloud-icon-pause" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="5" width="4.2" height="14" rx="1.2" fill="currentColor"/><rect x="13.8" y="5" width="4.2" height="14" rx="1.2" fill="currentColor"/></svg>
  279. </button>
  280. <div class="read-aloud-main">
  281. <div class="read-aloud-title"><?= htmlspecialchars(Language::get('read_aloud_title', 'Listen to this article')) ?></div>
  282. <div class="read-aloud-track">
  283. <input type="range" class="read-aloud-seek" min="0" max="1000" value="0" step="1" aria-label="<?= htmlspecialchars(Language::get('read_aloud_seek', 'Seek')) ?>">
  284. <span class="read-aloud-time" aria-live="off">0:00</span>
  285. </div>
  286. </div>
  287. <button type="button" class="read-aloud-speed" aria-label="<?= htmlspecialchars(Language::get('read_aloud_speed', 'Playback speed')) ?>">1&times;</button>
  288. </div>
  289. <?= Asset::js('assets/read-aloud.js') ?>
  290. <?php endif; ?>
  291. <?= $tocHtml ?>
  292. <div class="article-content">
  293. <?= $articleHtml ?>
  294. </div>
  295. <?php endif; ?>
  296. ​
  297. <?php $articleTags = ArticleTags::isEnabled() && !$isLocked ? ArticleTags::forArticle((int) $article['id']) : []; ?>
  298. <?php if (!empty($articleTags)): ?>
  299. <div class="article-tags">
  300. <span class="article-tags-label"><?= htmlspecialchars(Language::get('article_tags_label', 'Tags:')) ?></span>
  301. <?php foreach ($articleTags as $articleTag): ?>
  302. <a href="tag.php?slug=<?= urlencode($articleTag['slug']) ?>" class="article-tag"><?= htmlspecialchars($articleTag['name']) ?></a>
  303. <?php endforeach; ?>
  304. </div>
  305. <?php endif; ?>
  306. ​
  307. <?php foreach (SiteFront::activeAds('article-bottom') as $articleBottomAd): ?>
  308. <?= SiteFront::renderAd($articleBottomAd) ?>
  309. <?php endforeach; ?>
  310. ​
  311. <?php if (($settings['author_box_enabled'] ?? '0') === '1' && !$isLocked && $article['author_name'] !== ''): ?>
  312. <?php
  313. if ($article['author_id'] !== null) {
  314. $authorCountStatement = $db->prepare("SELECT COUNT(*) FROM articles WHERE author_id = :id AND status = 'published'");
  315. $authorCountStatement->execute(['id' => $article['author_id']]);
  316. } else {
  317. $authorCountStatement = $db->prepare("SELECT COUNT(*) FROM articles WHERE author_id IS NULL AND author_name = :name AND status = 'published'");
  318. $authorCountStatement->execute(['name' => $article['author_name']]);
  319. }
  320. $authorArticleCount = (int) $authorCountStatement->fetchColumn();
  321. $authorAvatar = '';
  322. try {
  323. $authorAvatarStatement = $db->prepare('SELECT avatar_path FROM team_accounts WHERE id = :id LIMIT 1');
  324. $authorAvatarStatement->execute(['id' => $article['author_id']]);
  325. $authorAvatar = (string) $authorAvatarStatement->fetchColumn();
  326. } catch (PDOException $e) {
  327. $authorAvatar = '';
  328. }
  329. ?>
  330. <aside class="author-box">
  331. <?= Avatar::html(['avatar_path' => $authorAvatar, 'display_name' => $article['author_name']], 'author-box-avatar') ?>
  332. <div class="author-box-info">
  333. <div class="author-box-label"><?= htmlspecialchars(Language::get('article_written_by', 'Written by')) ?></div>
  334. <div class="author-box-name">
  335. <?php if ($authorPage !== null): ?>
  336. <a href="<?= htmlspecialchars(SiteFront::authorUrl((string) $authorPage['slug']), ENT_QUOTES) ?>"><?= htmlspecialchars($article['author_name']) ?></a>
  337. <?php else: ?>
  338. <?= htmlspecialchars($article['author_name']) ?>
  339. <?php endif; ?>
  340. </div>
  341. <?php if ($authorPage !== null && $authorPage['headline'] !== ''): ?>
  342. <div class="author-box-headline"><?= htmlspecialchars($authorPage['headline']) ?></div>
  343. <?php endif; ?>
  344. <div class="author-box-meta"><?= htmlspecialchars(str_replace('{count}', (string) $authorArticleCount, Language::get('author_articles_count', 'Articles: {count}'))) ?></div>
  345. </div>
  346. </aside>
  347. <?php endif; ?>
  348. ​
  349. </article>
  350. ​
  351. <?php if ($settings['related_articles_enabled'] === '1' && (!empty($relatedArticles) || !empty($latestArticles))): ?>
  352. <section class="related-section">
  353. <?php if (!empty($relatedArticles)): ?>
  354. <h2><?= htmlspecialchars(Language::get('article_related', 'Related articles')) ?></h2>
  355. <div class="article-grid article-grid-small">
  356. <?php foreach ($relatedArticles as $related): ?>
  357. <a href="<?= htmlspecialchars(SiteFront::articleUrl($related['slug'])) ?>" class="article-card">
  358. <?php if (!empty($related['cover_image_path'])): ?>
  359. <img src="<?= htmlspecialchars($related['cover_image_path']) ?>" alt="" class="article-card-image">
  360. <?php endif; ?>
  361. <div class="article-card-body">
  362. <h3><?= htmlspecialchars($related['title']) ?></h3>
  363. </div>
  364. </a>
  365. <?php endforeach; ?>
  366. </div>
  367. <?php endif; ?>
  368. ​
  369. <?php if (!empty($latestArticles)): ?>
  370. <h2><?= htmlspecialchars(Language::get('article_latest', 'More recent articles')) ?></h2>
  371. <div class="article-grid article-grid-small">
  372. <?php foreach ($latestArticles as $latest): ?>
  373. <a href="<?= htmlspecialchars(SiteFront::articleUrl($latest['slug'])) ?>" class="article-card">
  374. <?php if (!empty($latest['cover_image_path'])): ?>
  375. <img src="<?= htmlspecialchars($latest['cover_image_path']) ?>" alt="" class="article-card-image">
  376. <?php endif; ?>
  377. <div class="article-card-body">
  378. <h3><?= htmlspecialchars($latest['title']) ?></h3>
  379. </div>
  380. </a>
  381. <?php endforeach; ?>
  382. </div>
  383. <?php endif; ?>
  384. </section>
  385. <?php endif; ?>
  386. ​
  387. <?php if ($settings['comments_enabled'] === '1' && !$isLocked): ?>
  388. <section class="comments-section">
  389. <h2><?= htmlspecialchars(Language::get('article_comments_title', 'Comments')) ?></h2>
  390. ​
  391. <?= Comments::render($settings, $article) ?>
  392. </section>
  393. <?php endif; ?>
  394. ​
  395. <?php require __DIR__ . '/includes/front-footer.php'; ?>