true]); } if ($uri === '/api/editor/status' && $method === 'GET') { handleEditorStatus(); } if ($uri === '/api/editor/claim' && $method === 'POST') { handleEditorClaim(); } if ($uri === '/api/editor/login' && $method === 'POST') { handleEditorLogin(); } if ($uri === '/api/editor/logout' && $method === 'POST') { handleEditorLogout(); } if ($uri === '/api/editor/reset/request' && $method === 'POST') { handleResetRequest(); } if ($uri === '/api/editor/reset/confirm' && $method === 'POST') { handleResetConfirm(); } if ($uri === '/api/content' && $method === 'GET') { if (!is_file(JSON_PATH)) { respondJson(['error' => 'Content JSON not found.'], 404); } $raw = file_get_contents(JSON_PATH); if ($raw === false) { respondJson(['error' => 'Failed to read content JSON.'], 500); } header('Content-Type: application/json; charset=utf-8'); echo $raw; exit; } if ($uri === '/api/save' && $method === 'POST') { handleSave(); } serveStatic($uri); function handleEditorStatus(): void { $creds = readCredentials(); $token = bearerToken(); $claimed = is_array($creds) && (bool) ($creds['claimed'] ?? false); $authenticated = false; if ($claimed && is_string($token) && $token !== '') { $authenticated = verifyAndTouchSession($creds, $token); if ($authenticated) { writeCredentials($creds); } } respondJson([ 'ok' => true, 'claimed' => $claimed, 'authenticated' => $authenticated, 'owner_email' => $claimed ? (string) ($creds['owner_email'] ?? '') : '', ]); } function handleEditorClaim(): void { $payload = requireJsonBody(); $email = normalizeEmail((string) ($payload['email'] ?? '')); $password = (string) ($payload['password'] ?? ''); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { respondJson(['error' => 'Invalid email.'], 422); } if (strlen($password) < 8) { respondJson(['error' => 'Password must be at least 8 characters.'], 422); } $fp = fopen(CREDENTIALS_PATH, 'c+'); if ($fp === false) { respondJson(['error' => 'Failed to open credentials store.'], 500); } if (!flock($fp, LOCK_EX)) { fclose($fp); respondJson(['error' => 'Failed to lock credentials store.'], 500); } $raw = stream_get_contents($fp); $current = jsonToArray($raw); $alreadyClaimed = is_array($current) && (bool) ($current['claimed'] ?? false); if ($alreadyClaimed) { flock($fp, LOCK_UN); fclose($fp); respondJson(['error' => 'Editor already claimed.'], 409); } $now = gmdate('c'); $token = randomToken(48); $tokenHash = hash('sha256', $token); $creds = [ 'claimed' => true, 'owner_email' => $email, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'created_at' => $now, 'updated_at' => $now, 'sessions' => [ $tokenHash => [ 'created_at' => $now, 'expires_at' => gmdate('c', time() + SESSION_TTL_SECONDS), ], ], ]; ftruncate($fp, 0); rewind($fp); fwrite($fp, toPrettyJson($creds)); fflush($fp); flock($fp, LOCK_UN); fclose($fp); respondJson([ 'ok' => true, 'claimed' => true, 'token' => $token, 'owner_email' => $email, ]); } function handleEditorLogin(): void { $payload = requireJsonBody(); $email = normalizeEmail((string) ($payload['email'] ?? '')); $password = (string) ($payload['password'] ?? ''); $creds = readCredentials(); if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) { respondJson(['error' => 'Editor is not claimed yet.'], 409); } $ownerEmail = normalizeEmail((string) ($creds['owner_email'] ?? '')); $passwordHash = (string) ($creds['password_hash'] ?? ''); $accountSubject = hash('sha256', $email !== '' ? $email : $ownerEmail); $accountRate = consumeRateLimit( 'login_account', $accountSubject, LOGIN_ACCOUNT_MAX_ATTEMPTS, LOGIN_WINDOW_SECONDS ); $globalRate = consumeRateLimit( 'login_global', 'global', LOGIN_GLOBAL_MAX_ATTEMPTS, LOGIN_WINDOW_SECONDS ); if (!$accountRate['allowed'] || !$globalRate['allowed']) { respondRateLimited(max($accountRate['retry_after'], $globalRate['retry_after'])); } $valid = hash_equals($ownerEmail, $email) && $passwordHash !== '' && password_verify($password, $passwordHash); if (!$valid) { respondJson(['error' => 'Invalid credentials.'], 401); } clearRateLimit('login_account', $accountSubject); cleanupExpiredSessions($creds); $token = randomToken(48); $tokenHash = hash('sha256', $token); $now = gmdate('c'); $creds['sessions'][$tokenHash] = [ 'created_at' => $now, 'expires_at' => gmdate('c', time() + SESSION_TTL_SECONDS), ]; $creds['updated_at'] = $now; writeCredentials($creds); respondJson([ 'ok' => true, 'token' => $token, 'owner_email' => $ownerEmail, ]); } function handleEditorLogout(): void { $token = bearerToken(); if (!is_string($token) || $token === '') { respondJson(['ok' => true]); } $creds = readCredentials(); if (!is_array($creds)) { respondJson(['ok' => true]); } $hash = hash('sha256', $token); if (isset($creds['sessions'][$hash])) { unset($creds['sessions'][$hash]); $creds['updated_at'] = gmdate('c'); writeCredentials($creds); } respondJson(['ok' => true]); } function handleResetRequest(): void { $payload = requireJsonBody(); $email = normalizeEmail((string) ($payload['email'] ?? '')); $accountSubject = hash('sha256', $email); $accountRate = consumeRateLimit( 'reset_request_account', $accountSubject, RESET_REQUEST_ACCOUNT_MAX_ATTEMPTS, RESET_REQUEST_WINDOW_SECONDS ); $globalRate = consumeRateLimit( 'reset_request_global', 'global', RESET_REQUEST_GLOBAL_MAX_ATTEMPTS, RESET_REQUEST_WINDOW_SECONDS ); if (!$accountRate['allowed'] || !$globalRate['allowed']) { respondRateLimited(max($accountRate['retry_after'], $globalRate['retry_after'])); } $creds = readCredentials(); if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) { respondJson(['ok' => true, 'message' => 'If the account exists, a reset token has been created.']); } $ownerEmail = normalizeEmail((string) ($creds['owner_email'] ?? '')); if (!filter_var($email, FILTER_VALIDATE_EMAIL) || !hash_equals($ownerEmail, $email)) { respondJson(['ok' => true, 'message' => 'If the account exists, a reset token has been created.']); } $token = randomToken(40); $tokenHash = hash('sha256', $token); $expiresAt = gmdate('c', time() + RESET_TTL_SECONDS); $pathPrefix = routePrefixFromHeader(); $resetUrl = ($pathPrefix !== '' ? $pathPrefix : '') . '/?edit=1&reset_token=' . rawurlencode($token); $resetData = [ 'email' => $ownerEmail, 'token_hash' => $tokenHash, 'token_plain' => $token, 'reset_url' => $resetUrl, 'created_at' => gmdate('c'), 'expires_at' => $expiresAt, ]; file_put_contents(RESET_PATH, toPrettyJson($resetData) . "\n", LOCK_EX); respondJson([ 'ok' => true, 'message' => 'If the account exists, a reset token has been created.', ]); } function handleResetConfirm(): void { $payload = requireJsonBody(); $token = trim((string) ($payload['token'] ?? '')); $newPassword = (string) ($payload['newPassword'] ?? ''); $resetRawForLimit = is_file(RESET_PATH) ? file_get_contents(RESET_PATH) : false; $resetDataForLimit = jsonToArray($resetRawForLimit); $emailForLimit = is_array($resetDataForLimit) ? normalizeEmail((string) ($resetDataForLimit['email'] ?? '')) : ''; $accountSubject = hash('sha256', $emailForLimit !== '' ? $emailForLimit : 'unknown'); $accountRate = consumeRateLimit( 'reset_confirm_account', $accountSubject, RESET_CONFIRM_ACCOUNT_MAX_ATTEMPTS, RESET_CONFIRM_WINDOW_SECONDS ); $globalRate = consumeRateLimit( 'reset_confirm_global', 'global', RESET_CONFIRM_GLOBAL_MAX_ATTEMPTS, RESET_CONFIRM_WINDOW_SECONDS ); if (!$accountRate['allowed'] || !$globalRate['allowed']) { respondRateLimited(max($accountRate['retry_after'], $globalRate['retry_after'])); } if ($token === '') { respondJson(['error' => 'Missing reset token.'], 422); } if (strlen($newPassword) < 8) { respondJson(['error' => 'Password must be at least 8 characters.'], 422); } $resetRaw = is_file(RESET_PATH) ? file_get_contents(RESET_PATH) : false; $resetData = jsonToArray($resetRaw); if (!is_array($resetData)) { respondJson(['error' => 'Reset token not found.'], 404); } $expiresAt = strtotime((string) ($resetData['expires_at'] ?? '')); if ($expiresAt === false || $expiresAt < time()) { @unlink(RESET_PATH); respondJson(['error' => 'Reset token expired.'], 410); } $tokenHash = hash('sha256', $token); $expectedHash = (string) ($resetData['token_hash'] ?? ''); if ($expectedHash === '' || !hash_equals($expectedHash, $tokenHash)) { respondJson(['error' => 'Invalid reset token.'], 401); } $creds = readCredentials(); if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) { respondJson(['error' => 'Editor credentials not found.'], 404); } $creds['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT); $creds['sessions'] = []; $creds['updated_at'] = gmdate('c'); writeCredentials($creds); clearRateLimit('reset_confirm_account', $accountSubject); @unlink(RESET_PATH); respondJson(['ok' => true]); } function handleSave(): void { $creds = requireAuthenticatedEditor(); $body = file_get_contents('php://input'); if ($body === false || trim($body) === '') { respondJson(['error' => 'Missing request body.'], 400); } $payload = json_decode($body, true); if (!is_array($payload)) { respondJson(['error' => 'Invalid JSON body.'], 400); } $textChanges = is_array($payload['textChanges'] ?? null) ? $payload['textChanges'] : []; $imageChanges = is_array($payload['imageChanges'] ?? null) ? $payload['imageChanges'] : []; $dryRun = (bool) ($payload['dryRun'] ?? false); $html = file_get_contents(HTML_PATH); if ($html === false) { respondJson(['error' => 'Failed to read HTML file.'], 500); } $jsonRaw = file_get_contents(JSON_PATH); if ($jsonRaw === false) { respondJson(['error' => 'Failed to read content JSON file.'], 500); } $jsonData = json_decode($jsonRaw, true); if (!is_array($jsonData)) { respondJson(['error' => 'Content JSON is invalid.'], 500); } $dom = new DOMDocument(); libxml_use_internal_errors(true); $loaded = $dom->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET); if (!$loaded) { respondJson(['error' => 'Failed to parse HTML.'], 500); } $xpath = new DOMXPath($dom); $appliedText = applyTextChanges($xpath, $jsonData, $textChanges); $appliedImage = applyImageChanges($xpath, $jsonData, $imageChanges); if (!$dryRun) { backupFile(HTML_PATH); backupFile(JSON_PATH); $savedHtml = $dom->saveHTML(); if ($savedHtml === false) { respondJson(['error' => 'Failed to serialize HTML after changes.'], 500); } $savedJson = json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($savedJson === false) { respondJson(['error' => 'Failed to encode JSON after changes.'], 500); } if (file_put_contents(HTML_PATH, $savedHtml) === false) { respondJson(['error' => 'Failed to save HTML file.'], 500); } if (file_put_contents(JSON_PATH, $savedJson . "\n") === false) { respondJson(['error' => 'Failed to save JSON file.'], 500); } } cleanupExpiredSessions($creds); writeCredentials($creds); respondJson([ 'ok' => true, 'applied' => [ 'text' => $appliedText, 'images' => $appliedImage, ], 'dryRun' => $dryRun, ]); } function requireAuthenticatedEditor(): array { $token = bearerToken(); if (!is_string($token) || $token === '') { respondJson(['error' => 'Missing editor authorization.'], 401); } $creds = readCredentials(); if (!is_array($creds) || !(bool) ($creds['claimed'] ?? false)) { respondJson(['error' => 'Editor is not claimed yet.'], 409); } $ok = verifyAndTouchSession($creds, $token); if (!$ok) { respondJson(['error' => 'Invalid or expired editor session.'], 401); } return $creds; } function verifyAndTouchSession(array &$creds, string $token): bool { cleanupExpiredSessions($creds); $hash = hash('sha256', $token); if (!isset($creds['sessions'][$hash]) || !is_array($creds['sessions'][$hash])) { return false; } $creds['sessions'][$hash]['expires_at'] = gmdate('c', time() + SESSION_TTL_SECONDS); $creds['updated_at'] = gmdate('c'); return true; } function cleanupExpiredSessions(array &$creds): void { if (!is_array($creds['sessions'] ?? null)) { $creds['sessions'] = []; return; } $now = time(); foreach ($creds['sessions'] as $hash => $session) { $exp = strtotime((string) ($session['expires_at'] ?? '')); if ($exp === false || $exp < $now) { unset($creds['sessions'][$hash]); } } } function bearerToken(): ?string { $header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; if (!is_string($header)) { return null; } if (preg_match('/^Bearer\s+(.+)$/i', $header, $m) === 1) { return trim($m[1]); } return null; } function routePrefixFromHeader(): string { $prefix = $_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? ''; if (!is_string($prefix) || $prefix === '' || $prefix === '/') { return ''; } $prefix = '/' . trim($prefix, '/'); return $prefix; } function requireJsonBody(): array { $raw = file_get_contents('php://input'); if ($raw === false || trim($raw) === '') { respondJson(['error' => 'Missing request body.'], 400); } $data = json_decode($raw, true); if (!is_array($data)) { respondJson(['error' => 'Invalid JSON body.'], 400); } return $data; } function readCredentials(): ?array { if (!is_file(CREDENTIALS_PATH)) { return null; } $raw = file_get_contents(CREDENTIALS_PATH); $data = jsonToArray($raw); return is_array($data) ? $data : null; } function writeCredentials(array $data): void { file_put_contents(CREDENTIALS_PATH, toPrettyJson($data) . "\n", LOCK_EX); } function consumeRateLimit(string $bucket, string $subject, int $maxAttempts, int $windowSeconds): array { $subjectKey = $bucket . '|' . $subject; $store = readRateLimitStore(); $now = time(); foreach ($store as $key => $entry) { $start = (int) ($entry['window_start'] ?? 0); if ($start <= 0 || ($start + $windowSeconds) <= $now) { unset($store[$key]); } } if (!isset($store[$subjectKey]) || !is_array($store[$subjectKey])) { $store[$subjectKey] = [ 'window_start' => $now, 'count' => 0, 'window_seconds' => $windowSeconds, ]; } $entry = $store[$subjectKey]; $start = (int) ($entry['window_start'] ?? $now); if (($start + $windowSeconds) <= $now) { $start = $now; $entry['count'] = 0; } $entry['window_start'] = $start; $entry['window_seconds'] = $windowSeconds; $entry['count'] = (int) ($entry['count'] ?? 0) + 1; $store[$subjectKey] = $entry; writeRateLimitStore($store); $allowed = $entry['count'] <= $maxAttempts; $retryAfter = max(1, ($start + $windowSeconds) - $now); return ['allowed' => $allowed, 'retry_after' => $retryAfter]; } function clearRateLimit(string $bucket, string $subject): void { $subjectKey = $bucket . '|' . $subject; $store = readRateLimitStore(); if (isset($store[$subjectKey])) { unset($store[$subjectKey]); writeRateLimitStore($store); } } function readRateLimitStore(): array { if (!is_file(RATE_LIMIT_PATH)) { return []; } $raw = file_get_contents(RATE_LIMIT_PATH); $data = jsonToArray($raw); return is_array($data) ? $data : []; } function writeRateLimitStore(array $store): void { file_put_contents(RATE_LIMIT_PATH, toPrettyJson($store) . "\n", LOCK_EX); } function jsonToArray(string|false $raw): ?array { if (!is_string($raw) || trim($raw) === '') { return null; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : null; } function toPrettyJson(array $data): string { return (string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } function randomToken(int $bytes): string { return bin2hex(random_bytes($bytes)); } function normalizeEmail(string $email): string { return strtolower(trim($email)); } function applyTextChanges(DOMXPath $xpath, array &$jsonData, array $changes): int { $sectionChanges = []; $sharedCommonChanges = []; foreach ($changes as $change) { if (!is_array($change)) { continue; } $scope = (string) ($change['scope'] ?? ''); $key = (string) ($change['key'] ?? ''); $value = normalizeText((string) ($change['value'] ?? '')); if ($scope === 'section') { $section = (string) ($change['section'] ?? ''); if ($section !== '' && $key !== '') { $sectionChanges[$section][$key] = $value; } continue; } if ($scope === 'shared' && $key !== '') { $sharedCommonChanges[$key] = $value; } } foreach ($sectionChanges as $section => $pairs) { foreach ($pairs as $key => $value) { if (isset($jsonData['sections'][$section]['texts'][$key])) { $jsonData['sections'][$section]['texts'][$key] = $value; } } } foreach ($sharedCommonChanges as $key => $value) { if (isset($jsonData['shared']['common_texts'][$key])) { $jsonData['shared']['common_texts'][$key] = $value; } } $applied = 0; foreach (getSectionRoots($xpath) as $section => $root) { if (!$root instanceof DOMNode || !isset($sectionChanges[$section])) { continue; } $applied += applySectionTextToHtml($xpath, $root, $sectionChanges[$section]); } foreach (getSharedRoots($xpath) as $area => $root) { if (!$root instanceof DOMNode) { continue; } $keys = $jsonData['shared'][$area]['text_keys'] ?? []; if (!is_array($keys) || count($keys) === 0) { continue; } $textNodes = extractVisibleTextNodes($xpath, $root); foreach ($textNodes as $index => $node) { if (!$node instanceof DOMText) { continue; } $commonKey = $keys[$index] ?? null; if (!is_string($commonKey) || $commonKey === '') { continue; } if (!isset($sharedCommonChanges[$commonKey])) { continue; } $node->nodeValue = $sharedCommonChanges[$commonKey]; $applied++; } } return $applied; } function applyImageChanges(DOMXPath $xpath, array &$jsonData, array $changes): int { $sectionChanges = []; $sharedChanges = []; foreach ($changes as $change) { if (!is_array($change)) { continue; } $scope = (string) ($change['scope'] ?? ''); $key = (string) ($change['key'] ?? ''); if ($key === '') { continue; } $record = [ 'src' => trim((string) ($change['src'] ?? '')), 'alt' => normalizeText((string) ($change['alt'] ?? '')), ]; if ($scope === 'section') { $section = (string) ($change['section'] ?? ''); if ($section !== '') { $sectionChanges[$section][$key] = $record; } continue; } if ($scope === 'shared') { $area = (string) ($change['area'] ?? ''); if ($area !== '') { $sharedChanges[$area][$key] = $record; } } } foreach ($sectionChanges as $section => $pairs) { foreach ($pairs as $key => $record) { if (isset($jsonData['sections'][$section]['images'][$key])) { if ($record['src'] !== '') { $jsonData['sections'][$section]['images'][$key]['src'] = $record['src']; } $jsonData['sections'][$section]['images'][$key]['alt'] = $record['alt']; } } } foreach ($sharedChanges as $area => $pairs) { foreach ($pairs as $key => $record) { if (isset($jsonData['shared'][$area]['images'][$key])) { if ($record['src'] !== '') { $jsonData['shared'][$area]['images'][$key]['src'] = $record['src']; } $jsonData['shared'][$area]['images'][$key]['alt'] = $record['alt']; } } } $applied = 0; foreach (getSectionRoots($xpath) as $section => $root) { if (!$root instanceof DOMNode || !isset($sectionChanges[$section])) { continue; } $applied += applyImagePairsToRoot($xpath, $root, $sectionChanges[$section]); } foreach (getSharedRoots($xpath) as $area => $root) { if (!$root instanceof DOMNode || !isset($sharedChanges[$area])) { continue; } $applied += applyImagePairsToRoot($xpath, $root, $sharedChanges[$area]); } return $applied; } function applySectionTextToHtml(DOMXPath $xpath, DOMNode $root, array $changes): int { $applied = 0; $counters = []; $nodes = extractVisibleTextNodes($xpath, $root); foreach ($nodes as $node) { if (!$node instanceof DOMText) { continue; } $parent = $node->parentNode; if (!$parent instanceof DOMElement) { continue; } $tag = strtolower($parent->tagName); $counters[$tag] = ($counters[$tag] ?? 0) + 1; $key = $tag . '_' . str_pad((string) $counters[$tag], 3, '0', STR_PAD_LEFT); if (!array_key_exists($key, $changes)) { continue; } $node->nodeValue = $changes[$key]; $applied++; } return $applied; } function applyImagePairsToRoot(DOMXPath $xpath, DOMNode $root, array $pairs): int { $applied = 0; $images = $xpath->query('.//img[@src]', $root); if (!$images instanceof DOMNodeList) { return 0; } $index = 1; foreach ($images as $imageNode) { if (!$imageNode instanceof DOMElement) { continue; } $key = 'img_' . str_pad((string) $index, 3, '0', STR_PAD_LEFT); $index++; if (!isset($pairs[$key])) { continue; } $record = $pairs[$key]; $src = trim((string) ($record['src'] ?? '')); if ($src !== '') { $imageNode->setAttribute('src', $src); } $imageNode->setAttribute('alt', normalizeText((string) ($record['alt'] ?? ''))); $applied++; } return $applied; } function getSectionRoots(DOMXPath $xpath): array { $queries = [ 'hero' => "//main//section[contains(@class, 'module-hero-teaser')]", 'page_header' => "//main//section[contains(@class, 'page-header')]", 'projects' => "//main//section[contains(@class, 'module-projects-teaser')]", 'services' => "//main//section[.//*[contains(@class, 'services-teaser__content')]]", 'team' => "(//main//section[contains(@class, 'text-image')])[1]", 'awards' => "//main//section[.//*[contains(@class, 'awards-teaser__content')]]", 'contact' => "//main//section[contains(@class, 'contact-teaser')]", 'clients' => "//main//section[.//*[contains(@class, 'clients-teaser__content')]]", 'partners' => "(//main//section[contains(@class, 'text-image')])[2]", ]; $roots = []; foreach ($queries as $key => $query) { $roots[$key] = firstNode($xpath, $query); } return $roots; } function getSharedRoots(DOMXPath $xpath): array { return [ 'navigation' => firstNode($xpath, "//header[contains(@class, 'page-head')]"), 'cookie_layer' => firstNode($xpath, "//*[@id='cookie-layer']"), 'footer' => firstNode($xpath, "//footer[contains(@class, 'site-footer')]"), ]; } function extractVisibleTextNodes(DOMXPath $xpath, DOMNode $root): array { $result = []; $nodes = $xpath->query('.//text()', $root); if (!$nodes instanceof DOMNodeList) { return $result; } foreach ($nodes as $node) { if (!$node instanceof DOMText) { continue; } if (normalizeText($node->wholeText) === '') { continue; } if (!isVisibleTextNode($node)) { continue; } $result[] = $node; } return $result; } function isVisibleTextNode(DOMText $textNode): bool { $skipTags = ['script', 'style', 'noscript', 'template', 'svg', 'path', 'defs']; $node = $textNode->parentNode; while ($node instanceof DOMNode) { if ($node instanceof DOMElement) { $tag = strtolower($node->tagName); if (in_array($tag, $skipTags, true)) { return false; } } $node = $node->parentNode; } return true; } function normalizeText(string $value): string { $value = str_replace(["\r", "\n", "\t"], ' ', $value); $value = preg_replace('/\s+/u', ' ', $value) ?? $value; return trim($value); } function firstNode(DOMXPath $xpath, string $query): ?DOMNode { $nodes = $xpath->query($query); if (!$nodes instanceof DOMNodeList || $nodes->length === 0) { return null; } $node = $nodes->item(0); return $node instanceof DOMNode ? $node : null; } function backupFile(string $path): void { if (!is_file($path)) { return; } @copy($path, $path . '.bak'); } function serveStatic(string $uri): void { $cleanPath = ltrim($uri, '/'); if ($cleanPath === '') { $cleanPath = 'index.html'; } // Never expose internal editor auth/reset files via HTTP. $basename = basename($cleanPath); if (str_starts_with($basename, '.editor-')) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo "Not Found"; exit; } $resolved = realpath(WEBSITE_ROOT . '/' . $cleanPath); $root = realpath(WEBSITE_ROOT); if ($resolved === false || $root === false || !str_starts_with($resolved, $root) || !is_file($resolved)) { http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo "Not Found"; exit; } $ext = strtolower(pathinfo($resolved, PATHINFO_EXTENSION)); $mime = match ($ext) { 'html', 'htm' => 'text/html; charset=utf-8', 'js' => 'application/javascript; charset=utf-8', 'css' => 'text/css; charset=utf-8', 'json' => 'application/json; charset=utf-8', 'svg' => 'image/svg+xml', 'jpg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', default => 'application/octet-stream', }; header('Content-Type: ' . $mime); readfile($resolved); exit; } function respondJson(array $data, int $status = 200): void { http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } function respondRateLimited(int $retryAfter): void { header('Retry-After: ' . max(1, $retryAfter)); respondJson(['error' => 'Too many attempts. Try again later.'], 429); }