Files

1015 lines
29 KiB
PHP

<?php
declare(strict_types=1);
const PROJECT_ROOT = __DIR__ . '/../..';
const WEBSITE_ROOT = PROJECT_ROOT . '/website';
const HTML_PATH = WEBSITE_ROOT . '/ikfreunde.com.html';
const JSON_PATH = WEBSITE_ROOT . '/content/site-content.de.json';
const CREDENTIALS_PATH = WEBSITE_ROOT . '/content/.editor-credentials.json';
const RESET_PATH = WEBSITE_ROOT . '/content/.editor-reset.json';
const RATE_LIMIT_PATH = WEBSITE_ROOT . '/content/.editor-rate-limit.json';
const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12h
const RESET_TTL_SECONDS = 60 * 30; // 30m
const LOGIN_ACCOUNT_MAX_ATTEMPTS = 6;
const LOGIN_GLOBAL_MAX_ATTEMPTS = 20;
const LOGIN_WINDOW_SECONDS = 60 * 10; // 10m
const RESET_REQUEST_ACCOUNT_MAX_ATTEMPTS = 5;
const RESET_REQUEST_GLOBAL_MAX_ATTEMPTS = 15;
const RESET_REQUEST_WINDOW_SECONDS = 60 * 15; // 15m
const RESET_CONFIRM_ACCOUNT_MAX_ATTEMPTS = 5;
const RESET_CONFIRM_GLOBAL_MAX_ATTEMPTS = 15;
const RESET_CONFIRM_WINDOW_SECONDS = 60 * 15; // 15m
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($uri === '/api/health') {
respondJson(['ok' => 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);
}