WebOrbiton

Code

Publisium

A modern, fast CMS for building article and blog websites.

Version history

Version 2.0.0.0 2026-10-02

Browse files
Files changed22
Lines added+195
Lines removed-11
article.php Modified +1 -0
⋮ 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 Modified

Not shown (binary file or too large to diff).

dashboard-actions/save-article.php Modified +6 -3
⋮ 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 Modified +5 -2
⋮ 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 Modified +16 -0
⋮ 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 Modified +2 -0
⋮ 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 Modified +1 -0
⋮ 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 Modified +2 -0
⋮ 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 Modified +1 -1
⋮ 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 Modified +2 -0
⋮ 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 Modified

Not shown (binary file or too large to diff).

includes/webp.php Added +119 -0
+ <?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 Modified +2 -2
⋮ 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 Modified +10 -0
⋮ 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 Modified +1 -0
⋮ 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 Modified +1 -0
⋮ 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 Modified

Not shown (binary file or too large to diff).

sitemap.php Modified +2 -2
⋮ 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 Modified +2 -0
⋮ 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 Modified +2 -0
⋮ 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 Modified +19 -0
⋮ 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 ? '&middot;' : '' ?>
+                     <a href="<?= htmlspecialchars($legalUrl, ENT_QUOTES) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($legalLabel) ?></a>
+                 <?php endforeach; ?>
+             </p>
          <?php endif; ?>
  
      </div>
⋮ 4 unchanged lines ⋮
version.txt Modified +1 -1
- 1.0.0.9
+ 2.0.0.0