Raiju/admin/api/bootstrap.php
2026-10-25 06:05:42 +03:00

2057 lines
74 KiB
PHP

<?php
if (!defined('RAIJU_ADMIN_DIR')) { http_response_code(404); exit; }
/**
* API endpoint for Content Management
* Actions: load, save, backups, restore, delete-backup, events, images, audio, mails
*/
require_once RAIJU_ADMIN_DIR . '/config.php';
require_once RAIJU_ADMIN_DIR . '/users.php';
require_once RAIJU_ADMIN_DIR . '/lang/i18n.php';
require_once RAIJU_ADMIN_DIR . '/../includes/content-loader.php';
require_once RAIJU_ADMIN_DIR . '/../includes/page-path.php';
require_once RAIJU_ADMIN_DIR . '/../includes/seo-helper.php';
require_once RAIJU_ADMIN_DIR . '/../includes/ai/ai-helper.php';
require_once RAIJU_ADMIN_DIR . '/../includes/ai/copilot-context.php';
require_once RAIJU_ADMIN_DIR . '/../includes/ai/image-job-runner.php';
require_once RAIJU_ADMIN_DIR . '/../includes/analytics-helper.php';
require_once RAIJU_ADMIN_DIR . '/../includes/forms.php';
require_once RAIJU_ADMIN_DIR . '/../includes/media-web-version.php';
ensureUsersFile();
// Prevent PHP HTML error output from corrupting JSON responses. The security
// check reports the configured value that public pages still use.
define('RAIJU_INITIAL_DISPLAY_ERRORS', (string)ini_get('display_errors'));
ini_set('html_errors', '0');
ini_set('display_errors', '0');
require_once RAIJU_ADMIN_DIR . '/../includes/session-helper.php';
raijuSessionStart();
header('Content-Type: application/json; charset=utf-8');
function isAuthenticated() {
return raijuSessionValidate();
}
// CSRF token validation
function validateCsrfToken() {
$token = $_POST['csrf_token'] ?? $_GET['csrf_token'] ?? '';
return is_string($token) && isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
// JSON response helper
function jsonResponse($success, $data = null, $message = '') {
if (!$success && http_response_code() < 400) {
http_response_code($message === 'Forbidden' ? 403 : 400);
}
$messageKeys = ['Forbidden' => 'api.forbidden', 'Invalid CSRF token' => 'api.csrf', 'Invalid JSON format' => 'api.invalid_json', 'Error saving' => 'api.storage', 'Error saving settings' => 'api.storage'];
$message = isset($messageKeys[$message]) ? t($messageKeys[$message]) : $message;
echo json_encode([
'revision' => isset($GLOBALS['raijuRevisionPath']) ? raijuJsonRevision($GLOBALS['raijuRevisionPath']) : null,
'success' => $success,
'data' => $data,
'message' => $message
], JSON_UNESCAPED_UNICODE);
exit;
}
function raijuParseEmailList($value): array {
$rawItems = is_array($value) ? $value : explode(',', (string)$value);
$emails = [];
foreach ($rawItems as $item) {
foreach (explode(',', (string)$item) as $email) {
$email = trim(str_replace(["\r", "\n", "\t"], '', (string)$email));
if ($email !== '') {
$emails[] = $email;
}
}
}
return array_values(array_unique($emails));
}
function raijuNormalizeEmailList($value): string {
return implode(', ', raijuParseEmailList($value));
}
function raijuValidateEmailList($value): bool {
foreach (raijuParseEmailList($value) as $email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return false;
}
}
return true;
}
function raijuApiCurrentAiUser(): string {
return (string)($_SESSION['admin_username'] ?? ($_SESSION['admin_user_id'] ?? ''));
}
function raijuApiCanUseImageJobs(): bool {
if (isAdmin()) {
return true;
}
if (!dashboardAiModuleEnabled()) {
return false;
}
if (raijuCopilotCan('generateImage')) {
return true;
}
// Editors whose AI agent may generate images run and poll their own agent jobs.
$agent = RAIJU_ADMIN_DIR . '/../includes/ai/agent/agent-config.php';
if (!function_exists('raijuAgentCan') && is_file($agent)) {
require_once $agent;
}
return function_exists('raijuAgentCan') && raijuAgentCan('generateImages');
}
function raijuApiAssertImageJobAccess(array $job): void {
if (isAdmin()) {
return;
}
if ((string)($job['user'] ?? '') !== raijuApiCurrentAiUser()) {
throw new RuntimeException('Forbidden');
}
}
/**
* Send the JSON response now and keep this PHP process alive to finish work
* in the background. Works on PHP-FPM (most shared hosting) without cron;
* returns false when detaching is unavailable so callers run synchronously.
*/
function raijuApiDetachResponse(array $payload): bool {
if (!function_exists('fastcgi_finish_request')) {
return false;
}
ignore_user_abort(true);
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
fastcgi_finish_request();
return true;
}
function raijuApiRunImageJob(string $jobId): array {
return raijuAiRunImageJobCore($jobId, 'raijuApiAssertImageJobAccess');
}
function raijuApiTrySpawnLocalImageJobWorker(string $jobId): bool {
if (PHP_SAPI !== 'cli-server') {
return false;
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $jobId)) {
return false;
}
$php = PHP_BINARY;
$script = dirname(RAIJU_ADMIN_DIR) . '/cli/ai-image-job-worker.php';
if (!is_file($script) || $php === '') {
return false;
}
if (stripos(PHP_OS_FAMILY, 'Windows') !== false) {
$command = 'start /B "" ' . escapeshellarg($php) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($jobId);
pclose(popen($command, 'r'));
} else {
$command = escapeshellarg($php) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($jobId) . ' > /dev/null 2>&1 &';
exec($command);
}
raijuAiAudit('image-job-worker-spawned', true, ['jobId' => $jobId, 'sapi' => PHP_SAPI]);
return true;
}
function raijuNormalizeHexColor(string $value): string {
return strtolower(trim($value));
}
function raijuHexToRgb(string $hex): array {
$hex = ltrim(raijuNormalizeHexColor($hex), '#');
return [
hexdec(substr($hex, 0, 2)),
hexdec(substr($hex, 2, 2)),
hexdec(substr($hex, 4, 2))
];
}
function raijuRgbToHex(array $rgb): string {
return sprintf(
'#%02x%02x%02x',
max(0, min(255, (int)round($rgb[0]))),
max(0, min(255, (int)round($rgb[1]))),
max(0, min(255, (int)round($rgb[2])))
);
}
function raijuRelativeLuminance(string $hex): float {
$channels = array_map(function ($channel) {
$value = $channel / 255;
return $value <= 0.03928
? $value / 12.92
: (($value + 0.055) / 1.055) ** 2.4;
}, raijuHexToRgb($hex));
return ($channels[0] * 0.2126) + ($channels[1] * 0.7152) + ($channels[2] * 0.0722);
}
function raijuContrastRatio(string $a, string $b): float {
$l1 = raijuRelativeLuminance($a);
$l2 = raijuRelativeLuminance($b);
$lighter = max($l1, $l2);
$darker = min($l1, $l2);
return ($lighter + 0.05) / ($darker + 0.05);
}
function raijuMixHex(string $a, string $b, float $ratio): string {
$ar = raijuHexToRgb($a);
$br = raijuHexToRgb($b);
return raijuRgbToHex([
($ar[0] * $ratio) + ($br[0] * (1 - $ratio)),
($ar[1] * $ratio) + ($br[1] * (1 - $ratio)),
($ar[2] * $ratio) + ($br[2] * (1 - $ratio))
]);
}
function raijuAdjustColorForContrast(string $hex, string $background, float $minimumRatio, string $direction): string {
$hex = raijuNormalizeHexColor($hex);
if (raijuContrastRatio($hex, $background) >= $minimumRatio) {
return $hex;
}
$target = $direction === 'lighter' ? '#ffffff' : '#000000';
for ($step = 1; $step <= 20; $step++) {
$candidate = raijuMixHex($hex, $target, 1 - ($step * 0.05));
if (raijuContrastRatio($candidate, $background) >= $minimumRatio) {
return $candidate;
}
}
return $target;
}
function raijuSanitizeThemeContrast(array $theme): array {
$minReadable = 3.0;
// The stylesheet picks white or dark text on the base colour by its
// lightness, so a base colour only needs to carry one of the two. Light
// colours such as orange stay as chosen instead of being darkened.
foreach (['primaryColor', 'accentColor', 'dangerColor', 'warningColor', 'successColor'] as $key) {
if (!empty($theme[$key])) {
$hex = raijuNormalizeHexColor((string)$theme[$key]);
$best = max(raijuContrastRatio($hex, '#ffffff'), raijuContrastRatio($hex, '#111111'));
$theme[$key] = $best >= $minReadable ? $hex : raijuAdjustColorForContrast($hex, '#ffffff', $minReadable, 'darker');
}
}
foreach (['darkPrimaryColor', 'darkAccentColor'] as $key) {
if (!empty($theme[$key])) {
$theme[$key] = raijuAdjustColorForContrast((string)$theme[$key], '#0b0d12', $minReadable, 'lighter');
}
}
if (empty($theme['darkPrimaryColor']) && !empty($theme['primaryColor']) && raijuContrastRatio((string)$theme['primaryColor'], '#0b0d12') < $minReadable) {
$theme['darkPrimaryColor'] = raijuAdjustColorForContrast((string)$theme['primaryColor'], '#0b0d12', $minReadable, 'lighter');
}
if (empty($theme['darkAccentColor']) && !empty($theme['accentColor']) && raijuContrastRatio((string)$theme['accentColor'], '#0b0d12') < $minReadable) {
$theme['darkAccentColor'] = raijuAdjustColorForContrast((string)$theme['accentColor'], '#0b0d12', $minReadable, 'lighter');
}
if (!empty($theme['sidebarBg'])) {
$theme['sidebarBg'] = raijuAdjustColorForContrast((string)$theme['sidebarBg'], '#1a1a1a', $minReadable, 'lighter');
}
if (!empty($theme['darkSidebarBg'])) {
$theme['darkSidebarBg'] = raijuAdjustColorForContrast((string)$theme['darkSidebarBg'], '#e5e5e5', $minReadable, 'darker');
}
return $theme;
}
function redirectHtml($title, $message, $url = 'dashboard') {
header_remove('Content-Type');
header('Content-Type: text/html; charset=utf-8');
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$safeMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
$safeUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
echo "<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"2;url={$safeUrl}\"><title>{$safeTitle}</title></head><body style=\"font-family:system-ui,sans-serif;padding:2rem\"><h1>{$safeTitle}</h1><p>{$safeMessage}</p><p><a href=\"{$safeUrl}\">Back to dashboard</a></p><script>if (window.opener) { window.opener.postMessage({type:'raiju:backup-oauth', provider:'dropbox'}, window.location.origin); setTimeout(function(){ window.close(); }, 1200); }</script></body></html>";
exit;
}
// Validate page name (lang_slug format, e.g. de_home, en_example)
function validatePageName($page) {
return raijuPageIsValidContentKey((string)$page) || in_array($page, ['sidebar', 'footer']);
}
function dashboardCopilotAdminUrl(string $hash = ''): string {
$hash = ltrim($hash, '#');
return '/admin/dashboard' . ($hash !== '' ? '#' . $hash : '');
}
function dashboardCopilotPageUrl(string $pageName): string {
$page = raijuPageParseContentKey($pageName);
if ($page === null) {
return '';
}
return raijuSeoPageUrl($page['lang'], $page['path']);
}
function dashboardCopilotNewsUrl(string $id, string $lang): string {
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $id) || !preg_match('/^[a-z]{2}$/', $lang)) {
return '';
}
$base = raijuSeoBaseUrl();
$defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en';
$path = $lang === $defaultLang ? '/news/' . $id : '/' . $lang . '/news/' . $id;
return $base . $path;
}
function dashboardCopilotCreatePageBackup(string $contentPage, bool $cleanup = true): string {
if (!validatePageName($contentPage)) {
throw new RuntimeException('Invalid page name');
}
$filepath = CONTENT_PATH . $contentPage . '.json';
if (!is_file($filepath)) {
return '';
}
for ($offset = 0; $offset < 5; $offset++) {
$timestamp = date('Y-m-d_His', time() + $offset);
$backupName = $contentPage . '_' . $timestamp . '.json';
$backupPath = BACKUP_PATH . $backupName;
if (is_file($backupPath)) {
continue;
}
if (!copy($filepath, $backupPath)) {
throw new RuntimeException('Could not create backup before AI change.');
}
if ($cleanup) {
cleanupOldBackups($contentPage);
}
return $backupName;
}
throw new RuntimeException('Could not allocate a backup filename before AI change.');
}
function dashboardCopilotConfirmed(): bool {
return (string)($_POST['confirmed'] ?? '') === '1';
}
function raijuApiHttpGetJson(string $url, int $timeout = 12): array {
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'raiju-cms'
]);
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
unset($ch);
if ($raw === false) {
throw new RuntimeException($error !== '' ? $error : 'Request failed.');
}
} else {
$context = stream_context_create(['http' => ['timeout' => $timeout, 'ignore_errors' => true, 'header' => "Accept: application/json\r\nUser-Agent: raiju-cms"]]);
$raw = @file_get_contents($url, false, $context);
if ($raw === false) {
throw new RuntimeException('Request failed.');
}
$status = 200;
$responseHeaders = $http_response_header ?? [];
if (isset($responseHeaders[0]) && preg_match('/\s(\d{3})\s/', $responseHeaders[0], $m)) {
$status = (int)$m[1];
}
}
if ($status < 200 || $status >= 300) {
throw new RuntimeException('Request failed with status ' . $status . '.');
}
$data = json_decode((string)$raw, true);
if (!is_array($data)) {
throw new RuntimeException('Response is not valid JSON.');
}
return $data;
}
/**
* Fetch the OpenRouter model catalog through the server (the browser never
* talks to providers directly) with a 24h flat-file cache.
*/
function raijuApiOpenRouterModels(bool $forceRefresh = false): array {
$cachePath = dirname(rtrim(CONTENT_PATH, '/')) . '/openrouter-models-cache.json';
if (!$forceRefresh && is_file($cachePath)) {
$cache = json_decode((string)file_get_contents($cachePath), true);
if (is_array($cache)
&& (time() - (int)($cache['fetchedAt'] ?? 0)) < 86400
&& is_array($cache['textModels'] ?? null)) {
return $cache;
}
}
$response = raijuApiHttpGetJson('https://openrouter.ai/api/v1/models');
$preferredVendors = ['openai', 'anthropic', 'google', 'mistralai', 'meta-llama', 'deepseek', 'x-ai', 'qwen'];
$textModels = [];
$imageModels = [];
foreach ((is_array($response['data'] ?? null) ? $response['data'] : []) as $model) {
if (!is_array($model)) {
continue;
}
$id = trim((string)($model['id'] ?? ''));
if ($id === '' || strlen($id) > 120) {
continue;
}
$name = substr(trim((string)($model['name'] ?? $id)), 0, 120);
$arch = is_array($model['architecture'] ?? null) ? $model['architecture'] : [];
$outputs = is_array($arch['output_modalities'] ?? null) ? $arch['output_modalities'] : [];
$modality = (string)($arch['modality'] ?? '');
$outputPart = str_contains($modality, '->') ? explode('->', $modality, 2)[1] : '';
$producesImages = in_array('image', $outputs, true) || str_contains($outputPart, 'image');
if ($producesImages) {
$pricing = is_array($model['pricing'] ?? null) ? $model['pricing'] : [];
// Prefer an explicit per-output-image price (USD). Some models
// report a per-image-token figure here that is far below a real
// whole-image cost, so when it is implausibly tiny (< $0.001) fall
// back to estimating from the completion (output token) price,
// assuming a typical image is billed at ~1290 output tokens.
$imageFieldUsd = (float)($pricing['image'] ?? 0);
$completionUsd = (float)($pricing['completion'] ?? 0);
$perImageUsd = 0.0;
$estimated = false;
if ($imageFieldUsd >= 0.001) {
$perImageUsd = $imageFieldUsd;
} elseif ($completionUsd > 0) {
$perImageUsd = $completionUsd * 1290;
$estimated = true;
} elseif ($imageFieldUsd > 0) {
$perImageUsd = $imageFieldUsd;
$estimated = true;
}
// Store as a float of cents so sub-cent image prices survive
// (e.g. $0.0004 -> 0.04 cents); the UI formats with precision.
$imageModels[] = [
'id' => $id,
'name' => $name,
'imageCostCents' => $perImageUsd > 0 ? round($perImageUsd * 100, 4) : null,
'imageCostEstimated' => $estimated
];
continue;
}
$vendor = strtolower((string)strtok($id, '/'));
if (!in_array($vendor, $preferredVendors, true)) {
continue;
}
$pricing = is_array($model['pricing'] ?? null) ? $model['pricing'] : [];
// OpenRouter pricing is USD per token; convert to cents per million tokens.
$textModels[] = [
'id' => $id,
'name' => $name,
'promptCentsPerMillion' => (int)round(((float)($pricing['prompt'] ?? 0)) * 100000000),
'completionCentsPerMillion' => (int)round(((float)($pricing['completion'] ?? 0)) * 100000000)
];
}
usort($textModels, fn(array $a, array $b) => strcmp($a['id'], $b['id']));
usort($imageModels, fn(array $a, array $b) => strcmp($a['id'], $b['id']));
$result = [
'fetchedAt' => time(),
'textModels' => array_slice($textModels, 0, 150),
'imageModels' => array_slice($imageModels, 0, 50)
];
@file_put_contents($cachePath, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
return $result;
}
function raijuAuditCountMissingAlt($node): int {
if (!is_array($node)) {
return 0;
}
$count = 0;
$src = $node['src'] ?? null;
if (is_string($src) && trim($src) !== '' && trim((string)($node['alt'] ?? '')) === '') {
$count++;
}
foreach ($node as $value) {
if (is_array($value)) {
$count += raijuAuditCountMissingAlt($value);
}
}
return $count;
}
function raijuAuditDescriptionStatus(string $description): string {
$length = function_exists('mb_strlen') ? mb_strlen($description, 'UTF-8') : strlen($description);
if ($length === 0) {
return 'missing';
}
if ($length < 50) {
return 'short';
}
if ($length > 170) {
return 'long';
}
return 'ok';
}
function raijuAuditPageText(array $data, int $limit = 6000): string {
$parts = [];
$walk = function ($node) use (&$walk, &$parts): void {
if (is_string($node)) {
$text = trim(strip_tags($node));
if ($text !== '') {
$parts[] = $text;
}
return;
}
if (is_array($node)) {
foreach ($node as $key => $value) {
if (in_array((string)$key, ['src', 'id', 'type', 'href', 'url', 'image'], true)) {
continue;
}
$walk($value);
}
}
};
$walk($data['sections'] ?? []);
return substr(implode("\n", $parts), 0, $limit);
}
function dashboardCopilotUiLanguage(): string {
$language = trim((string)($_POST['uiLanguage'] ?? $_GET['uiLanguage'] ?? ''));
if ($language === '' && function_exists('_nbAdminLang')) {
$language = _nbAdminLang();
}
if (function_exists('raijuCopilotNormalizeLanguageCode')) {
$language = raijuCopilotNormalizeLanguageCode($language);
}
return $language !== '' ? $language : (defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en');
}
function dashboardCopilotProposalAuditSummary(array $proposals): array {
return raijuCopilotProposalAuditSummary($proposals);
}
function dashboardCopilotUndoSignature(string $contentPage, string $backup, string $path): string {
return raijuCopilotUndoSignature($contentPage, $backup, $path);
}
function dashboardCopilotHistoryDir(): string {
$dir = dirname(CONTENT_PATH) . '/ai-chat-history';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return rtrim($dir, '/\\') . '/';
}
function dashboardCopilotCurrentUserKey(): string {
$user = (string)($_SESSION['admin_user_id'] ?? ($_SESSION['admin_username'] ?? 'admin'));
$user = preg_replace('/[^a-zA-Z0-9_-]/', '-', $user);
return trim((string)$user, '-') ?: 'admin';
}
function dashboardCopilotHistoryId(?string $id = null): string {
$id = trim((string)$id);
if ($id !== '' && preg_match('/^[a-z0-9][a-z0-9_-]{7,79}$/i', $id)) {
return $id;
}
return 'chat-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4));
}
function dashboardCopilotHistoryPath(string $id): string {
if (!preg_match('/^[a-z0-9][a-z0-9_-]{7,79}$/i', $id)) {
throw new RuntimeException('Invalid chat history ID');
}
return dashboardCopilotHistoryDir() . $id . '.json';
}
function dashboardCopilotCleanHistoryMessages($messages): array {
if (!is_array($messages)) {
return [];
}
$clean = [];
foreach (array_slice($messages, -80) as $message) {
if (!is_array($message)) {
continue;
}
$role = (string)($message['role'] ?? '');
if (!in_array($role, ['user', 'assistant'], true)) {
continue;
}
$content = trim((string)($message['content'] ?? ''));
if ($content === '') {
continue;
}
$clean[] = [
'role' => $role,
'content' => substr($content, 0, 4000)
];
}
return $clean;
}
function dashboardCopilotCleanImageResult($image): ?array {
if (!is_array($image) || trim((string)($image['path'] ?? '')) === '') {
return null;
}
return [
'path' => substr((string)$image['path'], 0, 500),
'alt' => substr((string)($image['alt'] ?? ''), 0, 500),
'prompt' => substr((string)($image['prompt'] ?? ''), 0, 1000),
'field' => substr((string)($image['field'] ?? ''), 0, 500),
'label' => substr((string)($image['label'] ?? ''), 0, 500)
];
}
function dashboardCopilotHistorySummary(array $chat): array {
$messages = is_array($chat['messages'] ?? null) ? $chat['messages'] : [];
$title = trim((string)($chat['title'] ?? ''));
if ($title === '') {
foreach ($messages as $message) {
if (($message['role'] ?? '') === 'user') {
$title = trim((string)($message['content'] ?? ''));
break;
}
}
}
if ($title === '') {
$title = 'AI Assistant chat';
}
return [
'id' => (string)($chat['id'] ?? ''),
'title' => substr($title, 0, 90),
'contentPage' => (string)($chat['contentPage'] ?? ''),
'pageTitle' => substr((string)($chat['pageTitle'] ?? ''), 0, 120),
'url' => substr((string)($chat['url'] ?? ''), 0, 500),
'messageCount' => count($messages),
'updatedAt' => (string)($chat['updatedAt'] ?? ''),
'createdAt' => (string)($chat['createdAt'] ?? '')
];
}
function dashboardCopilotLoadOwnedHistory(string $id): array {
$path = dashboardCopilotHistoryPath($id);
if (!is_file($path)) {
throw new RuntimeException('Chat history not found');
}
$chat = json_decode((string)file_get_contents($path), true);
if (!is_array($chat)) {
throw new RuntimeException('Chat history is invalid');
}
if ((string)($chat['user'] ?? '') !== dashboardCopilotCurrentUserKey()) {
throw new RuntimeException('Chat history not found');
}
return $chat;
}
// Validate backup filename
function validateBackupName($backup) {
if (!preg_match('/^(.+)_\d{4}-\d{2}-\d{2}_\d{6}(?:_[a-f0-9]{6})?\.json$/', (string)$backup, $matches)) return false;
return validatePageName($matches[1]);
}
function dashboardReadJsonFile(string $path): array {
if (!is_file($path)) {
return [];
}
$data = json_decode((string)file_get_contents($path), true);
return is_array($data) ? $data : [];
}
function dashboardAiModuleEnabled(): bool {
if (!defined('SETTINGS_PATH') || !is_file(SETTINGS_PATH)) {
return true;
}
$settings = dashboardReadJsonFile(SETTINGS_PATH);
$modules = $settings['modules'] ?? [];
if (!is_array($modules)) {
return true;
}
return !array_key_exists('ai', $modules) || !empty($modules['ai']);
}
function dashboardStatusOverview(string $pagesPath, string $newsPath): array {
$contentRoot = dirname($pagesPath);
$mails = dashboardReadJsonFile($contentRoot . '/mails.json');
$unreadMessages = count(array_filter($mails, fn($mail) => empty($mail['read'])));
$backup = ['enabled' => false, 'lastRun' => null, 'lastStatus' => '', 'newest' => null, 'count' => 0];
$backupHelper = RAIJU_ADMIN_DIR . '/../includes/backup-helper.php';
if (is_file($backupHelper)) {
require_once $backupHelper;
if (function_exists('backupStatus')) {
$status = backupStatus();
$backup = [
'enabled' => (bool)($status['enabled'] ?? false),
'lastRun' => $status['last_run'] ?? null,
'lastStatus' => $status['last_status'] ?? '',
'newest' => $status['newest'] ?? null,
'count' => (int)($status['count'] ?? 0)
];
}
}
$recent = [];
foreach (glob($pagesPath . '[a-z][a-z]_*.json') ?: [] as $file) {
$data = dashboardReadJsonFile($file);
$modified = strtotime((string)($data['lastModified'] ?? '')) ?: filemtime($file);
$pageId = pathinfo($file, PATHINFO_FILENAME);
$recent[] = [
'type' => 'page',
'id' => $pageId,
'title' => $data['title'] ?? $pageId,
'modified' => $modified,
'url' => '#page/' . $pageId
];
}
foreach (glob($newsPath . '*.json') ?: [] as $file) {
$data = dashboardReadJsonFile($file);
$modified = strtotime((string)($data['lastModified'] ?? '')) ?: filemtime($file);
$recent[] = [
'type' => 'news',
'id' => $data['id'] ?? pathinfo($file, PATHINFO_FILENAME),
'title' => $data['title'] ?? ($data['slug'] ?? pathinfo($file, PATHINFO_FILENAME)),
'modified' => $modified,
'url' => '#news'
];
}
usort($recent, fn($a, $b) => ($b['modified'] ?? 0) <=> ($a['modified'] ?? 0));
$recent = array_slice($recent, 0, 3);
$usersData = loadUsers();
$currentUserId = $_SESSION['admin_user_id'] ?? '';
$currentUser = $currentUserId ? findUserById($currentUserId) : null;
$lastLoginUser = null;
foreach (($usersData['users'] ?? []) as $user) {
if (empty($user['lastLogin'])) {
continue;
}
if (!$lastLoginUser || strtotime((string)$user['lastLogin']) > strtotime((string)$lastLoginUser['lastLogin'])) {
$lastLoginUser = $user;
}
}
return [
'messages' => ['unread' => $unreadMessages],
'backup' => $backup,
'recentEdits' => $recent,
'users' => [
'current' => $currentUser ? [
'id' => $currentUser['id'] ?? '',
'username' => $currentUser['username'] ?? '',
'role' => $currentUser['role'] ?? ''
] : null,
'lastLogin' => $lastLoginUser ? [
'id' => $lastLoginUser['id'] ?? '',
'username' => $lastLoginUser['username'] ?? '',
'lastLogin' => $lastLoginUser['lastLogin'] ?? null
] : null
]
];
}
// Cleanup old backups (keep max MAX_BACKUPS)
function cleanupOldBackups($pagePrefix) {
$backups = glob(BACKUP_PATH . $pagePrefix . '_*.json');
usort($backups, function($a, $b) {
return filemtime($b) - filemtime($a);
});
while (count($backups) > MAX_BACKUPS) {
$oldBackup = array_pop($backups);
unlink($oldBackup);
}
}
function validateImageFilename(string $filename): bool {
if ($filename === '' || strpos($filename, '/') !== false || strpos($filename, '\\') !== false || strpos($filename, '..') !== false) {
return false;
}
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'], true);
}
function normalizeOgImagePath(string $path): string {
$path = trim($path);
if ($path === '') return '';
if (str_starts_with($path, '../assets/images/')) {
$path = '/assets/images/' . substr($path, strlen('../assets/images/'));
} elseif (str_starts_with($path, 'assets/images/')) {
$path = '/' . $path;
}
return $path;
}
function validateOgImagePath(string $path): bool {
$path = normalizeOgImagePath($path);
if ($path === '') return true;
if (
strpos($path, '..') !== false ||
!str_starts_with($path, '/assets/images/') ||
preg_match('#[:\x00]#', $path)
) {
return false;
}
$ext = strtolower(pathinfo(parse_url($path, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
return in_array($ext, ['jpg', 'jpeg', 'png'], true);
}
function getMediaConfig(?string $type = null): array {
$root = dirname(RAIJU_ADMIN_DIR);
$configs = [
'image' => [
'type' => 'image',
'label' => 'Images',
'field' => 'image',
'path' => defined('IMAGES_PATH') ? IMAGES_PATH : $root . '/assets/images/',
'trashPath' => defined('IMAGES_TRASH_PATH') ? IMAGES_TRASH_PATH : $root . '/assets/images-trash/',
'publicPath' => '../assets/images/',
'extensions' => ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'],
'mimeTypes' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml'],
// The image limit is a setting (Settings → Modules → Media library); large
// uploads get a web version, see includes/media-web-version.php
'maxSize' => raijuMediaImageMaxBytes(),
'fallbackName' => 'image',
],
'audio' => [
'type' => 'audio',
'label' => 'Audio',
'field' => 'audio',
'path' => defined('AUDIO_PATH') ? AUDIO_PATH : $root . '/assets/audio/',
'trashPath' => defined('AUDIO_TRASH_PATH') ? AUDIO_TRASH_PATH : $root . '/assets/audio-trash/',
'publicPath' => '../assets/audio/',
'extensions' => ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'],
'mimeTypes' => ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/x-m4a', 'audio/mp4', 'audio/aac', 'audio/flac'],
'maxSize' => 50 * 1024 * 1024,
'fallbackName' => 'audio',
],
'video' => [
'type' => 'video',
'label' => 'Video',
'field' => 'media',
'path' => defined('VIDEO_PATH') ? VIDEO_PATH : $root . '/assets/videos/',
'trashPath' => defined('VIDEO_TRASH_PATH') ? VIDEO_TRASH_PATH : $root . '/assets/videos-trash/',
'publicPath' => '../assets/videos/',
'extensions' => ['mp4', 'webm', 'mov', 'm4v'],
'mimeTypes' => ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-m4v'],
'maxSize' => 250 * 1024 * 1024,
'fallbackName' => 'video',
],
'document' => [
'type' => 'document',
'label' => 'Documents',
'field' => 'media',
'path' => defined('DOCUMENTS_PATH') ? DOCUMENTS_PATH : $root . '/assets/documents/',
'trashPath' => defined('DOCUMENTS_TRASH_PATH') ? DOCUMENTS_TRASH_PATH : $root . '/assets/documents-trash/',
'publicPath' => '../assets/documents/',
'extensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'],
'mimeTypes' => [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'application/rtf',
'text/rtf',
],
'maxSize' => 50 * 1024 * 1024,
'fallbackName' => 'document',
],
];
return $type === null ? $configs : ($configs[$type] ?? []);
}
function normalizeMediaType(string $type): string {
return array_key_exists($type, getMediaConfig()) ? $type : '';
}
function validateMediaFilename(string $filename, string $type): bool {
$config = getMediaConfig($type);
if (!$config || $filename === '' || strpos($filename, '\\') !== false || str_starts_with($filename, '/') || str_contains($filename, '..')) {
return false;
}
$segments = explode('/', $filename);
if (count($segments) > 2) {
return false;
}
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($ext, $config['extensions'], true);
}
function validateMediaFolderName(string $folder): bool {
return $folder !== ''
&& strlen($folder) <= 64
&& preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/', $folder)
&& !str_contains($folder, '..')
&& !str_contains($folder, '/')
&& !str_contains($folder, '\\');
}
function listMediaFolders(string $type, bool $trash = false): array {
$config = getMediaConfig($type);
if (!$config) {
return [];
}
$directory = $trash ? $config['trashPath'] : $config['path'];
if (!is_dir($directory)) {
return [];
}
$folders = [];
foreach (scandir($directory) ?: [] as $entry) {
if ($entry === '.' || $entry === '..' || !validateMediaFolderName($entry)) {
continue;
}
if (is_dir($directory . $entry) && !is_link($directory . $entry)) {
$folders[] = $entry;
}
}
natcasesort($folders);
return array_values($folders);
}
function uniqueMediaRelativePath(string $directory, string $relativePath): string {
$relativePath = ltrim(str_replace('\\', '/', $relativePath), '/');
$folder = trim(str_replace('\\', '/', dirname($relativePath)), '.');
$basename = basename($relativePath);
$name = pathinfo($basename, PATHINFO_FILENAME);
$ext = pathinfo($basename, PATHINFO_EXTENSION);
$prefix = $folder !== '' ? $folder . '/' : '';
$targetPath = $prefix . $basename;
$counter = 1;
while (file_exists($directory . $targetPath)) {
$targetPath = $prefix . $name . '-' . $counter . ($ext !== '' ? '.' . $ext : '');
$counter++;
}
return $targetPath;
}
function normalizeRenameExtension(string $extension): string {
$extension = strtolower(trim($extension));
return $extension === 'jpeg' ? 'jpg' : $extension;
}
function validateMediaRenameBasename(string $filename, string $type): bool {
if ($filename === ''
|| strlen($filename) > 180
|| basename($filename) !== $filename
|| strpos($filename, '\\') !== false
|| strpos($filename, '/') !== false
|| str_contains($filename, '..')
|| preg_match('/[\x00-\x1F\x7F]/', $filename)
) {
return false;
}
$name = pathinfo($filename, PATHINFO_FILENAME);
return trim($name) !== '' && validateMediaFilename($filename, $type);
}
function findMediaJsonReferences(string $needle): array {
$needle = trim($needle);
if ($needle === '' || !is_dir(dirname(CONTENT_PATH))) {
return [];
}
$contentRoot = realpath(dirname(CONTENT_PATH));
if ($contentRoot === false) {
return [];
}
$matches = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($contentRoot, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile() || $fileInfo->isLink() || strtolower($fileInfo->getExtension()) !== 'json') {
continue;
}
$path = $fileInfo->getPathname();
$raw = (string)file_get_contents($path);
if ($raw === '' || strpos($raw, $needle) === false) {
continue;
}
$relative = ltrim(str_replace('\\', '/', substr($path, strlen($contentRoot))), '/');
$lines = preg_split('/\R/', $raw);
$lineMatches = [];
foreach ($lines as $index => $line) {
if (strpos($line, $needle) === false) {
continue;
}
$snippet = trim(preg_replace('/\s+/', ' ', $line));
$lineMatches[] = [
'line' => $index + 1,
'snippet' => substr($snippet, 0, 220)
];
if (count($lineMatches) >= 5) {
break;
}
}
$matches[] = [
'file' => $relative,
'matches' => $lineMatches,
'count' => substr_count($raw, $needle)
];
if (count($matches) >= 50) {
break;
}
}
return $matches;
}
function formatAssetSize(int $sizeBytes): string {
if ($sizeBytes >= 1048576) {
return round($sizeBytes / 1048576, 1) . ' MB';
}
if ($sizeBytes >= 1024) {
return round($sizeBytes / 1024, 0) . ' KB';
}
return $sizeBytes . ' B';
}
function sanitizeVisualImageLayout(string $layout): string {
if ($layout === 'split') {
return 'left';
}
return in_array($layout, ['none', 'background', 'left', 'right'], true) ? $layout : 'none';
}
function sanitizeVisualOverlayColor(string $color): string {
$color = trim($color);
if ($color === '') {
return '';
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) {
jsonResponse(false, null, 'Invalid overlay color');
}
return $color;
}
function sanitizeVisualOverlayOpacity($opacity, int $default): int {
if ($opacity === null || $opacity === '') {
return $default;
}
return max(0, min(100, (int)$opacity));
}
function sanitizeAccessSettings(array $settings, array $existing): array {
if (!array_key_exists('access', $settings)) return [];
$access = $settings['access'] ?? [];
if (!is_array($access)) {
return [];
}
$maintenance = $access['maintenance'] ?? [];
if (!is_array($maintenance)) {
return [];
}
$mode = $maintenance['mode'] ?? 'maintenance';
if (!in_array($mode, ['maintenance', 'offline', 'launch'], true)) {
$mode = 'maintenance';
}
$param = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($maintenance['bypassParam'] ?? 'preview'));
if ($param === '') {
$param = 'preview';
}
// Bypass links: several URL parameter / key pairs, stored as entered so
// the dashboard can show and hand them out. Rows without a key are
// dropped. The older single hashed key keeps working until it is
// cleared or replaced by a visible link.
$links = [];
foreach ((array)($maintenance['bypassLinks'] ?? []) as $link) {
if (!is_array($link)) continue;
$linkParam = preg_replace('/[^a-zA-Z0-9_-]/', '', (string)($link['param'] ?? ''));
$linkKey = trim((string)($link['key'] ?? ''));
if ($linkParam === '') $linkParam = 'preview';
if ($linkKey === '' || preg_match('/[\x00-\x1f&=#\s]/', $linkKey)) continue;
$links[] = ['param' => substr($linkParam, 0, 40), 'key' => substr($linkKey, 0, 120)];
if (count($links) >= 20) break;
}
$existingHash = (string)($existing['access']['maintenance']['bypassKeyHash'] ?? '');
$newKey = trim((string)($maintenance['bypassKey'] ?? ''));
$hash = $existingHash;
if ($newKey !== '') {
$hash = password_hash($newKey, PASSWORD_DEFAULT);
} elseif (!empty($maintenance['clearBypassKey'])) {
$hash = '';
}
$until = trim((string)($maintenance['until'] ?? ''));
if ($until !== '' && strtotime($until) === false) {
jsonResponse(false, null, 'Invalid maintenance date');
}
$brandAsset = (string)($maintenance['brandAsset'] ?? 'none');
if (!in_array($brandAsset, ['none', 'favicon', 'logo'], true)) {
$brandAsset = 'none';
}
$imageLayout = sanitizeVisualImageLayout((string)($maintenance['imageLayout'] ?? 'none'));
$overlayColor = sanitizeVisualOverlayColor((string)($maintenance['overlayColor'] ?? ''));
$overlayOpacity = sanitizeVisualOverlayOpacity($maintenance['overlayOpacity'] ?? null, 88);
$image = trim((string)($maintenance['image'] ?? ''));
if ($image !== '' && (
strpos($image, '..') !== false ||
!str_starts_with($image, '/assets/images/') ||
preg_match('#[:\x00]#', $image)
)) {
jsonResponse(false, null, 'Invalid maintenance image path');
}
return [
'maintenance' => [
'enabled' => !empty($maintenance['enabled']),
'mode' => $mode,
'title' => substr(trim((string)($maintenance['title'] ?? '')), 0, 160),
'text' => substr(trim((string)($maintenance['text'] ?? '')), 0, 2000),
'until' => $until,
'showCountdown' => !empty($maintenance['showCountdown']),
'brandAsset' => $brandAsset,
'image' => $image,
'imageLayout' => $imageLayout,
'overlayColor' => $overlayColor,
'overlayOpacity' => $overlayOpacity,
'bypassParam' => substr($param, 0, 40),
'bypassKeyHash' => $hash,
'bypassLinks' => $links,
],
];
}
function sanitizeLoginVisualSettings(array $settings): array {
if (!array_key_exists('login', $settings)) return [];
$login = $settings['login'] ?? [];
if (!is_array($login)) {
return [];
}
$brandAsset = (string)($login['brandAsset'] ?? 'favicon');
if (!in_array($brandAsset, ['none', 'favicon', 'logo'], true)) {
$brandAsset = 'favicon';
}
$imageLayout = sanitizeVisualImageLayout((string)($login['imageLayout'] ?? 'none'));
$overlayColor = sanitizeVisualOverlayColor((string)($login['overlayColor'] ?? ''));
$overlayOpacity = sanitizeVisualOverlayOpacity($login['overlayOpacity'] ?? null, 86);
$boxStyle = (string)($login['boxStyle'] ?? 'card');
if (!in_array($boxStyle, ['card', 'plain'], true)) {
$boxStyle = 'card';
}
$boxColor = sanitizeVisualOverlayColor((string)($login['boxColor'] ?? ''));
$boxTextColor = sanitizeVisualOverlayColor((string)($login['boxTextColor'] ?? ''));
$image = trim((string)($login['image'] ?? ''));
if ($image !== '' && (
strpos($image, '..') !== false ||
!str_starts_with($image, '/assets/images/') ||
preg_match('#[:\x00]#', $image)
)) {
jsonResponse(false, null, 'Invalid login image path');
}
return [
'brandAsset' => $brandAsset,
'image' => $image,
'imageLayout' => $imageLayout,
'overlayColor' => $overlayColor,
'overlayOpacity' => $overlayOpacity,
'boxStyle' => $boxStyle,
'boxColor' => $boxColor,
'boxTextColor' => $boxTextColor,
];
}
function sanitizePrivacySettings(array $settings): array {
if (!array_key_exists('privacy', $settings)) return [];
$privacy = $settings['privacy'] ?? [];
if (!is_array($privacy)) {
return [];
}
return array_intersect_key([
'emailObfuscation' => !empty($privacy['emailObfuscation']),
'analyticsEnabled' => !array_key_exists('analyticsEnabled', $privacy) || !empty($privacy['analyticsEnabled']),
'rememberPublicTheme' => !array_key_exists('rememberPublicTheme', $privacy) || !empty($privacy['rememberPublicTheme']),
], $privacy);
}
function sanitizeModuleSettings(array $settings): array {
if (!array_key_exists('modules', $settings)) return [];
$modules = $settings['modules'] ?? [];
if (!is_array($modules)) {
return [];
}
return array_intersect_key([
'ai' => !array_key_exists('ai', $modules) || !empty($modules['ai']),
'news' => !array_key_exists('news', $modules) || !empty($modules['news']),
'events' => !array_key_exists('events', $modules) || !empty($modules['events']),
'messages' => !array_key_exists('messages', $modules) || !empty($modules['messages']),
'iconManager' => !array_key_exists('iconManager', $modules) || !empty($modules['iconManager']),
], $modules);
}
function sanitizeMediaSettings(array $settings): array {
if (!isset($settings['media']) || !is_array($settings['media'])) {
return [];
}
return array_intersect_key(raijuSanitizeMediaSettings($settings['media']), $settings['media']);
}
function sanitizeDashboardSettings(array $settings): array {
if (!isset($settings['dashboard']) || !is_array($settings['dashboard'])) {
return [];
}
$dashboard = $settings['dashboard'];
$sanitizePageSize = function($value): int {
$value = is_numeric($value) ? (int)$value : 50;
return max(10, min(500, $value));
};
return array_intersect_key([
'itemsPerPage' => $sanitizePageSize($dashboard['itemsPerPage'] ?? 50),
'iconManagerItemsPerPage' => $sanitizePageSize($dashboard['iconManagerItemsPerPage'] ?? 50),
'mediaItemsPerPage' => $sanitizePageSize($dashboard['mediaItemsPerPage'] ?? 25),
], $dashboard);
}
function normalizePageVisibility(array $contentData, ?array $existingData = null): array {
$visibility = $contentData['visibility'] ?? null;
if (!is_array($visibility)) {
unset($contentData['visibility']);
return $contentData;
}
$status = $visibility['status'] ?? 'public';
if (!in_array($status, ['public', 'private'], true)) {
$status = 'public';
}
$normalized = [
'status' => $status,
'title' => substr(trim((string)($visibility['title'] ?? '')), 0, 160),
'text' => substr(trim((string)($visibility['text'] ?? '')), 0, 1000),
];
$password = trim((string)($visibility['password'] ?? ''));
$existingHash = (string)($existingData['visibility']['passwordHash'] ?? ($visibility['passwordHash'] ?? ''));
if ($password !== '') {
$normalized['passwordHash'] = password_hash($password, PASSWORD_DEFAULT);
} elseif ($status === 'private' && $existingHash !== '') {
$normalized['passwordHash'] = $existingHash;
} elseif ($status === 'private') {
jsonResponse(false, null, 'Private pages require a password');
}
if ($status === 'public' && empty($normalized['title']) && empty($normalized['text'])) {
unset($contentData['visibility']);
return $contentData;
}
$contentData['visibility'] = $normalized;
return $contentData;
}
function normalizePageSeo(array $contentData): array {
$seo = $contentData['seo'] ?? [];
if (!is_array($seo)) {
unset($contentData['seo']);
return $contentData;
}
$robots = trim((string)($seo['robots'] ?? 'index, follow'));
if (!in_array($robots, ['index, follow', 'noindex, follow', 'noindex, nofollow'], true)) {
$robots = 'index, follow';
}
$canonical = trim((string)($seo['canonical'] ?? ''));
if ($canonical !== '' && !filter_var($canonical, FILTER_VALIDATE_URL)) {
jsonResponse(false, null, 'Invalid canonical URL');
}
$ogImage = normalizeOgImagePath((string)($seo['ogImage'] ?? ''));
if (!validateOgImagePath($ogImage)) {
jsonResponse(false, null, 'Open Graph image must be a JPG or PNG file from /assets/images/');
}
// Keep additional scalar SEO fields that the page editor does not manage,
// e.g. the sitemap `priority` read by seo-helper.php or site-specific keys
$managed = ['title', 'description', 'answerSummary', 'canonical', 'robots', 'ogTitle', 'ogDescription', 'ogImage', 'sitemap'];
$extra = [];
foreach ($seo as $key => $value) {
if (!is_string($key) || in_array($key, $managed, true) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/', $key)) {
continue;
}
if (is_string($value)) {
$extra[$key] = substr(trim($value), 0, 1000);
} elseif (is_int($value) || is_float($value) || is_bool($value)) {
$extra[$key] = $value;
}
}
$contentData['seo'] = [
'title' => substr(trim((string)($seo['title'] ?? '')), 0, 120),
'description' => substr(trim((string)($seo['description'] ?? '')), 0, 260),
'answerSummary' => substr(trim((string)($seo['answerSummary'] ?? '')), 0, 500),
'canonical' => $canonical,
'robots' => $robots,
'ogTitle' => substr(trim((string)($seo['ogTitle'] ?? '')), 0, 120),
'ogDescription' => substr(trim((string)($seo['ogDescription'] ?? '')), 0, 260),
'ogImage' => $ogImage,
'sitemap' => ($seo['sitemap'] ?? true) !== false,
] + $extra;
return $contentData;
}
function listImageFiles(string $directory, string $publicBasePath): array {
if (!is_dir($directory)) {
return [];
}
$images = [];
$files = scandir($directory);
foreach ($files as $file) {
if ($file === '.' || $file === '..' || !validateImageFilename($file)) {
continue;
}
$path = $directory . $file;
if (!is_file($path)) {
continue;
}
$sizeBytes = filesize($path);
$modified = filemtime($path);
$images[] = [
'name' => $file,
'path' => $publicBasePath . $file,
'sizeBytes' => $sizeBytes,
'size' => formatAssetSize($sizeBytes),
'modified' => $modified,
'dateFormatted' => date('d.m.Y H:i', $modified)
];
}
usort($images, function($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
return $images;
}
function listMediaFiles(string $type, bool $trash = false): array {
$config = getMediaConfig($type);
if (!$config) {
return [];
}
$directory = $trash ? $config['trashPath'] : $config['path'];
if (!is_dir($directory)) {
return [];
}
$media = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile() || $fileInfo->isLink()) {
continue;
}
$path = $fileInfo->getPathname();
$relativePath = ltrim(str_replace('\\', '/', substr($path, strlen($directory))), '/');
if (!validateMediaFilename($relativePath, $type)) {
continue;
}
$sizeBytes = filesize($path);
$modified = filemtime($path);
$folder = trim(str_replace('\\', '/', dirname($relativePath)), '.');
$media[] = [
'type' => $type,
'name' => $relativePath,
'basename' => basename($relativePath),
'folder' => $folder,
'path' => $trash
? 'api.php?action=media-trash-file&type=' . rawurlencode($type) . '&filename=' . rawurlencode($relativePath)
: $config['publicPath'] . $relativePath,
'sizeBytes' => $sizeBytes,
'size' => formatAssetSize($sizeBytes),
'modified' => $modified,
'dateFormatted' => date('d.m.Y H:i', $modified),
'extension' => strtolower(pathinfo($relativePath, PATHINFO_EXTENSION)),
'aiLabel' => raijuMediaAiLabelFor($trash ? $type . '-trash' : $type, $relativePath),
];
if ($type === 'image') {
$entry = &$media[count($media) - 1];
// The uploaded original, when a web version replaced it; and a flag for
// files that are large for a page (the library shows both)
$original = $trash ? null : raijuMediaOriginalFor($relativePath, false);
if ($original !== null) $entry['original'] = $original;
$entry['large'] = $sizeBytes > RAIJU_MEDIA_LARGE_BYTES && in_array($entry['extension'], ['jpg', 'jpeg', 'png', 'webp'], true);
unset($entry);
}
}
usort($media, function($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
return $media;
}
function uniqueMediaFilename(string $directory, string $filename): string {
$targetFilename = $filename;
$counter = 1;
while (file_exists($directory . $targetFilename)) {
$name = pathinfo($filename, PATHINFO_FILENAME);
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$targetFilename = $name . '-' . $counter . ($ext !== '' ? '.' . $ext : '');
$counter++;
}
return $targetFilename;
}
function serveLocalImage(string $path, string $filename): void {
$mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'mp3' => 'audio/mpeg',
'wav' => 'audio/wav',
'ogg' => 'audio/ogg',
'm4a' => 'audio/mp4',
'aac' => 'audio/aac',
'flac' => 'audio/flac',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mov' => 'video/quicktime',
'm4v' => 'video/x-m4v',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'txt' => 'text/plain',
'rtf' => 'application/rtf',
];
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
header_remove('Content-Type');
header('Content-Type: ' . ($mimeTypes[$ext] ?? 'application/octet-stream'));
header('Content-Length: ' . filesize($path));
header('Cache-Control: private, max-age=300');
readfile($path);
exit;
}
function readSiteIconSetRaw() {
$path = getIconSetPath();
if (!is_file($path)) {
return [];
}
$raw = json_decode(file_get_contents($path), true);
return is_array($raw) ? $raw : [];
}
function writeSiteIconSetRaw(array $iconSet) {
$path = getIconSetPath();
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
return false;
}
if (isset($iconSet['_deleted']) && is_array($iconSet['_deleted'])) {
$iconSet['_deleted'] = array_values(array_unique(array_filter($iconSet['_deleted'], function($key) {
return is_string($key) && preg_match('/^[a-zA-Z0-9_-]+$/', $key);
})));
if (empty($iconSet['_deleted'])) {
unset($iconSet['_deleted']);
}
}
ksort($iconSet);
return file_put_contents(
$path,
json_encode($iconSet, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
LOCK_EX
) !== false;
}
function sanitizeIconKeyInput($key) {
$key = trim((string)$key);
if (!preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $key)) {
return '';
}
return $key;
}
function getIconifyAllowedSets() {
return [
'lucide' => [
'label' => 'Lucide',
'license' => 'ISC',
'licenseUrl' => 'https://github.com/lucide-icons/lucide/blob/main/LICENSE',
'defaultWidth' => 24,
'defaultHeight' => 24,
],
'tabler' => [
'label' => 'Tabler Icons',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/tabler/tabler-icons/blob/master/LICENSE',
'defaultWidth' => 24,
'defaultHeight' => 24,
],
'heroicons' => [
'label' => 'Heroicons',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/tailwindlabs/heroicons/blob/master/LICENSE',
'defaultWidth' => 24,
'defaultHeight' => 24,
],
'bi' => [
'label' => 'Bootstrap Icons',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/twbs/icons/blob/main/LICENSE.md',
'style' => 'fill',
'defaultWidth' => 16,
'defaultHeight' => 16,
],
'ph' => [
'label' => 'Phosphor',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/phosphor-icons/core/blob/main/LICENSE',
'defaultWidth' => 256,
'defaultHeight' => 256,
],
'iconoir' => [
'label' => 'Iconoir',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/iconoir-icons/iconoir/blob/main/LICENSE',
'defaultWidth' => 24,
'defaultHeight' => 24,
],
'ion' => [
'label' => 'Ionicons',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/ionic-team/ionicons/blob/main/LICENSE',
'defaultWidth' => 512,
'defaultHeight' => 512,
],
'mynaui' => [
'label' => 'Myna UI',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/MynaUI/icons/blob/main/LICENSE',
'defaultWidth' => 24,
'defaultHeight' => 24,
],
'mingcute' => [
'label' => 'MingCute',
'license' => 'Apache-2.0',
'licenseUrl' => 'https://github.com/mingcute-design/mingcute-icons/blob/main/LICENSE',
'defaultWidth' => 24,
'defaultHeight' => 24,
],
'tdesign' => [
'label' => 'TDesign Icons',
'license' => 'MIT',
'licenseUrl' => 'https://github.com/Tencent/tdesign-icons/blob/main/LICENSE',
'defaultWidth' => 24,
'defaultHeight' => 24,
],
];
}
function fetchIconifyJson($url) {
$context = stream_context_create([
'http' => [
'timeout' => 8,
'header' => "User-Agent: raiju-CMS/1.0\r\nAccept: application/json\r\n",
],
]);
$json = @file_get_contents($url, false, $context);
if ($json === false && function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 8,
CURLOPT_USERAGENT => 'raiju-CMS/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$json = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
unset($ch);
if ($json === false || $status >= 400) {
return null;
}
}
$data = is_string($json) ? json_decode($json, true) : null;
return is_array($data) ? $data : null;
}
function iconifyIconToDefinition($prefix, $name, array $data, array $setInfo) {
$icon = $data['icons'][$name] ?? null;
if (!$icon && isset($data['aliases'][$name]['parent'])) {
$parent = $data['aliases'][$name]['parent'];
$icon = $data['icons'][$parent] ?? null;
}
if (!is_array($icon) || empty($icon['body']) || !is_string($icon['body'])) {
return null;
}
$width = $icon['width'] ?? $data['width'] ?? $setInfo['defaultWidth'] ?? 24;
$height = $icon['height'] ?? $data['height'] ?? $setInfo['defaultHeight'] ?? 24;
$left = $icon['left'] ?? 0;
$top = $icon['top'] ?? 0;
$viewBox = normalizeIconViewBox($left . ' ' . $top . ' ' . $width . ' ' . $height);
$label = ucwords(str_replace(['-', '_'], ' ', $name));
$style = $setInfo['style'] ?? 'stroke';
$normalized = normalizeIconSet([
$prefix . '-' . $name => [
'label' => $label,
'tags' => [$setInfo['label'], $prefix],
'svg' => $icon['body'],
'viewBox' => $viewBox,
'style' => $style,
],
]);
$key = array_key_first($normalized);
if (!$key) {
return null;
}
return [
'key' => $key,
'prefix' => $prefix,
'name' => $name,
'full' => $prefix . ':' . $name,
'label' => $normalized[$key]['label'],
'tags' => $normalized[$key]['tags'],
'svg' => $normalized[$key]['svg'],
'viewBox' => $normalized[$key]['viewBox'],
'style' => $normalized[$key]['style'] ?? $style,
'license' => $setInfo['license'],
'licenseUrl' => $setInfo['licenseUrl'],
'setLabel' => $setInfo['label'],
];
}
function searchIconifyIcons($prefix, $query) {
$allowedSets = getIconifyAllowedSets();
if (!isset($allowedSets[$prefix])) {
return [false, null, 'Icon set is not allowed.'];
}
$query = trim((string)$query);
if (strlen($query) < 2) {
return [false, null, 'Enter at least 2 characters.'];
}
$searchUrl = 'https://api.iconify.design/search?query=' . rawurlencode($query) . '&prefix=' . rawurlencode($prefix) . '&limit=48';
$search = fetchIconifyJson($searchUrl);
if (!$search || empty($search['icons']) || !is_array($search['icons'])) {
return [true, ['icons' => [], 'sets' => $allowedSets], ''];
}
$names = [];
foreach ($search['icons'] as $iconName) {
if (is_string($iconName) && str_starts_with($iconName, $prefix . ':')) {
$names[] = substr($iconName, strlen($prefix) + 1);
}
}
$names = array_slice(array_values(array_unique($names)), 0, 48);
if (!$names) {
return [true, ['icons' => [], 'sets' => $allowedSets], ''];
}
$dataUrl = 'https://api.iconify.design/' . rawurlencode($prefix) . '.json?icons=' . rawurlencode(implode(',', $names));
$data = fetchIconifyJson($dataUrl);
if (!$data) {
return [false, null, 'Could not load icon data from Iconify.'];
}
$icons = [];
foreach ($names as $name) {
$definition = iconifyIconToDefinition($prefix, $name, $data, $allowedSets[$prefix]);
if ($definition) {
$icons[] = $definition;
}
}
return [true, ['icons' => $icons, 'sets' => $allowedSets], ''];
}
function importIconifyIcon($fullName) {
$allowedSets = getIconifyAllowedSets();
$fullName = trim((string)$fullName);
if (!preg_match('/^([a-z0-9-]+):([a-z0-9-]+)$/', $fullName, $m)) {
return [false, null, 'Invalid Iconify icon name.'];
}
$prefix = $m[1];
$name = $m[2];
if (!isset($allowedSets[$prefix])) {
return [false, null, 'Icon set is not allowed.'];
}
$dataUrl = 'https://api.iconify.design/' . rawurlencode($prefix) . '.json?icons=' . rawurlencode($name);
$data = fetchIconifyJson($dataUrl);
if (!$data) {
return [false, null, 'Could not load icon data from Iconify.'];
}
$definition = iconifyIconToDefinition($prefix, $name, $data, $allowedSets[$prefix]);
if (!$definition) {
return [false, null, 'Icon could not be imported.'];
}
$rawIconSet = readSiteIconSetRaw();
$availableKeys = array_column(iconManagerListData()['icons'], 'key');
if (in_array($definition['key'], $availableKeys, true)) {
return [false, null, 'An icon with this key already exists.'];
}
$rawIconSet[$definition['key']] = [
'label' => $definition['label'],
'tags' => array_values(array_unique(array_merge($definition['tags'], ['iconify', $definition['full']]))),
'svg' => $definition['svg'],
'viewBox' => $definition['viewBox'],
'style' => $definition['style'],
'createdAt' => date('c'),
'updatedAt' => date('c'),
'source' => [
'type' => 'iconify',
'icon' => $definition['full'],
'license' => $definition['license'],
'licenseUrl' => $definition['licenseUrl'],
],
];
if (!writeSiteIconSetRaw($rawIconSet)) {
return [false, null, 'Could not write icon set.'];
}
return [true, iconManagerListData(), ''];
}
function normalizeIconManagerPayload($key, $label, $tags, $svg, $viewBox = '') {
$key = sanitizeIconKeyInput($key);
if ($key === '') {
return [false, null, 'Invalid icon key. Use lowercase letters, numbers, hyphens, and underscores.'];
}
$tagsArray = [];
if (is_string($tags) && trim($tags) !== '') {
$tagsArray = array_values(array_filter(array_map('trim', explode(',', $tags)), 'strlen'));
} elseif (is_array($tags)) {
$tagsArray = array_values(array_filter($tags, 'is_string'));
}
$extractedViewBox = extractIconViewBoxFromSvg((string)$svg);
$rawViewBox = trim((string)$viewBox);
$definition = [
'label' => trim((string)$label) ?: ucwords(str_replace(['-', '_'], ' ', $key)),
'tags' => $tagsArray,
'svg' => (string)$svg,
'viewBox' => ($extractedViewBox && ($rawViewBox === '' || normalizeIconViewBox($rawViewBox) === '0 0 24 24'))
? $extractedViewBox
: normalizeIconViewBox($rawViewBox),
];
$normalized = normalizeIconSet([$key => $definition]);
if (empty($normalized[$key])) {
return [false, null, 'SVG code is empty or contains no supported SVG elements.'];
}
return [true, [$key, $normalized[$key]], ''];
}
function iconManagerListData() {
$core = getDefaultIconSet();
$raw = readSiteIconSetRaw();
$custom = normalizeIconSet($raw);
$deleted = getDeletedIconKeys($raw);
$merged = $core;
foreach ($deleted as $deletedKey) {
unset($merged[$deletedKey]);
}
$merged = array_merge($merged, $custom);
if (empty($merged['default'])) {
$fallback = getEmergencyDefaultIconSet();
$merged['default'] = $fallback['default'];
}
$icons = [];
foreach ($merged as $key => $definition) {
$icons[] = [
'key' => $key,
'label' => $definition['label'] ?? ucwords(str_replace(['-', '_'], ' ', $key)),
'tags' => $definition['tags'] ?? [],
'svg' => $definition['svg'] ?? '',
'viewBox' => $definition['viewBox'] ?? '0 0 24 24',
'style' => $definition['style'] ?? 'stroke',
'createdAt' => $definition['createdAt'] ?? '',
'updatedAt' => $definition['updatedAt'] ?? '',
'source' => isset($custom[$key]) ? 'custom' : 'core',
'canDelete' => $key !== 'default',
];
}
usort($icons, function($a, $b) {
return strcasecmp($a['key'], $b['key']);
});
return [
'icons' => $icons,
'path' => getIconSetPath(),
'deleted' => $deleted,
];
}
// Not authenticated?
if (!isAuthenticated()) {
http_response_code(401);
echo json_encode([
'success' => false,
'data' => null,
'message' => 'Session expired',
'session_expired' => true
], JSON_UNESCAPED_UNICODE);
exit;
}
// Build page list by scanning content/pages/*.json
function buildPageSeoHealth($lang, $slug, array $data) {
if (!function_exists('raijuSeoContext') || !function_exists('raijuSeoHealth')) {
return null;
}
return raijuSeoHealth(raijuSeoContext([
'contentPage' => raijuPageContentKey($lang, $slug),
'currentLang' => $lang,
'currentPage' => $slug,
'pageTitle' => $data['title'] ?? '',
'pageDescription' => $data['description'] ?? '',
'data' => $data,
]));
}
/**
* $PAGE_MAPPING of includes/nav-config.php (page key => [lang => URL path]), or [] when the site has none.
* Read in a closure so the variables of that file stay out of the caller's scope.
*/
function raijuLoadPageMapping(): array {
static $mapping = null;
if ($mapping !== null) return $mapping;
$mapping = [];
$file = RAIJU_ADMIN_DIR . '/../includes/nav-config.php';
if (is_file($file)) {
$loaded = (static function (string $file): array {
include $file;
return isset($PAGE_MAPPING) && is_array($PAGE_MAPPING) ? $PAGE_MAPPING : [];
})($file);
$mapping = $loaded;
}
return $mapping;
}
/** The page path ("slug") a URL path of $PAGE_MAPPING stands for in $lang, or null when it is not a usable page path. */
function raijuPagePathFromMapping($path, string $lang, string $defaultLang): ?string {
if (!is_string($path)) return null;
$path = trim(trim($path), '/');
if ($path === '' || $path === '.') return 'home';
if ($lang !== $defaultLang) {
if ($path === $lang) return 'home';
if (str_starts_with($path, $lang . '/')) $path = trim(substr($path, strlen($lang) + 1), '/');
if ($path === '') return 'home';
}
return raijuPageIsValidPath($path) ? $path : null;
}
/**
* The pages of the site, one entry per page with all its language versions.
*
* Versions belong together when $PAGE_MAPPING connects them (different URL paths per language, aliases
* included) or when they share the same path. Every version carries its own slug; that slug, not the one of
* another language, is what editor links, previews, duplicates, the trash and the SEO badges use. A missing
* version has no entry in "languages"; "targetSlugs" names the path the mapping intends for it.
*/
function buildPageList() {
global $SITE_LANGUAGES;
$allLangs = array_keys($SITE_LANGUAGES);
$defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : $allLangs[0];
// Scan filesystem for page JSON files (pattern: {lang}_{slug}.json)
$versions = []; // "lang|slug" => version
$files = glob(CONTENT_PATH . '*.json');
foreach ($files as $file) {
$basename = basename($file, '.json');
// Only match {2-letter-lang}_{slug} pattern
$page = raijuPageParseContentKey($basename);
if ($page === null) {
continue;
}
$lang = $page['lang'];
$slug = $page['path'];
// Only include languages defined in config
if (!isset($SITE_LANGUAGES[$lang])) {
continue;
}
$data = json_decode(file_get_contents($file), true);
$title = $data['title'] ?? ucfirst(str_replace('-', ' ', $slug));
// Use JSON lastModified, fall back to file modification time
$lastModified = $data['lastModified'] ?? date('c', filemtime($file));
$versions[$lang . '|' . $slug] = [
'lang' => $lang,
'slug' => $slug,
'title' => $title,
'lastModified' => $lastModified,
'seoHealth' => buildPageSeoHealth($lang, $slug, is_array($data) ? $data : []),
];
}
// Group the versions: union-find, never joining two versions of one language.
$parent = [];
$langsOf = [];
foreach ($versions as $key => $version) {
$parent[$key] = $key;
$langsOf[$key] = [$version['lang'] => true];
}
$find = static function (string $key) use (&$parent, &$find): string {
return $parent[$key] === $key ? $key : ($parent[$key] = $find($parent[$key]));
};
$union = static function (string $a, string $b) use (&$parent, &$langsOf, $find): void {
$a = $find($a);
$b = $find($b);
if ($a === $b || array_intersect_key($langsOf[$a], $langsOf[$b])) return;
$parent[$b] = $a;
$langsOf[$a] += $langsOf[$b];
};
// 1. Translated paths from $PAGE_MAPPING (aliases of one page simply join the same group).
$mappingEntries = [];
foreach (raijuLoadPageMapping() as $entry) {
if (!is_array($entry)) continue;
$paths = [];
foreach ($entry as $lang => $path) {
$lang = (string)$lang;
if (!isset($SITE_LANGUAGES[$lang])) continue;
$slug = raijuPagePathFromMapping($path, $lang, $defaultLang);
if ($slug !== null) $paths[$lang] = $slug;
}
if (count($paths) < 2) continue;
$mappingEntries[] = $paths;
$members = [];
foreach ($paths as $lang => $slug) {
if (isset($versions[$lang . '|' . $slug])) $members[] = $lang . '|' . $slug;
}
for ($i = 1; $i < count($members); $i++) $union($members[0], $members[$i]);
}
// 2. Pages with the same path in several languages, as before.
$byPath = [];
foreach ($versions as $key => $version) $byPath[$version['slug']][] = $key;
foreach ($byPath as $keys) {
for ($i = 1; $i < count($keys); $i++) $union($keys[0], $keys[$i]);
}
$groups = [];
foreach ($versions as $key => $version) {
$groups[$find($key)][$version['lang']] = $version;
}
$pages = [];
foreach ($groups as $langData) {
// Representative: the default language's version, else the first language that has one.
$first = null;
foreach ($allLangs as $lang) {
if (isset($langData[$lang])) { $first = $langData[$lang]; break; }
}
$representative = $langData[$defaultLang] ?? $first;
$pageInfo = [
'slug' => $representative['slug'],
'title' => $first['title'],
'languages' => [],
];
foreach ($allLangs as $lang) {
if (isset($langData[$lang])) {
$pageInfo['languages'][$lang] = [
'exists' => true,
'slug' => $langData[$lang]['slug'],
'title' => $langData[$lang]['title'],
'lastModified' => $langData[$lang]['lastModified'],
'seoHealth' => $langData[$lang]['seoHealth'],
];
}
}
// Missing versions: the path the mapping intends for them, when it names one that is still free.
$targets = [];
foreach ($mappingEntries as $paths) {
$related = false;
foreach ($langData as $lang => $version) {
if (($paths[$lang] ?? null) === $version['slug']) { $related = true; break; }
}
if (!$related) continue;
foreach ($paths as $lang => $slug) {
if (!isset($langData[$lang]) && !isset($targets[$lang]) && !isset($versions[$lang . '|' . $slug])) $targets[$lang] = $slug;
}
}
if ($targets) $pageInfo['targetSlugs'] = $targets;
$dates = array_filter(array_column($pageInfo['languages'], 'lastModified'));
$pageInfo['lastModified'] = !empty($dates) ? max($dates) : null;
if (isset($pageInfo['languages'][$defaultLang]['title'])) {
$pageInfo['title'] = $pageInfo['languages'][$defaultLang]['title'];
}
$pages[] = $pageInfo;
}
usort($pages, function($a, $b) {
return strcasecmp($a['title'], $b['title']);
});
return [
'pages' => $pages,
'languages' => $SITE_LANGUAGES,
'defaultLang' => $defaultLang,
];
}