Raiju/includes/access-guard.php
2026-10-25 06:05:42 +03:00

389 lines
17 KiB
PHP

<?php
/**
* Central frontend access controls for maintenance mode and private pages.
*/
require_once __DIR__ . '/session-helper.php';
function raijuAccessStartSession(): void {
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
raijuSessionStart();
}
}
function raijuAccessHasSessionCookie(): bool {
$name = session_name();
return $name !== '' && !empty($_COOKIE[$name]);
}
function raijuAccessStartExistingSession(): bool {
if (session_status() === PHP_SESSION_ACTIVE) {
return true;
}
if (!raijuAccessHasSessionCookie() || headers_sent()) {
return false;
}
raijuSessionStart();
return session_status() === PHP_SESSION_ACTIVE;
}
function raijuAccessSiteRoot(): string {
return dirname(__DIR__);
}
function raijuAccessSettings(): array {
$path = raijuAccessSiteRoot() . '/content/settings.json';
if (!is_file($path)) {
return [];
}
$settings = json_decode((string)file_get_contents($path), true);
return is_array($settings) ? $settings : [];
}
function raijuAccessIsLoggedIn(): bool {
if (!raijuAccessStartExistingSession()) {
return false;
}
return raijuSessionValidate();
}
function raijuAccessCurrentPath(): string {
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
return '/' . ltrim((string)$path, '/');
}
function raijuAccessIsBackendOrAssetPath(string $path): bool {
return preg_match('#^/(admin|api|assets|css|js)(/|$)#', $path) === 1
|| preg_match('#^/(favicon\.ico|robots\.txt|sitemap\.xml)$#', $path) === 1;
}
function raijuAccessVerifySecret(string $secret, string $hash): bool {
$secret = trim($secret);
$hash = trim($hash);
if ($secret === '' || $hash === '') {
return false;
}
if (str_starts_with($hash, '$2y$') || str_starts_with($hash, '$argon2')) {
return password_verify($secret, $hash);
}
return hash_equals($hash, hash('sha256', $secret));
}
function raijuAccessMatchBypassLink(array $maintenance): bool {
foreach ((array)($maintenance['bypassLinks'] ?? []) as $link) {
if (!is_array($link)) continue;
$linkParam = trim((string)($link['param'] ?? ''));
$linkKey = (string)($link['key'] ?? '');
if ($linkParam === '' || $linkKey === '' || !array_key_exists($linkParam, $_GET)) continue;
if (hash_equals($linkKey, (string)$_GET[$linkParam])) return true;
}
return false;
}
function raijuAccessCheckMaintenanceBypass(array $maintenance): bool {
$param = trim((string)($maintenance['bypassParam'] ?? ''));
$hasParam = $param !== '' && array_key_exists($param, $_GET);
foreach ((array)($maintenance['bypassLinks'] ?? []) as $link) {
$linkParam = is_array($link) ? trim((string)($link['param'] ?? '')) : '';
if ($linkParam !== '' && array_key_exists($linkParam, $_GET)) $hasParam = true;
}
if (!$hasParam && !raijuAccessStartExistingSession()) {
return false;
}
if ($hasParam) {
raijuAccessStartSession();
}
if (!empty($_SESSION['raiju_maintenance_bypass'])) {
return true;
}
$hash = trim((string)($maintenance['bypassKeyHash'] ?? ''));
if (!$hasParam) {
return false;
}
if (!raijuAccessMatchBypassLink($maintenance)) {
$candidate = ($param !== '' && array_key_exists($param, $_GET)) ? (string)$_GET[$param] : '';
if (!raijuAccessVerifySecret($candidate, $hash)) {
return false;
}
}
$_SESSION['raiju_maintenance_bypass'] = true;
return true;
}
function raijuAccessMaintenanceIsActive(array $maintenance): bool {
if (empty($maintenance['enabled'])) {
return false;
}
$until = trim((string)($maintenance['until'] ?? ''));
if ($until === '') {
return true;
}
$timestamp = strtotime($until);
return $timestamp === false || $timestamp > time();
}
function raijuAccessAssetPath(string $path): string {
$path = trim($path);
if ($path === '') {
return '';
}
if (preg_match('#^https?://#i', $path)) {
return '';
}
return '/' . ltrim($path, '/');
}
function raijuAccessResolveBrandAsset(array $settings, string $asset): string {
if ($asset === 'favicon') {
return raijuAccessAssetPath((string)($settings['favicon'] ?? '/assets/images/favicon.svg'));
}
if ($asset === 'logo') {
$logo = (string)($settings['branding']['logo'] ?? '');
return raijuAccessAssetPath($logo !== '' ? $logo : (string)($settings['favicon'] ?? '/assets/images/favicon.svg'));
}
return '';
}
function raijuAccessCssUrl(string $path): string {
return str_replace(["\\", "\"", "\n", "\r"], ["\\\\", "\\\"", "", ""], $path);
}
function raijuAccessImageLayout(string $layout): string {
if ($layout === 'split') {
return 'left';
}
return in_array($layout, ['none', 'background', 'left', 'right'], true) ? $layout : 'none';
}
function raijuAccessRgbaFromHex(string $hex, int $opacity): string {
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) {
return '';
}
$opacity = max(0, min(100, $opacity));
$red = hexdec(substr($hex, 1, 2));
$green = hexdec(substr($hex, 3, 2));
$blue = hexdec(substr($hex, 5, 2));
$alpha = rtrim(rtrim(sprintf('%.2F', $opacity / 100), '0'), '.');
return "rgba($red, $green, $blue, $alpha)";
}
function raijuAccessRenderStandalonePage(array $options): void {
$status = (int)($options['status'] ?? 503);
http_response_code($status);
if ($status === 503 && !empty($options['retryAfter'])) {
header('Retry-After: ' . (int)$options['retryAfter']);
}
header('Content-Type: text/html; charset=utf-8');
header('X-Robots-Tag: noindex, nofollow', false);
$title = htmlspecialchars((string)($options['title'] ?? 'Temporarily unavailable'), ENT_QUOTES, 'UTF-8');
$text = nl2br(htmlspecialchars((string)($options['text'] ?? ''), ENT_QUOTES, 'UTF-8'));
$mode = htmlspecialchars((string)($options['mode'] ?? 'maintenance'), ENT_QUOTES, 'UTF-8');
$until = htmlspecialchars((string)($options['until'] ?? ''), ENT_QUOTES, 'UTF-8');
$showCountdown = !empty($options['showCountdown']) && $until !== '';
$brandAsset = raijuAccessAssetPath((string)($options['brandAssetPath'] ?? ''));
$image = raijuAccessAssetPath((string)($options['image'] ?? ''));
$imageLayout = $image !== '' ? raijuAccessImageLayout((string)($options['imageLayout'] ?? 'none')) : 'none';
$bodyClass = 'rj-lock-page';
if ($imageLayout === 'background') {
$bodyClass .= ' rj-lock-page--background';
} elseif ($imageLayout === 'left') {
$bodyClass .= ' rj-lock-page--split rj-lock-page--image-left';
} elseif ($imageLayout === 'right') {
$bodyClass .= ' rj-lock-page--split rj-lock-page--image-right';
}
$cssImage = raijuAccessCssUrl($image);
$overlayColor = trim((string)($options['overlayColor'] ?? ''));
$overlayOpacity = (int)($options['overlayOpacity'] ?? 88);
$overlayRgba = raijuAccessRgbaFromHex($overlayColor, $overlayOpacity);
$overlayStyle = $overlayColor !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $overlayColor)
? '; --rj-lock-overlay-color: ' . htmlspecialchars($overlayRgba, ENT_QUOTES, 'UTF-8')
: '';
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title><?php echo $title; ?></title>
<script>
(function() {
try {
var theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.style.colorScheme = 'light';
}
})();
</script>
<style>
:root { color-scheme: light; --rj-bg: #f7f7f4; --rj-fg: #141414; --rj-muted: #646464; --rj-border: rgba(20,20,20,.14); --rj-error: #b42318; }
:root[data-theme="dark"] { color-scheme: dark; --rj-bg: #101010; --rj-fg: #f4f4ef; --rj-muted: #b3b3aa; --rj-border: rgba(255,255,255,.18); --rj-error: #ff8a80; }
:root[data-theme="light"] { color-scheme: light; --rj-bg: #f7f7f4; --rj-fg: #141414; --rj-muted: #646464; --rj-border: rgba(20,20,20,.14); --rj-error: #b42318; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--rj-bg); color: var(--rj-fg); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body.rj-lock-page--background { background: linear-gradient(var(--rj-lock-overlay-color, color-mix(in srgb, #f7f7f4 88%, transparent)), var(--rj-lock-overlay-color, color-mix(in srgb, #f7f7f4 88%, transparent))), var(--rj-lock-image), var(--rj-bg); background-position: center; background-size: cover; }
:root[data-theme="dark"] body.rj-lock-page--background { background: linear-gradient(var(--rj-lock-overlay-color, color-mix(in srgb, #101010 82%, transparent)), var(--rj-lock-overlay-color, color-mix(in srgb, #101010 82%, transparent))), var(--rj-lock-image), var(--rj-bg); background-position: center; background-size: cover; }
main { width: min(680px, calc(100vw - 40px)); padding: 56px 0; }
.rj-lock-shell { width: 100%; min-height: 100vh; display: grid; place-items: center; }
.rj-lock-page--split .rj-lock-shell { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.rj-lock-media { display: none; width: 100%; height: 100%; min-height: 100vh; background-image: var(--rj-lock-image); background-position: center; background-size: cover; }
.rj-lock-page--split .rj-lock-media { display: block; }
.rj-lock-page--image-right .rj-lock-media { grid-column: 2; }
.rj-lock-page--image-right main { grid-column: 1; grid-row: 1; }
.rj-lock-page--split main { width: min(560px, calc(100% - 64px)); justify-self: center; }
.rj-lock-brand { display: block; width: auto; max-width: 168px; max-height: 72px; margin: 0 0 26px; object-fit: contain; }
.rj-lock-label { display: inline-flex; margin-bottom: 24px; padding: 6px 10px; border: 1px solid var(--rj-border); border-radius: 999px; color: var(--rj-muted); font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
h1 { margin: 0 0 18px; font-size: clamp(34px, 6vw, 68px); line-height: .95; letter-spacing: 0; }
p { margin: 0; color: var(--rj-muted); font-size: clamp(17px, 2vw, 21px); line-height: 1.55; }
.rj-countdown { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 32px; }
.rj-countdown span { min-width: 82px; padding: 14px 16px; border: 1px solid var(--rj-border); border-radius: 8px; text-align: center; font-size: 24px; font-weight: 700; }
.rj-countdown small { display: block; margin-top: 4px; color: var(--rj-muted); font-size: 11px; font-weight: 500; text-transform: uppercase; }
form { margin-top: 28px; display: grid; gap: 12px; }
input { width: 100%; min-height: 46px; padding: 0 14px; border: 1px solid var(--rj-border); border-radius: 6px; background: transparent; color: inherit; font: inherit; }
button { min-height: 46px; border: 0; border-radius: 6px; background: var(--rj-fg); color: var(--rj-bg); font: inherit; font-weight: 700; cursor: pointer; }
.rj-error { margin-top: 12px; color: var(--rj-error); font-size: 14px; }
@media (max-width: 760px) {
.rj-lock-page--split .rj-lock-shell { grid-template-columns: 1fr; }
.rj-lock-page--split .rj-lock-media { min-height: 34vh; }
.rj-lock-page--split main { width: min(680px, calc(100vw - 40px)); }
}
</style>
</head>
<body class="<?php echo htmlspecialchars($bodyClass, ENT_QUOTES, 'UTF-8'); ?>" data-raiju-lock="<?php echo $mode; ?>"<?php echo $imageLayout !== 'none' ? ' style="--rj-lock-image: url(&quot;' . htmlspecialchars($cssImage, ENT_QUOTES, 'UTF-8') . '&quot;)' . $overlayStyle . '"' : ''; ?>>
<div class="rj-lock-shell">
<?php if (in_array($imageLayout, ['left', 'right'], true)): ?><div class="rj-lock-media" aria-hidden="true"></div><?php endif; ?>
<main>
<?php if ($brandAsset !== ''): ?><img class="rj-lock-brand" src="<?php echo htmlspecialchars($brandAsset, ENT_QUOTES, 'UTF-8'); ?>" alt=""><?php endif; ?>
<div class="rj-lock-label"><?php echo $mode === 'launch' ? 'Launch' : ($mode === 'private' ? 'Privat' : 'Wartung'); ?></div>
<h1><?php echo $title; ?></h1>
<?php if ($text): ?><p><?php echo $text; ?></p><?php endif; ?>
<?php if (!empty($options['form'])): ?>
<form method="post">
<input type="password" name="raiju_page_password" placeholder="Passwort" autocomplete="current-password" required>
<button type="submit">Freischalten</button>
</form>
<?php if (!empty($options['error'])): ?><div class="rj-error"><?php echo htmlspecialchars((string)$options['error'], ENT_QUOTES, 'UTF-8'); ?></div><?php endif; ?>
<?php endif; ?>
<?php if ($showCountdown): ?>
<div class="rj-countdown" data-countdown-until="<?php echo $until; ?>" aria-live="polite"></div>
<script>
(function(){var el=document.querySelector('[data-countdown-until]');if(!el)return;var target=new Date(el.dataset.countdownUntil).getTime();function tick(){var d=Math.max(0,target-Date.now());var s=Math.floor(d/1000),days=Math.floor(s/86400);s-=days*86400;var h=Math.floor(s/3600);s-=h*3600;var m=Math.floor(s/60);s-=m*60;el.innerHTML='<span>'+days+'<small>Tage</small></span><span>'+h+'<small>Std</small></span><span>'+m+'<small>Min</small></span><span>'+s+'<small>Sek</small></span>';if(d>0)setTimeout(tick,1000);}tick();})();
</script>
<?php endif; ?>
</main>
</div>
</body>
</html>
<?php
exit;
}
function raijuAccessEnforceMaintenance(): void {
$path = raijuAccessCurrentPath();
if (raijuAccessIsBackendOrAssetPath($path)) {
return;
}
if (raijuAccessIsLoggedIn()) {
return;
}
$settings = raijuAccessSettings();
$maintenance = $settings['access']['maintenance'] ?? [];
if (!is_array($maintenance) || !raijuAccessMaintenanceIsActive($maintenance)) {
return;
}
if (raijuAccessCheckMaintenanceBypass($maintenance)) {
return;
}
$until = trim((string)($maintenance['until'] ?? ''));
$retryAfter = 0;
if ($until !== '') {
$untilTs = strtotime($until);
if ($untilTs !== false && $untilTs > time()) {
$retryAfter = min(86400, max(60, $untilTs - time()));
}
}
raijuAccessRenderStandalonePage([
'status' => 503,
'mode' => $maintenance['mode'] ?? 'maintenance',
'title' => $maintenance['title'] ?? 'Wartungsarbeiten',
'text' => $maintenance['text'] ?? 'Wir sind in Kürze wieder online.',
'until' => $until,
'showCountdown' => !empty($maintenance['showCountdown']),
'brandAssetPath' => raijuAccessResolveBrandAsset($settings, (string)($maintenance['brandAsset'] ?? 'none')),
'image' => $maintenance['image'] ?? '',
'imageLayout' => $maintenance['imageLayout'] ?? 'none',
'overlayColor' => $maintenance['overlayColor'] ?? '',
'overlayOpacity' => $maintenance['overlayOpacity'] ?? 88,
'retryAfter' => $retryAfter,
]);
}
function raijuAccessPageIsPrivate(array $pageData): bool {
$visibility = $pageData['visibility'] ?? [];
return is_array($visibility) && (($visibility['status'] ?? 'public') === 'private');
}
function raijuAccessEnforcePage(string $contentPage, array $pageData): void {
if (!raijuAccessPageIsPrivate($pageData) || raijuAccessIsLoggedIn()) {
return;
}
raijuAccessStartSession();
$sessionKey = 'raiju_private_page_' . hash('sha256', $contentPage);
$visibility = $pageData['visibility'];
$passwordHash = (string)($visibility['passwordHash'] ?? '');
$grant = (string)($_SESSION[$sessionKey] ?? '');
if ($passwordHash !== '' && hash_equals(hash('sha256', $passwordHash), $grant)) {
return;
}
$error = '';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && isset($_POST['raiju_page_password'])) {
$password = (string)$_POST['raiju_page_password'];
$hash = (string)($visibility['passwordHash'] ?? '');
if ($hash !== '' && password_verify($password, $hash)) {
$_SESSION[$sessionKey] = hash('sha256', $hash);
header('Location: ' . ($_SERVER['REQUEST_URI'] ?? '/'));
exit;
}
$error = 'Das Passwort ist nicht korrekt.';
}
raijuAccessRenderStandalonePage([
'status' => 403,
'mode' => 'private',
'title' => $visibility['title'] ?? 'Geschützte Seite',
'text' => $visibility['text'] ?? 'Bitte gib das Passwort ein, um diese Seite zu öffnen.',
'form' => true,
'error' => $error,
]);
}
function raijuAccessEnforceCurrentTemplatePage(?string $contentPage): void {
require_once __DIR__ . '/page-path.php';
if (!$contentPage || !raijuPageIsValidContentKey($contentPage)) {
return;
}
$path = raijuAccessSiteRoot() . '/content/pages/' . $contentPage . '.json';
if (!is_file($path)) {
return;
}
$data = json_decode((string)file_get_contents($path), true);
if (!is_array($data)) {
return;
}
raijuAccessEnforcePage($contentPage, $data);
}