Raiju/admin/api/settings.php
touche 3c591a287a
Some checks are pending
Raiju CI / PHP ${{ matrix.php }} (8.1) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.4) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.5) (push) Waiting to run
Raiju CI / Apache routing / PHP-FPM (push) Waiting to run
Raiju CI / Chromium (push) Waiting to run
Brand: capitalize Raiju in content, UI strings and prompts
- Starter content and admin language files: Raiju, not raiju
- Test email, backup messages, mailer header and AI prompts use Raiju
- Meta generator and footer fallback use Raiju
- Technical identifiers (paths, CSS classes, user agents, temp prefixes) unchanged
2026-10-25 06:30:17 +03:00

604 lines
26 KiB
PHP

<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
// Authenticated dispatcher supplies shared helpers and request context.
switch ($action) {
case 'load-settings':
$defaults = [
'favicon' => 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 ────────────────────────────────────────────────
}