1014 lines
29 KiB
PHP
1014 lines
29 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
const PROJECT_ROOT = __DIR__ . '/..';
|
|
const HTML_PATH = PROJECT_ROOT . '/ikfreunde.com.html';
|
|
const JSON_PATH = PROJECT_ROOT . '/content/site-content.de.json';
|
|
const CREDENTIALS_PATH = PROJECT_ROOT . '/content/.editor-credentials.json';
|
|
const RESET_PATH = PROJECT_ROOT . '/content/.editor-reset.json';
|
|
const RATE_LIMIT_PATH = PROJECT_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(PROJECT_ROOT . '/' . $cleanPath);
|
|
$root = realpath(PROJECT_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);
|
|
}
|