defined('RAIJU_DEFAULT_FAVICON') ? RAIJU_DEFAULT_FAVICON : '/assets/images/favicon.svg', 'favicon_png' => '', 'branding' => [ 'logo' => '', 'logoDark' => '', 'adminLogo' => '', 'name' => defined('SITE_NAME') ? SITE_NAME : 'CMS', 'showBranding' => true, 'logoDisplay' => 'both', 'logoSize' => 'medium' ], 'theme' => [ 'adminTheme' => 'light', 'preset' => 'soft', 'density' => 'auto', 'fontSize' => 'default', 'font' => 'system', 'primaryColor' => '#3858e9', 'accentColor' => '', 'dangerColor' => '', 'warningColor' => '', 'successColor' => '', 'sidebarBg' => '', 'darkPrimaryColor' => '', 'darkAccentColor' => '', 'darkSidebarBg' => '', 'buttonGlow' => true, 'buttonRadius' => 6 ], 'email' => [ 'method' => 'inactive', 'recipientEmail' => '', 'bccEmail' => '', 'fromEmail' => '', 'fromName' => defined('SITE_NAME') ? SITE_NAME : '', 'smtpHost' => '', 'smtpPort' => 587, 'smtpUsername' => '', 'smtpPassword' => '', 'smtpEncryption' => 'tls' ], 'privacy' => [ 'emailObfuscation' => false, 'analyticsEnabled' => true, 'rememberPublicTheme' => true ], 'modules' => [ 'ai' => true, 'news' => true, 'events' => true, 'messages' => true, 'iconManager' => true ], 'dashboard' => [ 'itemsPerPage' => 50, 'iconManagerItemsPerPage' => 50, 'mediaItemsPerPage' => 25 ], 'media' => raijuMediaSettingsDefaults(), 'access' => [ 'maintenance' => [ 'enabled' => false, 'mode' => 'maintenance', 'title' => t('settings.maintenance_default_title'), 'text' => t('settings.maintenance_default_text'), 'until' => '', 'showCountdown' => false, 'brandAsset' => 'none', 'image' => '', 'imageLayout' => 'none', 'overlayColor' => '', 'overlayOpacity' => 88, 'bypassParam' => 'preview', 'bypassKeyHash' => '', 'bypassLinks' => [] ] ], 'login' => [ 'brandAsset' => 'favicon', 'image' => '', 'imageLayout' => 'none', 'overlayColor' => '', 'overlayOpacity' => 86, 'boxStyle' => 'card', 'boxColor' => '', 'boxTextColor' => '' ], 'seo' => [ 'siteUrl' => '', 'organizationName' => '', 'defaultOgImage' => '', 'noindexSite' => false ] ]; if (!defined('SETTINGS_PATH') || !file_exists(SETTINGS_PATH)) { $defaults['access']['maintenance']['hasBypassKey'] = false; unset($defaults['access']['maintenance']['bypassKeyHash']); jsonResponse(true, $defaults); } $settings = json_decode(file_get_contents(SETTINGS_PATH), true); if ($settings === null) { $defaults['access']['maintenance']['hasBypassKey'] = false; unset($defaults['access']['maintenance']['bypassKeyHash']); jsonResponse(true, $defaults); } // Merge with defaults to ensure all keys exist $merged = array_replace_recursive($defaults, $settings); $merged['access']['maintenance']['hasBypassKey'] = !empty($merged['access']['maintenance']['bypassKeyHash']); unset($merged['access']['maintenance']['bypassKeyHash']); if (!isAdmin()) { unset($merged['email'], $merged['ai'], $merged['backup']); } jsonResponse(true, $merged); break; case 'save-settings': if (!isAdmin()) { jsonResponse(false, null, 'Forbidden'); } if (!defined('SETTINGS_PATH')) { jsonResponse(false, null, 'Settings path not configured'); } if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $settingsJson = $_POST['settings'] ?? ''; $settings = json_decode($settingsJson, true); if (!is_array($settings)) { jsonResponse(false, null, 'Invalid JSON format'); } // Whitelist allowed keys $allowed = [ 'branding' => ['logo', 'logoDark', 'adminLogo', 'name', 'showBranding', 'logoDisplay', 'logoSize'], 'theme' => ['adminTheme', 'publicDefault', 'preset', 'density', 'font', 'fontSize', 'primaryColor', 'accentColor', 'sidebarBg', 'darkPrimaryColor', 'darkAccentColor', 'darkSidebarBg', 'dangerColor', 'warningColor', 'successColor', 'buttonGlow', 'buttonRadius'], 'general' => ['adminLanguage', 'frontendLoginRedirect', 'aiLabelArtwork'], 'email' => ['method', 'recipientEmail', 'bccEmail', 'fromEmail', 'fromName', 'smtpHost', 'smtpPort', 'smtpUsername', 'smtpPassword', 'smtpEncryption'], 'seo' => ['siteUrl', 'organizationName', 'defaultOgImage', 'noindexSite'] ]; // Top-level scalar settings (not nested under a group) $allowedScalar = ['favicon', 'favicon_png']; // A path or address that is already stored is not checked again: an old invalid value must not block saving // unrelated settings (colours, radius, language ...), because the dashboard sends every group along. // Only a changed value has to be valid. $storedSettings = []; if (file_exists(SETTINGS_PATH)) { $storedSettings = json_decode(file_get_contents(SETTINGS_PATH), true) ?: []; } $isStored = static function (?string $group, string $key, $value) use ($storedSettings): bool { $stored = $group === null ? ($storedSettings[$key] ?? null) : ($storedSettings[$group][$key] ?? null); return is_string($value) && is_string($stored) && $value !== '' && $stored === $value; }; $sanitized = []; foreach ($allowed as $group => $keys) { if (!isset($settings[$group])) continue; $sanitized[$group] = []; foreach ($keys as $key) { if (array_key_exists($key, $settings[$group])) { $value = $settings[$group][$key]; // Validate the base colour — required (must be set) if ($key === 'primaryColor') { if (!preg_match('/^#[0-9a-fA-F]{6}$/', $value)) { jsonResponse(false, null, 'Invalid color value for ' . $key); } } // Validate optional color values — empty string means "auto" // (accentColor derives from the base colour unless overridden) if (in_array($key, ['accentColor', 'sidebarBg', 'darkPrimaryColor', 'darkAccentColor', 'darkSidebarBg', 'dangerColor', 'warningColor', 'successColor'])) { $value = (string)$value; if ($value !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $value)) { jsonResponse(false, null, 'Invalid color value for ' . $key); } } // Validate theme choices if (in_array($key, ['adminTheme', 'publicDefault'], true) && !in_array($value, ['light', 'dark', 'system'], true)) { jsonResponse(false, null, 'Invalid theme value'); } if ($key === 'preset' && !in_array($value, ['soft', 'classic', 'paper', 'studio', 'console', 'pop'], true)) { jsonResponse(false, null, 'Invalid theme preset'); } if ($key === 'density' && !in_array($value, ['auto', 'compact', 'comfortable'], true)) { jsonResponse(false, null, 'Invalid density value'); } if ($key === 'font') { if ($value === 'geist') $value = 'plex'; // retired in 1.0.0 if (!in_array($value, ['system', 'inter', 'plex', 'atkinson', 'public', 'geist-mono'], true)) { jsonResponse(false, null, 'Invalid font value'); } } if ($key === 'fontSize' && !in_array($value, ['small', 'default', 'large'], true)) { jsonResponse(false, null, 'Invalid font size value'); } // Validate buttonGlow (boolean) if ($key === 'buttonGlow') { $value = (bool)$value; } // Validate buttonRadius (integer 0-24) if ($key === 'buttonRadius') { $value = max(0, min(24, intval($value))); } // Validate frontendLoginRedirect mode if ($key === 'frontendLoginRedirect' && !in_array($value, ['auto', 'dashboard'], true)) { jsonResponse(false, null, 'Invalid frontendLoginRedirect value'); } // Validate logo paths (prevent traversal and protocol injection) if (in_array($key, ['logo', 'logoDark', 'adminLogo'], true)) { $value = (string)$value; if ($value !== '' && !$isStored($group, $key, $value) && ( strpos($value, '..') !== false || !str_starts_with($value, '/assets/images/') || preg_match('#[:\x00]#', $value) )) { jsonResponse(false, null, 'Invalid logo path'); } } if ($group === 'seo' && $key === 'defaultOgImage' && !$isStored($group, $key, $value)) { $value = normalizeOgImagePath((string)$value); if (!validateOgImagePath($value)) { jsonResponse(false, null, 'Default Open Graph image must be a JPG or PNG file from /assets/images/'); } } if ($group === 'seo' && $key === 'siteUrl' && !$isStored($group, $key, $value)) { $value = rtrim(trim((string)$value), '/'); if ($value !== '' && filter_var($value, FILTER_VALIDATE_URL) === false) { jsonResponse(false, null, 'Invalid SEO site URL'); } } if ($group === 'seo' && $key === 'organizationName') { $value = trim((string)$value); } if ($group === 'seo' && $key === 'noindexSite') { $value = (bool)$value; } // Validate logoDisplay (3-way selector) if ($key === 'logoDisplay' && !in_array($value, ['favicon', 'text', 'both'], true)) { $value = 'both'; } // Validate public logo size selector if ($key === 'logoSize' && !in_array($value, ['small', 'medium', 'large'], true)) { $value = 'medium'; } // Validate name if ($key === 'name') { $value = trim((string)$value); if (strlen($value) > 100) { $value = substr($value, 0, 100); } } // Validate adminLanguage if ($key === 'adminLanguage') { $value = trim((string)$value); if ($value !== '' && !preg_match('/^[a-z]{2,5}$/', $value)) { jsonResponse(false, null, 'Invalid language code'); } if ($value !== '' && !is_file(RAIJU_ADMIN_DIR . '/lang/' . $value . '.json')) { jsonResponse(false, null, 'Language file not found'); } } // AI label artwork: English everywhere or German on German pages if ($key === 'aiLabelArtwork' && !in_array($value, ['en', 'page'], true)) { $value = 'en'; } // Validate boolean if ($key === 'showBranding') { $value = (bool)$value; } // Validate email settings if ($group === 'email') { if ($key === 'method' && !in_array($value, ['smtp', 'sendmail', 'inactive'])) { $value = 'smtp'; } if (in_array($key, ['recipientEmail', 'bccEmail'], true)) { if (!raijuValidateEmailList($value)) { jsonResponse(false, null, 'Invalid email address list for ' . $key); } $value = raijuNormalizeEmailList($value); } if ($key === 'fromEmail') { $value = trim((string)$value); } if ($key === 'fromEmail' && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) { jsonResponse(false, null, 'Invalid email address for ' . $key); } if ($key === 'smtpPort') { $value = max(1, min(65535, intval($value))); } if ($key === 'smtpEncryption' && !in_array($value, ['tls', 'ssl', 'none'])) { $value = 'tls'; } if (in_array($key, ['smtpHost', 'smtpUsername', 'fromName'])) { $value = trim((string)$value); } // smtpPassword: allow empty (means "keep existing") if ($key === 'smtpPassword' && $value === '') { // Load existing password and keep it if (file_exists(SETTINGS_PATH)) { $existingSettings = json_decode(file_get_contents(SETTINGS_PATH), true) ?: []; $existingPw = $existingSettings['email']['smtpPassword'] ?? ''; if ($existingPw !== '') { $value = $existingPw; } } } } $sanitized[$group][$key] = $value; } } } // Top-level scalars (favicon, PNG fallback) foreach ($allowedScalar as $scalarKey) { if (!array_key_exists($scalarKey, $settings)) continue; $value = (string)$settings[$scalarKey]; if (in_array($scalarKey, ['favicon', 'favicon_png'], true)) { if ($value !== '' && !$isStored(null, $scalarKey, $value) && ( strpos($value, '..') !== false || !str_starts_with($value, '/assets/images/') || preg_match('#[:\x00]#', $value) )) { jsonResponse(false, null, 'Invalid favicon path'); } } $sanitized[$scalarKey] = $value; } if (!empty($sanitized['theme']) && is_array($sanitized['theme'])) { $sanitized['theme'] = raijuSanitizeThemeContrast($sanitized['theme']); } $contentDir = dirname(SETTINGS_PATH); if (!is_dir($contentDir)) { mkdir($contentDir, 0755, true); } // Merge with existing file to preserve non-whitelisted keys (e.g. favicon) $existing = []; if (file_exists(SETTINGS_PATH)) { $existing = json_decode(file_get_contents(SETTINGS_PATH), true) ?: []; } $accessInput = isset($settings['access']) ? ['access' => array_replace_recursive($existing['access'] ?? [], $settings['access'])] : []; // Lists replace as a whole: a recursive merge would keep rows by index if (isset($settings['access']['maintenance']) && is_array($settings['access']['maintenance']) && array_key_exists('bypassLinks', $settings['access']['maintenance'])) { $accessInput['access']['maintenance']['bypassLinks'] = $settings['access']['maintenance']['bypassLinks']; } $accessPatch = sanitizeAccessSettings($accessInput, $existing); if ($accessPatch) { $sanitized['access'] = $accessPatch; } $loginInput = isset($settings['login']) ? ['login' => array_replace_recursive($existing['login'] ?? [], $settings['login'])] : []; $loginPatch = sanitizeLoginVisualSettings($loginInput); if ($loginPatch) { $sanitized['login'] = $loginPatch; } $privacyPatch = sanitizePrivacySettings($settings); if ($privacyPatch) { $sanitized['privacy'] = $privacyPatch; } $modulesPatch = sanitizeModuleSettings($settings); if ($modulesPatch) { $sanitized['modules'] = $modulesPatch; } $dashboardPatch = sanitizeDashboardSettings($settings); if ($dashboardPatch) { $sanitized['dashboard'] = $dashboardPatch; } $mediaPatch = sanitizeMediaSettings($settings); if ($mediaPatch) { $sanitized['media'] = $mediaPatch; } $merged = array_replace_recursive($existing, $sanitized); if (isset($sanitized['access']['maintenance']['bypassLinks'])) { $merged['access']['maintenance']['bypassLinks'] = $sanitized['access']['maintenance']['bypassLinks']; } $result = raijuJsonAtomicWrite(SETTINGS_PATH, $merged); if ($result === false) { jsonResponse(false, null, 'Error saving settings'); } $publicMerged = $merged; $publicMerged['access']['maintenance']['hasBypassKey'] = !empty($publicMerged['access']['maintenance']['bypassKeyHash']); unset($publicMerged['access']['maintenance']['bypassKeyHash']); jsonResponse(true, $publicMerged, 'Settings saved'); break; case 'test-email': if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $testConfig = json_decode($_POST['emailConfig'] ?? '{}', true); if (!$testConfig || empty($testConfig['recipientEmail'])) { jsonResponse(false, null, 'Recipient email is required'); } if (!raijuValidateEmailList($testConfig['recipientEmail'] ?? '')) { jsonResponse(false, null, 'Invalid email address list for recipientEmail'); } if (!raijuValidateEmailList($testConfig['bccEmail'] ?? '')) { jsonResponse(false, null, 'Invalid email address list for bccEmail'); } $testToRecipients = raijuParseEmailList($testConfig['recipientEmail'] ?? ''); $testBccRecipients = raijuParseEmailList($testConfig['bccEmail'] ?? ''); if (empty($testToRecipients)) { jsonResponse(false, null, 'Recipient email is required'); } $testTo = implode(', ', $testToRecipients); $testFrom = trim((string)($testConfig['fromEmail'] ?? '')) ?: $testToRecipients[0]; if (!filter_var($testFrom, FILTER_VALIDATE_EMAIL)) { jsonResponse(false, null, 'Invalid email address for fromEmail'); } $testFromName = $testConfig['fromName'] ?: 'raiju CMS'; $testSubject = 'raiju CMS — Test Email'; $testBody = "This is a test email from raiju CMS.\n\nIf you can read this, your email settings are working correctly.\n\nTimestamp: " . date('Y-m-d H:i:s'); $testMethod = $testConfig['method'] ?? 'smtp'; $testSent = false; $testError = ''; if ($testMethod === 'smtp') { require_once RAIJU_ADMIN_DIR . '/../api/SmtpMailer.php'; $mailer = new SmtpMailer( $testConfig['smtpHost'] ?? '', intval($testConfig['smtpPort'] ?? 587), $testConfig['smtpUsername'] ?? '', $testConfig['smtpPassword'] ?? '', $testConfig['smtpEncryption'] ?? 'tls' ); // If password is empty, try to load from saved settings if (empty($testConfig['smtpPassword']) && defined('SETTINGS_PATH') && file_exists(SETTINGS_PATH)) { $savedSettings = json_decode(file_get_contents(SETTINGS_PATH), true) ?: []; $savedPw = $savedSettings['email']['smtpPassword'] ?? ''; if ($savedPw) { $mailer = new SmtpMailer( $testConfig['smtpHost'] ?? '', intval($testConfig['smtpPort'] ?? 587), $testConfig['smtpUsername'] ?? '', $savedPw, $testConfig['smtpEncryption'] ?? 'tls' ); } } $testSent = $mailer->send($testToRecipients, $testSubject, $testBody, $testFrom, $testFromName, '', $testBccRecipients); if (!$testSent) { $testError = $mailer->getLastError(); } } elseif ($testMethod === 'sendmail') { $headers = []; $headers[] = 'From: ' . ($testFromName ? "=?UTF-8?B?" . base64_encode($testFromName) . "?= <$testFrom>" : $testFrom); $headers[] = 'Content-Type: text/plain; charset=UTF-8'; $headers[] = 'X-Mailer: raiju CMS'; $testSent = @mail($testTo, '=?UTF-8?B?' . base64_encode($testSubject) . '?=', $testBody, implode("\r\n", $headers)); foreach ($testBccRecipients as $bccRecipient) { $testSent = @mail($bccRecipient, '=?UTF-8?B?' . base64_encode($testSubject) . '?=', $testBody, implode("\r\n", $headers)) && $testSent; } if (!$testSent) { $testError = 'PHP mail() returned false. Check server mail configuration.'; } } if ($testSent) { jsonResponse(true, null, 'Test email sent successfully'); } else { jsonResponse(false, null, $testError ?: 'Failed to send test email'); } break; case 'total-reset': if (!isAdmin()) { jsonResponse(false, null, 'Forbidden'); } if (!validateCsrfToken()) { jsonResponse(false, null, 'Invalid CSRF token'); } $confirm = $_POST['confirm'] ?? ''; if ($confirm !== 'DELETE') { jsonResponse(false, null, 'Confirmation mismatch'); } $root = dirname(RAIJU_ADMIN_DIR); // Collect language directories from config $langDirs = []; if (isset($SITE_LANGUAGES) && is_array($SITE_LANGUAGES)) { foreach (array_keys($SITE_LANGUAGES) as $code) { $dir = $root . '/' . $code; if (is_dir($dir)) { $langDirs[] = $dir; } } } // Recursive directory delete helper $rmdir = function($dir) use (&$rmdir) { if (!is_dir($dir)) return; $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $dir . '/' . $item; if (is_dir($path)) { $rmdir($path); } else { @unlink($path); } } @rmdir($dir); }; // Delete content directory (pages, news, settings, events) $rmdir($root . '/content'); // Delete language directories foreach ($langDirs as $dir) { $rmdir($dir); } // Delete backups $rmdir($root . '/backups'); // Delete user-uploaded images (but keep favicon.svg) $imagesDir = $root . '/assets/images'; if (is_dir($imagesDir)) { $items = scandir($imagesDir); foreach ($items as $item) { if ($item === '.' || $item === '..' || $item === 'favicon.svg') continue; $path = $imagesDir . '/' . $item; if (is_dir($path)) { $rmdir($path); } else { @unlink($path); } } } // Delete trash directories $rmdir($root . '/assets/images-trash'); $rmdir($root . '/assets/audio-trash'); // Clean audio directory (keep directory, remove files) $audioDir = $root . '/assets/audio'; if (is_dir($audioDir)) { $items = scandir($audioDir); foreach ($items as $item) { if ($item === '.' || $item === '..') continue; $path = $audioDir . '/' . $item; if (is_dir($path)) { $rmdir($path); } else { @unlink($path); } } } // Delete nav-config.php @unlink($root . '/includes/nav-config.php'); // Delete config.php (must be last — triggers setup wizard) @unlink(RAIJU_ADMIN_DIR . '/config.php'); // Destroy session session_destroy(); jsonResponse(true, null, 'Installation reset'); break; // ─── Site Backup ──────────────────────────────────────────────── }