551 lines
16 KiB
PHP
551 lines
16 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
use App\App;
|
|
|
|
function app(): App
|
|
{
|
|
return App::get();
|
|
}
|
|
|
|
function t(string $key, $default = '', array $vars = []): string
|
|
{
|
|
return app()->i18n()->get($key, $default, $vars);
|
|
}
|
|
|
|
function current_client_id(): string
|
|
{
|
|
$session = app()->session();
|
|
$session->start();
|
|
return $session->ensureClientId();
|
|
}
|
|
|
|
function user_theme(): string
|
|
{
|
|
$pdo = app()->basePdo();
|
|
if (!$pdo) {
|
|
return 'light';
|
|
}
|
|
|
|
$clientId = current_client_id();
|
|
$stmt = $pdo->prepare("SELECT theme FROM nexus_user_prefs WHERE client_id = :id LIMIT 1");
|
|
$stmt->execute(['id' => $clientId]);
|
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
$theme = is_array($row) ? (string)($row['theme'] ?? '') : '';
|
|
return match ($theme) {
|
|
'dark', 'night' => 'night',
|
|
'light', 'day', '' => 'day',
|
|
default => $theme,
|
|
};
|
|
}
|
|
|
|
function set_user_theme(string $theme): void
|
|
{
|
|
$pdo = app()->basePdo();
|
|
if (!$pdo) {
|
|
return;
|
|
}
|
|
|
|
$clientId = current_client_id();
|
|
$stmt = $pdo->prepare(
|
|
"INSERT INTO nexus_user_prefs (client_id, theme, updated_at)
|
|
VALUES (:id, :theme, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(client_id) DO UPDATE SET
|
|
theme = excluded.theme,
|
|
updated_at = CURRENT_TIMESTAMP"
|
|
);
|
|
$stmt->execute(['id' => $clientId, 'theme' => $theme]);
|
|
}
|
|
|
|
function current_module_name(): ?string
|
|
{
|
|
$path = app()->request()->path();
|
|
if (preg_match('~^/module/([a-zA-Z0-9_-]+)~', $path, $m)) {
|
|
return $m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function auth_enabled(): bool
|
|
{
|
|
return app()->auth()->isEnabled();
|
|
}
|
|
|
|
function auth_user(): ?array
|
|
{
|
|
return app()->auth()->user();
|
|
}
|
|
|
|
function auth_display_name(): string
|
|
{
|
|
$user = auth_user();
|
|
if (!$user) {
|
|
return '';
|
|
}
|
|
$name = trim((string)($user['name'] ?? ''));
|
|
if ($name !== '') {
|
|
return $name;
|
|
}
|
|
$email = trim((string)($user['email'] ?? ''));
|
|
return $email;
|
|
}
|
|
|
|
function auth_initials(): string
|
|
{
|
|
$name = auth_display_name();
|
|
if ($name === '') {
|
|
return 'U';
|
|
}
|
|
$parts = preg_split('/\s+/', $name) ?: [];
|
|
$letters = '';
|
|
foreach ($parts as $p) {
|
|
$p = trim($p);
|
|
if ($p !== '') {
|
|
$letters .= mb_strtoupper(mb_substr($p, 0, 1));
|
|
}
|
|
if (mb_strlen($letters) >= 2) {
|
|
break;
|
|
}
|
|
}
|
|
return $letters !== '' ? $letters : 'U';
|
|
}
|
|
|
|
function auth_groups(): array
|
|
{
|
|
$user = auth_user();
|
|
return is_array($user['groups'] ?? null) ? $user['groups'] : [];
|
|
}
|
|
|
|
function parse_group_list(string $value): array
|
|
{
|
|
$parts = preg_split('/[,\s]+/', $value) ?: [];
|
|
$out = [];
|
|
foreach ($parts as $p) {
|
|
$p = trim($p);
|
|
if ($p !== '') {
|
|
$out[] = $p;
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
function auth_is_admin(): bool
|
|
{
|
|
$config = app()->config();
|
|
$groups = auth_groups();
|
|
$allowed = parse_group_list($config->oidcAdminGroup);
|
|
foreach ($allowed as $g) {
|
|
if (in_array($g, $groups, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function auth_is_user(): bool
|
|
{
|
|
$config = app()->config();
|
|
$groups = auth_groups();
|
|
$admins = parse_group_list($config->oidcAdminGroup);
|
|
foreach ($admins as $g) {
|
|
if (in_array($g, $groups, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$users = parse_group_list($config->oidcUserGroup);
|
|
foreach ($users as $g) {
|
|
if (in_array($g, $groups, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function require_auth(): void
|
|
{
|
|
if (!auth_enabled()) {
|
|
return;
|
|
}
|
|
if (auth_user()) {
|
|
return;
|
|
}
|
|
redirect('/auth/login');
|
|
}
|
|
|
|
function require_admin(): void
|
|
{
|
|
require_auth();
|
|
if (!auth_is_admin()) {
|
|
http_response_code(403);
|
|
echo '<div class="card">Keine Berechtigung.</div>';
|
|
exit;
|
|
}
|
|
}
|
|
|
|
function modules(): \App\ModuleManager
|
|
{
|
|
return app()->modules();
|
|
}
|
|
|
|
function module_fn(string $module, string $name, mixed ...$args): mixed
|
|
{
|
|
return modules()->call($module, $name, ...$args);
|
|
}
|
|
|
|
/**
|
|
* Lädt ein Template-Partial.
|
|
*
|
|
* @param string $name Dateiname ohne .php
|
|
* @param string $folder Unterordner in /partials/
|
|
* @param array $data Daten, die im Template verfügbar sein sollen
|
|
*/
|
|
function tpl(string $name, string $folder = 'landingpages', array $data = []): void
|
|
{
|
|
$base = __DIR__ . '/../../partials/';
|
|
|
|
foreach ([$name, $folder] as $value) {
|
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $value)) {
|
|
echo "<!-- tpl(): invalid parameter -->";
|
|
return;
|
|
}
|
|
}
|
|
|
|
$paths = [];
|
|
if ($folder === 'landingpages') {
|
|
$paths[] = $base . 'landingpages/' . $name . '.php';
|
|
} else {
|
|
$paths[] = $base . 'structure/' . $name . '.php';
|
|
}
|
|
|
|
$path = null;
|
|
foreach ($paths as $candidate) {
|
|
if (file_exists($candidate)) {
|
|
$path = $candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($path === null) {
|
|
echo "<!-- Template not found: {$folder}/{$name} -->";
|
|
return;
|
|
}
|
|
|
|
extract($data);
|
|
require $path;
|
|
}
|
|
|
|
function module_tpl(string $module, string $name, array $data = []): void
|
|
{
|
|
$base = __DIR__ . '/../../modules/' . $module . '/partials/';
|
|
foreach ([$module, $name] as $value) {
|
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $value)) {
|
|
echo "<!-- module_tpl(): invalid parameter -->";
|
|
return;
|
|
}
|
|
}
|
|
$path = $base . $name . '.php';
|
|
if (file_exists($path)) {
|
|
extract($data);
|
|
require $path;
|
|
} else {
|
|
echo "<!-- module_tpl(): not found: {$module}/{$name} -->";
|
|
}
|
|
}
|
|
|
|
function module_design(string $module): array
|
|
{
|
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
|
|
return [];
|
|
}
|
|
|
|
static $cache = [];
|
|
if (array_key_exists($module, $cache)) {
|
|
return $cache[$module];
|
|
}
|
|
|
|
$path = __DIR__ . '/../../modules/' . $module . '/design.json';
|
|
if (!is_file($path)) {
|
|
$cache[$module] = [];
|
|
return $cache[$module];
|
|
}
|
|
|
|
$raw = file_get_contents($path);
|
|
$decoded = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null;
|
|
$cache[$module] = is_array($decoded) ? $decoded : [];
|
|
return $cache[$module];
|
|
}
|
|
|
|
function module_debug_entries(string $module): array
|
|
{
|
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
|
|
return [];
|
|
}
|
|
|
|
app()->session()->start();
|
|
$entries = $_SESSION['module_debug'][$module] ?? [];
|
|
return is_array($entries) ? array_values(array_filter($entries, 'is_array')) : [];
|
|
}
|
|
|
|
function module_debug_push(string $module, array $entry): void
|
|
{
|
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
|
|
return;
|
|
}
|
|
|
|
app()->session()->start();
|
|
if (!isset($_SESSION['module_debug']) || !is_array($_SESSION['module_debug'])) {
|
|
$_SESSION['module_debug'] = [];
|
|
}
|
|
if (!isset($_SESSION['module_debug'][$module]) || !is_array($_SESSION['module_debug'][$module])) {
|
|
$_SESSION['module_debug'][$module] = [];
|
|
}
|
|
|
|
$entry['at'] = $entry['at'] ?? date('Y-m-d H:i:s');
|
|
array_unshift($_SESSION['module_debug'][$module], $entry);
|
|
$_SESSION['module_debug'][$module] = array_slice($_SESSION['module_debug'][$module], 0, 25);
|
|
}
|
|
|
|
function module_debug_clear(string $module): void
|
|
{
|
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
|
|
return;
|
|
}
|
|
|
|
app()->session()->start();
|
|
unset($_SESSION['module_debug'][$module]);
|
|
}
|
|
|
|
function module_shell_header(string $module, array $options = []): string
|
|
{
|
|
$design = module_design($module);
|
|
$requestPath = app()->request()->path();
|
|
$title = trim((string) ($options['title'] ?? ''));
|
|
$description = trim((string) ($options['description'] ?? ''));
|
|
$eyebrow = trim((string) ($options['eyebrow'] ?? $design['eyebrow'] ?? 'Modul'));
|
|
$actions = is_array($options['actions'] ?? null) ? $options['actions'] : (is_array($design['actions'] ?? null) ? $design['actions'] : []);
|
|
$tabs = is_array($options['tabs'] ?? null) ? $options['tabs'] : (is_array($design['tabs'] ?? null) ? $design['tabs'] : []);
|
|
|
|
$renderActions = static function (array $actions): string {
|
|
$html = '';
|
|
foreach ($actions as $action) {
|
|
if (!is_array($action)) {
|
|
continue;
|
|
}
|
|
$label = trim((string) ($action['label'] ?? ''));
|
|
$href = trim((string) ($action['href'] ?? ''));
|
|
if ($label === '' || $href === '') {
|
|
continue;
|
|
}
|
|
$variant = trim((string) ($action['variant'] ?? 'secondary'));
|
|
$size = trim((string) ($action['size'] ?? 'sm'));
|
|
$class = $variant === 'ghost' ? 'module-button module-button--ghost' : 'module-button module-button--secondary';
|
|
if ($size === 'sm') {
|
|
$class .= ' module-button--small';
|
|
}
|
|
$html .= '<a class="' . e($class) . '" href="' . e($href) . '">' . e($label) . '</a>';
|
|
}
|
|
return $html;
|
|
};
|
|
|
|
$html = '<div class="module-shell"><div class="module-page-bg"><div class="module-page-stack">';
|
|
$html .= '<header class="module-hero">';
|
|
|
|
if ($tabs !== [] || $actions !== []) {
|
|
$html .= '<div class="module-hero-top module-hero-top--compact">';
|
|
if ($tabs !== []) {
|
|
$html .= '<nav class="module-tabs" aria-label="Modulnavigation">';
|
|
foreach ($tabs as $tab) {
|
|
if (!is_array($tab)) {
|
|
continue;
|
|
}
|
|
$label = trim((string) ($tab['label'] ?? ''));
|
|
$href = trim((string) ($tab['href'] ?? ''));
|
|
if ($label === '' || $href === '') {
|
|
continue;
|
|
}
|
|
$matchPrefixes = is_array($tab['match_prefixes'] ?? null) ? $tab['match_prefixes'] : [];
|
|
$isActive = !empty($tab['active']) || $href === $requestPath;
|
|
if (!$isActive) {
|
|
foreach ($matchPrefixes as $prefix) {
|
|
$prefix = is_string($prefix) ? trim($prefix) : '';
|
|
if ($prefix !== '' && str_starts_with($requestPath, $prefix)) {
|
|
$isActive = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$class = $isActive ? 'module-button module-button--tab-active' : 'module-button module-button--tab';
|
|
$html .= '<a class="' . e($class) . '" href="' . e($href) . '">' . e($label) . '</a>';
|
|
}
|
|
$html .= '</nav>';
|
|
}
|
|
if ($actions !== []) {
|
|
$html .= '<div class="module-hero-actions">' . $renderActions($actions) . '</div>';
|
|
}
|
|
$html .= '</div>';
|
|
} elseif ($title !== '' || $description !== '' || !empty($options['show_eyebrow'])) {
|
|
$html .= '<div class="module-hero-copy">';
|
|
if (!empty($options['show_eyebrow'])) {
|
|
$html .= '<div class="eyebrow">' . e($eyebrow) . '</div>';
|
|
}
|
|
if ($title !== '') {
|
|
$html .= '<h1 class="module-title">' . e($title) . '</h1>';
|
|
}
|
|
if ($description !== '') {
|
|
$html .= '<p class="module-lead">' . e($description) . '</p>';
|
|
}
|
|
$html .= '</div>';
|
|
}
|
|
|
|
if ($title !== '') {
|
|
$moduleTitle = trim((string) ($design['title'] ?? ucfirst($module)));
|
|
$selector = '.home-hero[data-module-name="' . $module . '"] .brand-copy h1';
|
|
$script = '(function(){'
|
|
. 'var root=document.querySelector(' . json_encode($selector, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ');'
|
|
. 'if(!root){return;}'
|
|
. 'var old=root.querySelector(".module-page-context");'
|
|
. 'if(old){old.remove();}'
|
|
. 'root.textContent=' . json_encode($moduleTitle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ';'
|
|
. 'var pageTitle=' . json_encode($title, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ';'
|
|
. 'if(pageTitle&&pageTitle!==' . json_encode($moduleTitle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '){'
|
|
. 'var span=document.createElement("span");'
|
|
. 'span.className="module-page-context";'
|
|
. 'span.textContent=" / "+pageTitle;'
|
|
. 'root.appendChild(span);'
|
|
. '}'
|
|
. '})();';
|
|
$html .= '<script>' . $script . '</script>';
|
|
}
|
|
|
|
$html .= '</header>';
|
|
|
|
return $html;
|
|
}
|
|
|
|
function module_shell_footer(): string
|
|
{
|
|
$html = '';
|
|
$module = current_module_name();
|
|
if (is_string($module) && $module !== '') {
|
|
if ((string) ($_GET['module_debug_clear'] ?? '') === '1') {
|
|
module_debug_clear($module);
|
|
}
|
|
|
|
$entries = module_debug_entries($module);
|
|
$currentPath = app()->request()->path();
|
|
$clearHref = $currentPath . '?module_debug_clear=1';
|
|
|
|
$html .= '<section class="module-debug">';
|
|
$html .= '<details class="module-debug-details"' . ($entries !== [] ? ' open' : '') . '>';
|
|
$html .= '<summary class="module-debug-summary">';
|
|
$html .= '<span>Debug</span>';
|
|
$html .= '<span class="module-debug-meta">' . e((string) count($entries)) . ' Eintraege</span>';
|
|
$html .= '</summary>';
|
|
$html .= '<div class="module-debug-body">';
|
|
$html .= '<div class="module-debug-toolbar">';
|
|
$html .= '<div class="muted">Standard-Debugbereich des Moduls. Zeigt die letzten Request-/Response-Daten der aktuellen Sitzung.</div>';
|
|
$html .= '<a class="module-button module-button--ghost module-button--small" href="' . e($clearHref) . '">Debug leeren</a>';
|
|
$html .= '</div>';
|
|
|
|
if ($entries === []) {
|
|
$html .= '<div class="module-debug-empty">Noch keine Debug-Daten vorhanden.</div>';
|
|
} else {
|
|
foreach ($entries as $index => $entry) {
|
|
$label = trim((string) ($entry['label'] ?? ('Eintrag ' . ($index + 1))));
|
|
$at = trim((string) ($entry['at'] ?? ''));
|
|
$payload = json_encode($entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if (!is_string($payload)) {
|
|
$payload = '{}';
|
|
}
|
|
|
|
$html .= '<details class="module-debug-entry"' . ($index === 0 ? ' open' : '') . '>';
|
|
$html .= '<summary><strong>' . e($label) . '</strong>' . ($at !== '' ? '<span class="module-debug-entry-time">' . e($at) . '</span>' : '') . '</summary>';
|
|
$html .= '<pre class="module-debug-pre">' . e($payload) . '</pre>';
|
|
$html .= '</details>';
|
|
}
|
|
}
|
|
|
|
$html .= '</div>';
|
|
$html .= '</details>';
|
|
$html .= '</section>';
|
|
}
|
|
|
|
return $html . '</div></div></div>';
|
|
}
|
|
|
|
/**
|
|
* HTML Escaping Helper.
|
|
*/
|
|
function e(?string $string): string
|
|
{
|
|
return htmlspecialchars($string ?? '', ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
function app_primary_domain(): string
|
|
{
|
|
if (defined('APP_DOMAIN_PRIMARY')) {
|
|
return APP_DOMAIN_PRIMARY;
|
|
}
|
|
if (defined('APP_DOMAIN_NAME')) {
|
|
return APP_DOMAIN_NAME;
|
|
}
|
|
return $_SERVER['HTTP_HOST'] ?? '';
|
|
}
|
|
|
|
function app_fakecheck_domain(): string
|
|
{
|
|
if (defined('APP_DOMAIN_FAKECHECK')) {
|
|
return APP_DOMAIN_FAKECHECK;
|
|
}
|
|
return app_primary_domain();
|
|
}
|
|
|
|
function asset_styles(): void
|
|
{
|
|
$styles = app()->assets()->styles();
|
|
|
|
$order = ['early' => 0, 'normal' => 1, 'late' => 2];
|
|
usort($styles, fn($a, $b) => ($order[$a['priority']] ?? 1) <=> ($order[$b['priority']] ?? 1));
|
|
|
|
foreach ($styles as $s) {
|
|
$href = $s['href'];
|
|
$v = $s['version'];
|
|
if ($v !== null && $v !== '') {
|
|
$sep = (str_contains($href, '?') ? '&' : '?');
|
|
$href = $href . $sep . 'v=' . rawurlencode((string)$v);
|
|
}
|
|
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES) . '">' . "\n";
|
|
}
|
|
}
|
|
|
|
function asset_scripts(string $pos = 'footer'): void
|
|
{
|
|
$scripts = ($pos === 'header') ? app()->assets()->headerScripts() : app()->assets()->footerScripts();
|
|
|
|
foreach ($scripts as $s) {
|
|
$src = $s['src'];
|
|
$v = $s['version'];
|
|
if ($v !== null && $v !== '') {
|
|
$sep = (str_contains($src, '?') ? '&' : '?');
|
|
$src = $src . $sep . 'v=' . rawurlencode((string)$v);
|
|
}
|
|
|
|
$attrs = '';
|
|
if (!empty($s['defer'])) {
|
|
$attrs .= ' defer';
|
|
}
|
|
if (!empty($s['async'])) {
|
|
$attrs .= ' async';
|
|
}
|
|
|
|
echo '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"' . $attrs . '></script>' . "\n";
|
|
}
|
|
}
|
|
|
|
function redirect(string $path): void
|
|
{
|
|
header('Location: ' . $path, true, 303);
|
|
exit;
|
|
}
|