389 lines
17 KiB
PHP
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("' . htmlspecialchars($cssImage, ENT_QUOTES, 'UTF-8') . '")' . $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);
|
|
}
|