Code
Publisium
A modern, fast CMS for building article and blog websites.
Version history
Version 2.0.0.0 2026-10-02
Browse filesFiles changed22
Lines added+195
Lines removed-11
article.php
⋮ 172 unchanged lines ⋮ $pageSchema = !$isPreview && $settings['seo_article_schema_enabled'] === '1' ? SiteFront::renderArticleSchema($article, AuthorProfile::schemaType((int) $article['author_id']), $authorPageUrl) : ''; + $pageNoindex = !empty($article['noindex']); $pageTitle = $isPreview ? 'Preview - ' . $pageTitle : $pageTitle; require __DIR__ . '/includes/front-header.php'; ⋮ 217 unchanged lines ⋮
assets/site.css
Not shown (binary file or too large to diff).
dashboard-actions/save-article.php
⋮ 9 unchanged lines ⋮ $accessType = ($_POST['access_type'] ?? 'free') === 'paid' ? 'paid' : 'free'; $seoTitle = trim((string) ($_POST['seo_title'] ?? '')); $seoDescription = trim((string) ($_POST['seo_description'] ?? '')); + $noindex = isset($_POST['noindex']) ? 1 : 0; $coverImage = trim((string) ($_POST['cover_image_path'] ?? '')); $blocksJson = BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}')); $articleId = (int) ($_POST['article_id'] ?? 0) ?: null; ⋮ 65 unchanged lines ⋮ $update = $db->prepare( 'UPDATE articles SET category_id = :category_id, title = :title, excerpt = :excerpt, content_blocks = :content, cover_image_path = :cover, access_type = :access_type, - status = :status, seo_title = :seo_title, seo_description = :seo_description, + status = :status, seo_title = :seo_title, seo_description = :seo_description, noindex = :noindex, scheduled_at = :scheduled_at, published_at = :published_at, updated_at = NOW() WHERE id = :id' ); ⋮ 7 unchanged lines ⋮ 'status' => $status, 'seo_title' => $seoTitle, 'seo_description' => $seoDescription, + 'noindex' => $noindex, 'scheduled_at' => $status === 'scheduled' ? ($scheduledAt !== '' ? $scheduledAt : $existing['scheduled_at']) : null, 'published_at' => $publishedAt, 'id' => $articleId, ⋮ 13 unchanged lines ⋮ $insert = $db->prepare( 'INSERT INTO articles (author_id, category_id, title, slug, excerpt, content_blocks, cover_image_path, - access_type, status, seo_title, seo_description, scheduled_at, published_at) + access_type, status, seo_title, seo_description, noindex, scheduled_at, published_at) VALUES (:author_id, :category_id, :title, :slug, :excerpt, :content, :cover, - :access_type, :status, :seo_title, :seo_description, :scheduled_at, :published_at)' + :access_type, :status, :seo_title, :seo_description, :noindex, :scheduled_at, :published_at)' ); $insert->execute([ 'author_id' => $currentUser['id'], ⋮ 7 unchanged lines ⋮ 'status' => $status, 'seo_title' => $seoTitle, 'seo_description' => $seoDescription, + 'noindex' => $noindex, 'scheduled_at' => $status === 'scheduled' ? $scheduledAt : null, 'published_at' => $status === 'published' ? date('Y-m-d H:i:s') : null, ]); ⋮ 6 unchanged lines ⋮
dashboard-actions/save-page.php
⋮ 9 unchanged lines ⋮ $pageId = (int) ($_POST['page_id'] ?? 0) ?: null; $status = ($_POST['status'] ?? 'draft') === 'published' ? 'published' : 'draft'; $showInMenu = isset($_POST['show_in_menu']) ? 1 : 0; + $noindex = isset($_POST['noindex']) ? 1 : 0; $blocksJson = BlockEditor::sanitize((string) ($_POST['blocks_json'] ?? '{"blocks":[]}'), true); if ($title === '') { ⋮ 19 unchanged lines ⋮ if ($pageId !== null) { $statement = $db->prepare( - 'UPDATE pages SET title = :title, slug = :slug, content_blocks = :content, status = :status, show_in_menu = :show_in_menu WHERE id = :id' + 'UPDATE pages SET title = :title, slug = :slug, content_blocks = :content, status = :status, show_in_menu = :show_in_menu, noindex = :noindex WHERE id = :id' ); $statement->execute([ 'title' => $title, ⋮ 1 unchanged line ⋮ 'content' => $blocksJson, 'status' => $status, 'show_in_menu' => $showInMenu, + 'noindex' => $noindex, 'id' => $pageId, ]); } else { $statement = $db->prepare( - 'INSERT INTO pages (author_id, title, slug, content_blocks, status, show_in_menu) VALUES (:author_id, :title, :slug, :content, :status, :show_in_menu)' + 'INSERT INTO pages (author_id, title, slug, content_blocks, status, show_in_menu, noindex) VALUES (:author_id, :title, :slug, :content, :status, :show_in_menu, :noindex)' ); $statement->execute([ 'author_id' => $currentUser['id'], ⋮ 2 unchanged lines ⋮ 'content' => $blocksJson, 'status' => $status, 'show_in_menu' => $showInMenu, + 'noindex' => $noindex, ]); } ⋮ 6 unchanged lines ⋮
dashboard-actions/save-settings.php
⋮ 26 unchanged lines ⋮ } } + function sanitizeLegalUrl(string $url): string + { + $url = trim($url); + + if (str_starts_with($url, 'https://') || str_starts_with($url, 'http://') || (str_starts_with($url, '/') && !str_starts_with($url, '//'))) { + return (string) (filter_var($url, FILTER_SANITIZE_URL) ?: ''); + } + + return ''; + } + function sanitizeLogoUrl(string $url): string { $url = trim($url); ⋮ 31 unchanged lines ⋮ 'site_short_name_enabled' => isset($_POST['site_short_name_enabled']) ? '1' : '0', 'registration_enabled' => isset($_POST['registration_enabled']) ? '1' : '0', 'login_enabled' => isset($_POST['login_enabled']) ? '1' : '0', + 'media_webp_enabled' => isset($_POST['media_webp_enabled']) ? '1' : '0', + 'auth_legal_links_enabled' => isset($_POST['auth_legal_links_enabled']) ? '1' : '0', + 'auth_terms_url' => sanitizeLegalUrl((string) ($_POST['auth_terms_url'] ?? '')), + 'auth_privacy_url' => sanitizeLegalUrl((string) ($_POST['auth_privacy_url'] ?? '')), 'activity_log_enabled' => isset($_POST['activity_log_enabled']) ? '1' : '0', 'subscription_name' => trim((string) ($_POST['subscription_name'] ?? 'Full access')), 'subscription_price_cents' => Currency::toMinor((int) ($_POST['subscription_price_major'] ?? 0), (int) ($_POST['subscription_price_minor'] ?? 0), Currency::normalize((string) ($_POST['subscription_currency'] ?? ''))), ⋮ 95 unchanged lines ⋮ if ($settingsTab === 'search') { saveSettingsFields($db, [ 'search_enabled' => isset($_POST['search_enabled']) ? '1' : '0', + 'search_noindex_enabled' => isset($_POST['search_noindex_enabled']) ? '1' : '0', 'search_match_title' => isset($_POST['search_match_title']) ? '1' : '0', 'search_match_excerpt' => isset($_POST['search_match_excerpt']) ? '1' : '0', 'search_match_content' => isset($_POST['search_match_content']) ? '1' : '0', ⋮ 71 unchanged lines ⋮
dashboard-views/article-edit.php
⋮ 213 unchanged lines ⋮ <label>Description for search engines</label> <textarea name="seo_description" rows="2" <?= $readOnlyMeta ? 'readonly' : '' ?>><?= htmlspecialchars($article['seo_description'] ?? '') ?></textarea> + + <label><input type="checkbox" name="noindex" <?= !empty($article['noindex']) ? 'checked' : '' ?> <?= $readOnlyMeta ? 'disabled' : '' ?>> Hide from search engines (noindex)</label> </div> <?php ⋮ 192 unchanged lines ⋮
dashboard-views/pages.php
⋮ 113 unchanged lines ⋮ <div id="block-editor-root" data-contact-form="<?= ContactForm::isEnabled() ? 'enabled' : 'disabled' ?>" data-initial-blocks='<?= htmlspecialchars($blocksJson, ENT_QUOTES) ?>'></div> <label><input type="checkbox" name="show_in_menu" <?= !empty($editingPage['show_in_menu']) ? 'checked' : '' ?>> Show in site menu</label> + <label><input type="checkbox" name="noindex" <?= !empty($editingPage['noindex']) ? 'checked' : '' ?>> Hide from search engines (noindex)</label> <label><?= Icons::icon('flag', 'icon icon-sm') ?>Status</label> <select name="status"> ⋮ 16 unchanged lines ⋮
includes/database.php
⋮ 93 unchanged lines ⋮ 'read_aloud_error' => "ALTER TABLE articles ADD COLUMN read_aloud_error VARCHAR(255) NULL", 'read_aloud_started' => "ALTER TABLE articles ADD COLUMN read_aloud_started DATETIME NULL", 'author_name' => "ALTER TABLE articles ADD COLUMN author_name VARCHAR(120) NULL AFTER author_id", + 'noindex' => "ALTER TABLE articles ADD COLUMN noindex TINYINT(1) NOT NULL DEFAULT 0 AFTER seo_description", ], 'analytics_scripts' => [ 'consent_category' => "ALTER TABLE analytics_scripts ADD COLUMN consent_category VARCHAR(20) NOT NULL DEFAULT 'analytics' AFTER requires_consent", ], 'pages' => [ 'author_name' => "ALTER TABLE pages ADD COLUMN author_name VARCHAR(120) NULL AFTER author_id", + 'noindex' => "ALTER TABLE pages ADD COLUMN noindex TINYINT(1) NOT NULL DEFAULT 0 AFTER show_in_menu", ], 'ads' => [ 'hide_for_subscribers' => "ALTER TABLE ads ADD COLUMN hide_for_subscribers TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active", ⋮ 263 unchanged lines ⋮
includes/front-header.php
⋮ 27 unchanged lines ⋮ <?php if (!empty($resolvedPageDescription)): ?> <meta name="description" content="<?= htmlspecialchars($resolvedPageDescription) ?>"> <?php endif; ?> - <?= SiteFront::renderSeoMeta() ?> + <?= SiteFront::renderSeoMeta(!empty($pageNoindex)) ?> <?= Languages::renderAlternates() ?> <?= SiteFront::renderOpenGraph($resolvedPageTitle, $resolvedPageDescription, $ogType ?? 'website', $ogImagePath ?? null, $ogArticleMeta ?? null) ?> <?php if ($isHomepage): ?> ⋮ 140 unchanged lines ⋮
includes/language.php
⋮ 121 unchanged lines ⋮ 'auth_wrong_credentials' => 'That email or password doesn’t match. Try again.', 'auth_passwords_mismatch' => 'The passwords don’t match.', 'auth_reader_account' => 'Reader account', + 'auth_terms' => 'Terms of Service', + 'auth_privacy' => 'Privacy Policy', 'auth_security_code' => 'Security code', 'subscription_status_active' => 'Active', 'subscription_status_trialing' => 'Free trial', ⋮ 78 unchanged lines ⋮
includes/site-front.php
Not shown (binary file or too large to diff).
includes/webp.php
+ <?php + + declare(strict_types=1); + + final class Webp + { + private const QUALITY = 82; + + public static function supported(): bool + { + return function_exists('imagewebp') && function_exists('imagecreatefromjpeg') && function_exists('imagecreatefrompng'); + } + + public static function convert(string $path, string $mimeType): ?string + { + if (!self::supported() || !in_array($mimeType, ['image/jpeg', 'image/png'], true)) { + return null; + } + + $size = @getimagesize($path); + if ($size === false || !self::fitsInMemory((int) $size[0], (int) $size[1])) { + return null; + } + + $orientation = 1; + if ($mimeType === 'image/jpeg') { + if (!function_exists('exif_read_data')) { + return null; + } + $exif = @exif_read_data($path); + $orientation = is_array($exif) ? (int) ($exif['Orientation'] ?? 1) : 1; + } + + $image = $mimeType === 'image/jpeg' ? @imagecreatefromjpeg($path) : @imagecreatefrompng($path); + if ($image === false) { + return null; + } + + if ($mimeType === 'image/png') { + imagepalettetotruecolor($image); + imagealphablending($image, false); + imagesavealpha($image, true); + } + + $image = self::applyOrientation($image, $orientation); + + $target = (string) preg_replace('/\.[A-Za-z0-9]+$/', '', $path) . '.webp'; + $saved = @imagewebp($image, $target, self::QUALITY); + imagedestroy($image); + + clearstatcache(true, $target); + if (!$saved || !is_file($target) || filesize($target) === 0 || filesize($target) >= filesize($path)) { + @unlink($target); + return null; + } + + return $target; + } + + private static function applyOrientation(GdImage $image, int $orientation): GdImage + { + if (in_array($orientation, [4, 5, 7], true)) { + imageflip($image, IMG_FLIP_VERTICAL); + } elseif ($orientation === 2) { + imageflip($image, IMG_FLIP_HORIZONTAL); + } + + $angle = match ($orientation) { + 3 => 180, + 5, 6 => -90, + 7, 8 => 90, + default => 0, + }; + + if ($angle === 0) { + return $image; + } + + $rotated = imagerotate($image, $angle, 0); + if ($rotated === false) { + return $image; + } + imagedestroy($image); + + return $rotated; + } + + private static function fitsInMemory(int $width, int $height): bool + { + if ($width <= 0 || $height <= 0) { + return false; + } + + $limit = self::memoryLimitBytes(); + if ($limit <= 0) { + return true; + } + + return memory_get_usage() + $width * $height * 10 < $limit; + } + + private static function memoryLimitBytes(): int + { + $value = trim((string) ini_get('memory_limit')); + if ($value === '' || $value === '-1') { + return -1; + } + + $number = (int) $value; + + return match (strtolower(substr($value, -1))) { + 'g' => $number * 1024 * 1024 * 1024, + 'm' => $number * 1024 * 1024, + 'k' => $number * 1024, + default => $number, + }; + } + } +
llms.php
⋮ 33 unchanged lines ⋮ echo "\n"; } - $pages = $db->query("SELECT title, slug FROM pages WHERE status = 'published' ORDER BY title ASC")->fetchAll(); + $pages = $db->query("SELECT title, slug FROM pages WHERE status = 'published' AND noindex = 0 ORDER BY title ASC")->fetchAll(); if (!empty($pages)) { echo "## Pages\n\n"; foreach ($pages as $page) { ⋮ 2 unchanged lines ⋮ echo "\n"; } - $articles = $db->query("SELECT title, slug, excerpt FROM articles WHERE status = 'published' ORDER BY published_at DESC, id DESC")->fetchAll(); + $articles = $db->query("SELECT title, slug, excerpt FROM articles WHERE status = 'published' AND noindex = 0 ORDER BY published_at DESC, id DESC")->fetchAll(); if (!empty($articles)) { echo "## Articles\n\n"; foreach ($articles as $article) { ⋮ 9 unchanged lines ⋮
media-upload.php
⋮ 5 unchanged lines ⋮ require_once __DIR__ . '/includes/database.php'; require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/csrf.php'; + require_once __DIR__ . '/includes/site-front.php'; + require_once __DIR__ . '/includes/webp.php'; Auth::boot(); Auth::requireLogin(); ⋮ 53 unchanged lines ⋮ http_response_code(500); echo json_encode(['success' => false, 'error' => 'We couldn’t save the file. Please try again.']); exit; + } + + if (SiteFront::settings()['media_webp_enabled'] === '1') { + $webpPath = Webp::convert($targetPath, $mimeType); + if ($webpPath !== null) { + @unlink($targetPath); + $fileName = basename($webpPath); + } } echo json_encode([ ⋮ 4 unchanged lines ⋮
page.php
⋮ 41 unchanged lines ⋮ } $pageTitle = $page['title'] . ' - ' . $settings['site_name']; + $pageNoindex = !empty($page['noindex']); $pageDescription = $settings['seo_description']; $showBanner = false; $pageType = 'pages'; ⋮ 14 unchanged lines ⋮
search.php
⋮ 97 unchanged lines ⋮ $pageTitle = Language::get('nav_search', 'Search') . ' - ' . $settings['site_name']; $pageDescription = ''; + $pageNoindex = $settings['search_noindex_enabled'] === '1'; $showBanner = false; require __DIR__ . '/includes/front-header.php'; ⋮ 56 unchanged lines ⋮
settings.php
Not shown (binary file or too large to diff).
sitemap.php
⋮ 15 unchanged lines ⋮ $db = Database::site(); $baseUrl = rtrim(Config::get('APP_URL', ''), '/') . Config::get('APP_BASE_PATH', ''); - $articles = $db->query("SELECT id, slug, updated_at FROM articles WHERE status = 'published' ORDER BY updated_at DESC")->fetchAll(); - $pages = $db->query("SELECT slug, updated_at FROM pages WHERE status = 'published'")->fetchAll(); + $articles = $db->query("SELECT id, slug, updated_at FROM articles WHERE status = 'published' AND noindex = 0 ORDER BY updated_at DESC")->fetchAll(); + $pages = $db->query("SELECT slug, updated_at FROM pages WHERE status = 'published' AND noindex = 0")->fetchAll(); $categories = $db->query('SELECT slug FROM categories')->fetchAll(); header('Content-Type: application/xml; charset=utf-8'); ⋮ 39 unchanged lines ⋮
sql/schema_site.sql
⋮ 64 unchanged lines ⋮ rejection_reason VARCHAR(500) NULL, seo_title VARCHAR(255) NULL, seo_description VARCHAR(500) NULL, + noindex TINYINT(1) NOT NULL DEFAULT 0, views_count INT UNSIGNED NOT NULL DEFAULT 0, published_at DATETIME NULL, scheduled_at DATETIME NULL, ⋮ 29 unchanged lines ⋮ content_blocks LONGTEXT NOT NULL, status ENUM('draft','published') NOT NULL DEFAULT 'draft', show_in_menu TINYINT(1) NOT NULL DEFAULT 0, + noindex TINYINT(1) NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, ⋮ 94 unchanged lines ⋮
translations/language.php
⋮ 162 unchanged lines ⋮ 'auth_wrong_credentials' => 'That email or password doesn’t match. Try again.', 'auth_passwords_mismatch' => 'The passwords don’t match.', 'auth_reader_account' => 'Reader account', + 'auth_terms' => 'Terms of Service', + 'auth_privacy' => 'Privacy Policy', 'auth_security_code' => 'Security code', 'subscription_status_active' => 'Active', 'subscription_status_trialing' => 'Free trial', ⋮ 44 unchanged lines ⋮
user-login.php
⋮ 135 unchanged lines ⋮ $siteName = Config::get('APP_NAME', 'Publisium'); + $legalLinks = []; + if (($settings['auth_legal_links_enabled'] ?? '0') === '1') { + if (($settings['auth_terms_url'] ?? '') !== '') { + $legalLinks[] = [$settings['auth_terms_url'], Language::get('auth_terms', 'Terms of Service')]; + } + if (($settings['auth_privacy_url'] ?? '') !== '') { + $legalLinks[] = [$settings['auth_privacy_url'], Language::get('auth_privacy', 'Privacy Policy')]; + } + } + ?> <!DOCTYPE html> <html lang="<?= htmlspecialchars(Languages::htmlLang(), ENT_QUOTES) ?>"> ⋮ 77 unchanged lines ⋮ </div> <button type="submit"><?= htmlspecialchars(Language::get('auth_register_button', 'Create account')) ?></button> </form> + <?php endif; ?> + + <?php if ($legalLinks !== []): ?> + <p class="auth-legal"> + <?php foreach ($legalLinks as $legalIndex => [$legalUrl, $legalLabel]): ?> + <?= $legalIndex > 0 ? '·' : '' ?> + <a href="<?= htmlspecialchars($legalUrl, ENT_QUOTES) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($legalLabel) ?></a> + <?php endforeach; ?> + </p> <?php endif; ?> </div> ⋮ 4 unchanged lines ⋮
version.txt
- 1.0.0.9 + 2.0.0.0