deploy
All checks were successful
Deploy / deploy-staging (push) Successful in 24s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-20 01:50:07 +02:00
parent 3f81f7a670
commit 2cdd14c400
210 changed files with 55853 additions and 36 deletions

View File

@@ -0,0 +1,398 @@
<?php
declare(strict_types=1);
namespace App;
final class AccountPages
{
public static function register(App $app): array
{
$flash = $app->flash()->get();
$isLoggedIn = isset($_SESSION['user_id']);
$error = '';
$displayName = '';
$email = '';
if ($isLoggedIn) {
redirect('/dashboard');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$displayName = trim((string)($_POST['display_name'] ?? ''));
$email = trim((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
$password2 = (string)($_POST['password_confirm'] ?? '');
if ($password !== $password2) {
$error = 'Passwörter stimmen nicht überein.';
} elseif (strlen($password) < 8) {
$error = 'Passwort muss mindestens 8 Zeichen haben.';
} else {
try {
$auth = new Auth($app);
$userId = $auth->register($displayName, $email, $password);
$code = $auth->createVerifyCode($userId, $email);
$mailer = new Mailer($app);
$mailer->sendTemplate('registration_confirm', $email, [
'code' => $code,
'display_name' => $displayName,
]);
$_SESSION['verify_email'] = $email;
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
redirect('/verify');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
}
return compact('flash', 'error', 'displayName', 'email');
}
public static function login(App $app): array
{
$flash = $app->flash()->get();
$isLoggedIn = isset($_SESSION['user_id']);
$error = '';
$emailPrefill = '';
if ($isLoggedIn) {
redirect('/dashboard');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim((string)($_POST['email'] ?? ''));
$emailPrefill = $email;
$password = (string)($_POST['password'] ?? '');
try {
$auth = new Auth($app);
$res = $auth->login($email, $password);
if ($res['status'] === 'pending') {
$code = $auth->createVerifyCode($res['id'], $email);
$mailer = new Mailer($app);
$mailer->sendTemplate('registration_confirm', $email, [
'code' => $code,
'display_name' => $email,
]);
$_SESSION['verify_email'] = $email;
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
redirect('/verify');
}
$_SESSION['user_id'] = $res['id'];
$app->flash()->set('success', 'Erfolgreich angemeldet.');
redirect('/dashboard');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
return compact('flash', 'error', 'emailPrefill', 'isLoggedIn');
}
public static function verify(App $app): array
{
$pdo = $app->pdo();
$flash = $app->flash()->get();
$error = '';
$info = '';
$email = $_SESSION['verify_email'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'verify';
$email = trim((string)($_POST['email'] ?? ''));
$code = strtoupper(trim((string)($_POST['code'] ?? '')));
$auth = new Auth($app);
$mailer = new Mailer($app);
if ($action === 'resend') {
try {
$stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
$stmt?->execute(['email' => $email]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
throw new \RuntimeException('E-Mail nicht gefunden.');
}
$userId = (int)$row['id'];
$codeNew = $auth->createVerifyCode($userId, $email);
$mailer->sendTemplate('registration_resend_code', $email, [
'code' => $codeNew,
'display_name' => $row['display_name'] ?? '',
]);
$info = 'Neuer Code wurde versendet.';
$_SESSION['verify_email'] = $email;
} catch (\Throwable $e) {
$error = $e->getMessage();
}
} else {
try {
$userId = $auth->verifyCode($email, $code);
$_SESSION['user_id'] = $userId;
unset($_SESSION['verify_email']);
$mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]);
$app->flash()->set('success', 'Registrierung bestätigt. Willkommen!');
redirect('/dashboard');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
}
return compact('flash', 'error', 'info', 'email');
}
public static function dashboard(App $app): array
{
if (!isset($_SESSION['user_id'])) {
redirect('/login');
}
$pdo = $app->pdo();
$flash = $app->flash()->get();
$userId = (int)$_SESSION['user_id'];
$error = '';
$info = '';
$crypto = null;
try { $crypto = new Crypto($app->config()); } catch (\Throwable) {}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
try {
if ($action === 'profile') {
$languages = $_POST['languages'] ?? '';
if (is_array($languages)) {
$languages = implode(', ', array_map('trim', $languages));
}
$phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']);
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id');
$stmt?->execute([
'name' => trim((string)$_POST['display_name']),
'fname' => trim((string)$_POST['first_name']),
'lname' => trim((string)$_POST['last_name']),
'zip' => trim((string)$_POST['zip']),
'city' => trim((string)$_POST['city']),
'prof' => trim((string)$_POST['profession']),
'langs' => trim((string)$languages),
'about' => trim((string)$_POST['about']),
'phone' => $phoneEnc,
'id' => $userId,
]);
$info = 'Profil gespeichert.';
} elseif ($action === 'child_add') {
$firstNameEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['first_name'])) : trim((string)$_POST['first_name']);
$noteEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['note'])) : trim((string)$_POST['note']);
$stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())');
$stmt?->execute([
'uid' => $userId,
'gender' => $_POST['gender'] ?? 'unknown',
'birthdate' => $_POST['birthdate'] ?: null,
'age' => $_POST['age_years'] ?: null,
'name' => $firstNameEnc,
'note' => $noteEnc,
]);
$info = 'Kind hinzugefügt.';
} elseif ($action === 'event_add' || $action === 'event_update') {
$street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$needsGeocode = ($lat === null || $lng === null || $region === '');
if ($needsGeocode) {
[$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region);
if ($lat === null) { $lat = $geoLat; }
if ($lng === null) { $lng = $geoLng; }
if ($region === '' && $geoRegion) { $region = $geoRegion; }
}
if ($action === 'event_add') {
$stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())');
$stmt?->execute([
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => trim((string)$_POST['teaser']),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'vis' => $_POST['visibility'] ?? 'public',
'status' => 'published',
]);
$info = 'Event gespeichert.';
// Punkte für Event-Erstellung vergeben
try {
$cfgPath = dirname(__DIR__, 2) . '/config/community.php';
$communityCfg = file_exists($cfgPath) ? require $cfgPath : [];
$community = new Community($pdo, $communityCfg);
$community->addPoints($userId, 'event', 'create', ['event_id' => $pdo?->lastInsertId()]);
} catch (\Throwable) {
// still continue, points optional
}
} else {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid');
$stmt?->execute([
'id' => $eventId,
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => trim((string)$_POST['teaser']),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'vis' => $_POST['visibility'] ?? 'public',
]);
$info = 'Event aktualisiert.';
}
} elseif ($action === 'event_delete') {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1');
$stmt?->execute(['id' => $eventId]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) {
throw new \RuntimeException('Event nicht gefunden.');
}
if ((int)$ev['participant_count'] > 0) {
throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.');
}
$pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]);
$info = 'Event gelöscht.';
} elseif ($action === 'event_cancel') {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1');
$stmt?->execute(['id' => $eventId]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) {
throw new \RuntimeException('Event nicht gefunden.');
}
$pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([
'st' => 'cancelled',
'id' => $eventId,
]);
$info = 'Event wurde abgesagt.';
}
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
// Daten laden
$profile = [
'display_name' => '',
'first_name' => '',
'last_name' => '',
'zip' => '',
'city' => '',
'profession' => '',
'languages' => '',
'about' => '',
'email' => '',
'contact_phone' => '',
];
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
$stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) {
$profile = array_merge($profile, array_filter($row, fn($v) => $v !== null));
if ($crypto && !empty($profile['contact_phone'])) {
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
}
}
$children = [];
$stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC');
$stmt?->execute(['id' => $userId]);
$childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
foreach ($childrenRaw as $c) {
if ($crypto) {
$c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: '';
$c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: '';
}
$children[] = $c;
}
$eventsUpcoming = [];
$eventsPast = [];
$editEvent = null;
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e
WHERE e.created_by = :id AND e.starts_at >= NOW()
ORDER BY e.starts_at ASC'
);
$stmt?->execute(['id' => $userId]);
$eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e
WHERE e.created_by = :id AND e.starts_at < NOW()
ORDER BY e.starts_at DESC'
);
$stmt?->execute(['id' => $userId]);
$eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if (isset($_GET['edit_event'])) {
$editId = (int)$_GET['edit_event'];
$stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1');
$stmt?->execute(['id' => $editId, 'uid' => $userId]);
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
}
return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent');
}
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array
{
$parts = array_filter([
$street ?: null,
$zip ?: null,
$city ?: null,
$region ?: null,
]);
if (!$parts) {
return [null, null, null];
}
$query = implode(', ', $parts);
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
'format' => 'jsonv2',
'limit' => 1,
'q' => $query,
]);
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "User-Agent: nexus/1.0\r\nAccept-Language: de\r\n",
'timeout' => 6,
],
]);
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) {
return [null, null, null];
}
$json = json_decode($resp, true);
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
return [null, null, null];
}
$addr = $json[0]['address'] ?? [];
$regionGuess = $addr['city_district'] ?? $addr['suburb'] ?? $addr['state'] ?? $addr['county'] ?? $addr['region'] ?? $addr['state_district'] ?? null;
return [round((float)$json[0]['lat'], 7), round((float)$json[0]['lon'], 7), $regionGuess];
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App;
final class App
{
private static ?self $instance = null;
private Request $request;
private SessionManager $session;
private Assets $assets;
private I18n $i18n;
private Flash $flash;
private ?\PDO $pdo;
private ?\PDO $basePdo;
private ModuleManager $modules;
private AuthService $auth;
private NexusDashboardService $dashboards;
private function __construct(private Config $config)
{
$this->request = new Request();
$this->session = new SessionManager($config);
$this->assets = new Assets($config);
$this->i18n = new I18n($config, 'en');
$this->flash = new Flash($this->session);
$this->pdo = Database::createPdo($config);
$this->basePdo = Database::createBasePdo($config);
$modulesPath = $this->config->modulesPath;
if ($modulesPath === '' || !is_dir($modulesPath)) {
$candidates = [
__DIR__ . '/../../modules',
__DIR__ . '/../modules',
dirname(__DIR__, 3) . '/modules',
];
foreach ($candidates as $candidate) {
if (is_dir($candidate)) {
$modulesPath = $candidate;
break;
}
}
}
$this->modules = new ModuleManager($this->basePdo, $modulesPath);
$this->modules->bootEnabled();
$this->auth = new AuthService($this);
$this->dashboards = new NexusDashboardService($this->basePdo);
}
public static function init(Config $config): self
{
if (self::$instance === null) {
self::$instance = new self($config);
}
return self::$instance;
}
public static function get(): self
{
if (self::$instance === null) {
throw new \RuntimeException('App not initialized. Call App::init() in bootstrap.');
}
return self::$instance;
}
public function config(): Config { return $this->config; }
public function request(): Request { return $this->request; }
public function session(): SessionManager { return $this->session; }
public function assets(): Assets { return $this->assets; }
public function i18n(): I18n { return $this->i18n; }
public function flash(): Flash { return $this->flash; }
public function pdo(): ?\PDO { return $this->pdo; }
public function basePdo(): ?\PDO { return $this->basePdo; }
public function modules(): ModuleManager { return $this->modules; }
public function auth(): AuthService { return $this->auth; }
public function dashboards(): NexusDashboardService { return $this->dashboards; }
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App;
final class Assets
{
private array $styles = [];
private array $scriptsHeader = [];
private array $scriptsFooter = [];
public function __construct(private Config $config) {}
public function addStyle(string $href, string $priority = 'normal', ?string $version = null): void
{
$version ??= $this->config->assetVersion;
$this->styles[] = [
'href' => $href,
'priority' => $priority,
'version' => $version,
];
}
public function addScript(string $src, string $pos = 'footer', bool $defer = true, bool $async = false, ?string $version = null): void
{
$version ??= $this->config->assetVersion;
$row = [
'src' => $src,
'defer' => $defer,
'async' => $async,
'version' => $version,
];
if ($pos === 'header') {
$this->scriptsHeader[] = $row;
} else {
$this->scriptsFooter[] = $row;
}
}
public function styles(): array { return $this->styles; }
public function headerScripts(): array { return $this->scriptsHeader; }
public function footerScripts(): array { return $this->scriptsFooter; }
public function versioned(string $path): string
{
return $path . '?v=' . rawurlencode($this->config->assetVersion);
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App;
final class Auth
{
public function __construct(private App $app) {}
private function pdo(): \PDO
{
$pdo = $this->app->pdo();
if (!$pdo) {
throw new \RuntimeException('Database connection not available.');
}
return $pdo;
}
public function register(string $displayName, string $email, string $password): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$displayName = trim($displayName);
if ($displayName === '' || $email === '' || $password === '') {
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
}
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
if ($stmt->fetchColumn()) {
throw new \RuntimeException('E-Mail ist bereits registriert.');
}
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())');
$stmt->execute([
'email' => $email,
'pw' => $hash,
'status' => 'pending',
]);
$userId = (int)$pdo->lastInsertId();
$stmt = $pdo->prepare('INSERT INTO user_profiles (user_id, display_name, share_level, children_visibility, created_at, updated_at) VALUES (:uid, :name, :share, :childvis, NOW(), NOW())');
$stmt->execute([
'uid' => $userId,
'name' => $displayName,
'share' => 'basic',
'childvis' => 'hidden',
]);
$pdo->commit();
return $userId;
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
public function createVerifyCode(int $userId, string $email): string
{
$pdo = $this->pdo();
$code = $this->generateCode(6);
$hash = hash('sha256', $code);
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'verify']);
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 48 HOUR), NOW())');
$stmt->execute([
'uid' => $userId,
'type' => 'verify',
'code' => $code,
'hash' => $hash,
]);
return $code;
}
public function verifyCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'verify', 'email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
}
$userId = (int)$row['id'];
$tid = (int)$row['tid'];
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
$pdo->prepare('UPDATE users SET status = :st, email_verified_at = NOW() WHERE id = :id')->execute(['st' => 'active', 'id' => $userId]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
return $userId;
}
public function createResetCode(string $email): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
throw new \RuntimeException('E-Mail ist nicht registriert.');
}
$userId = (int)$row['id'];
$displayName = (string)($row['display_name'] ?? $email);
$code = $this->generateCode(6);
$hash = hash('sha256', $code);
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'reset']);
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 2 HOUR), NOW())');
$stmt->execute([
'uid' => $userId,
'type' => 'reset',
'code' => $code,
'hash' => $hash,
]);
return ['user_id' => $userId, 'code' => $code, 'display_name' => $displayName];
}
public function verifyResetCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'reset', 'email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
}
$userId = (int)$row['id'];
$tid = (int)$row['tid'];
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
return $userId;
}
public function resetPassword(int $userId, string $password): void
{
$pdo = $this->pdo();
if ($password === '' || strlen($password) < 8) {
throw new \InvalidArgumentException('Passwort muss mindestens 8 Zeichen haben.');
}
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare('UPDATE users SET password_hash = :pw, status = :status, updated_at = NOW() WHERE id = :id');
$stmt->execute([
'pw' => $hash,
'status' => 'active',
'id' => $userId,
]);
}
private function generateCode(int $len = 6): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$out = '';
for ($i = 0; $i < $len; $i++) {
$out .= $chars[random_int(0, strlen($chars) - 1)];
}
return $out;
}
public function login(string $email, string $password): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
}
if (!password_verify($password, (string)$row['password_hash'])) {
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
}
$userId = (int)$row['id'];
$status = (string)$row['status'];
if ($status === 'active') {
$upd = $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id');
$upd->execute(['id' => $userId]);
}
return ['id' => $userId, 'status' => $status];
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App;
final class AuthService
{
private const SESSION_TTL = 604800;
public function __construct(private App $app) {}
public function isEnabled(): bool
{
return $this->app->config()->authEnabled;
}
public function start(): void
{
$this->app->session()->start();
}
public function user(): ?array
{
$this->start();
$user = $_SESSION['auth_user'] ?? null;
if (!is_array($user)) {
return null;
}
$expiresAt = (int) ($_SESSION['auth_expires_at'] ?? 0);
if ($expiresAt > 0 && $expiresAt < time()) {
$this->clearLocalSession();
return null;
}
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
return $user;
}
public function isAuthenticated(): bool
{
return $this->user() !== null;
}
public function login(string $returnTo = '/'): void
{
if (!$this->isEnabled()) {
redirect($returnTo !== '' ? $returnTo : '/');
}
$this->start();
$_SESSION['oidc_return_to'] = $this->safeReturnTo($returnTo);
redirect('/auth/login');
}
public function callback(): void
{
$query = (string) ($_SERVER['QUERY_STRING'] ?? '');
redirect('/auth/callback' . ($query !== '' ? '?' . $query : ''));
}
public function logout(): void
{
redirect('/auth/logout');
}
public function storeUser(array $claims, array $groups, string $idToken): void
{
$username = (string) ($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? '');
$_SESSION['auth_user'] = [
'sub' => (string) ($claims['sub'] ?? ''),
'username' => $username,
'email' => (string) ($claims['email'] ?? ''),
'name' => (string) ($claims['name'] ?? $username),
'groups' => $groups,
'id_token' => $idToken,
];
$_SESSION['auth_id_token'] = $idToken;
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
$this->rememberKeycloakUser($claims, $groups);
}
public function canAccessModule(array $module): bool
{
$auth = is_array($module['auth'] ?? null) ? $module['auth'] : [];
$required = (bool) ($auth['required'] ?? false);
if (!$required || !$this->isEnabled()) {
return true;
}
$user = $this->user();
if ($user === null) {
return false;
}
$allowedUsers = $this->normalizeList($auth['users'] ?? []);
$allowedGroups = $this->normalizeList($auth['groups'] ?? []);
if ($allowedUsers === [] && $allowedGroups === []) {
return true;
}
$username = strtolower((string) ($user['username'] ?? ''));
$email = strtolower((string) ($user['email'] ?? ''));
$sub = strtolower((string) ($user['sub'] ?? ''));
foreach ($allowedUsers as $allowedUser) {
if ($allowedUser === $username || ($email !== '' && $allowedUser === $email) || ($sub !== '' && $allowedUser === $sub)) {
return true;
}
}
$userGroups = $this->normalizeList($user['groups'] ?? []);
return array_intersect($allowedGroups, $userGroups) !== [];
}
private function rememberKeycloakUser(array $claims, array $groups): void
{
$pdo = $this->app->basePdo();
$sub = trim((string)($claims['sub'] ?? ''));
if (!$pdo || $sub === '') {
return;
}
$groupsJson = json_encode(array_values(array_unique(array_map('strval', $groups))), JSON_UNESCAPED_UNICODE);
if (!is_string($groupsJson)) {
$groupsJson = '[]';
}
$stmt = $pdo->prepare(
"INSERT INTO nexus_auth_users (sub, username, email, name, groups, last_login_at)
VALUES (:sub, :username, :email, :name, :groups, CURRENT_TIMESTAMP)
ON CONFLICT(sub) DO UPDATE SET
username = excluded.username,
email = excluded.email,
name = excluded.name,
groups = excluded.groups,
last_login_at = CURRENT_TIMESTAMP"
);
$stmt->execute([
'sub' => $sub,
'username' => (string)($claims['preferred_username'] ?? ''),
'email' => (string)($claims['email'] ?? ''),
'name' => (string)($claims['name'] ?? ''),
'groups' => $groupsJson,
]);
}
public function requireModuleAccess(array $module): void
{
if ($this->canAccessModule($module)) {
return;
}
if (!$this->isAuthenticated()) {
$this->login($this->currentPath());
}
http_response_code(403);
exit('Forbidden');
}
public function filterModules(array $modules): array
{
return array_values(array_filter($modules, fn (array $module): bool => $this->canAccessModule($module)));
}
private function normalizeList(mixed $values): array
{
if (is_string($values)) {
$values = preg_split('/[,\\n]+/', $values) ?: [];
}
if (!is_array($values)) {
$values = [$values];
}
$normalized = [];
foreach ($values as $value) {
$item = strtolower(trim((string) $value));
if ($item !== '') {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
private function clearLocalSession(): void
{
unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']);
}
private function currentPath(): string
{
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
return $this->safeReturnTo($uri);
}
private function safeReturnTo(string $returnTo): string
{
$returnTo = trim($returnTo);
if ($returnTo === '' || !str_starts_with($returnTo, '/') || str_starts_with($returnTo, '//')) {
return '/';
}
return $returnTo;
}
}

View File

@@ -0,0 +1,850 @@
<?php
declare(strict_types=1);
namespace App;
final class BaseSchema
{
public static function ensure(\PDO $pdo): void
{
$driver = (string)$pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
self::ensurePgsql($pdo);
self::seedTimezones($pdo);
return;
}
if ($driver === 'sqlite') {
self::ensureSqlite($pdo);
self::seedTimezones($pdo);
return;
}
self::ensureGeneric($pdo);
self::seedTimezones($pdo);
}
private static function ensurePgsql(\PDO $pdo): void
{
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_modules (
name TEXT PRIMARY KEY,
title TEXT,
version TEXT,
enabled BOOLEAN NOT NULL DEFAULT false,
installed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_settings (
name TEXT PRIMARY KEY,
settings TEXT NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
module_name TEXT NOT NULL,
migration TEXT NOT NULL,
version TEXT,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (module_name, migration)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
name TEXT PRIMARY KEY,
required BOOLEAN NOT NULL DEFAULT false,
users TEXT NOT NULL DEFAULT '[]',
groups TEXT NOT NULL DEFAULT '[]',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_task_runs (
module_name TEXT NOT NULL,
task_name TEXT NOT NULL,
last_started_at TIMESTAMPTZ NULL,
last_finished_at TIMESTAMPTZ NULL,
last_success_at TIMESTAMPTZ NULL,
last_status TEXT NULL,
last_message TEXT NULL,
lock_until TIMESTAMPTZ NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (module_name, task_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
module_name TEXT NOT NULL,
job_name TEXT NOT NULL,
last_scheduled_for TIMESTAMPTZ NULL,
last_started_at TIMESTAMPTZ NULL,
last_finished_at TIMESTAMPTZ NULL,
last_success_at TIMESTAMPTZ NULL,
last_status TEXT NULL,
last_message TEXT NULL,
lock_until TIMESTAMPTZ NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (module_name, job_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
username TEXT,
email TEXT,
name TEXT,
groups TEXT NOT NULL DEFAULT '[]',
last_login_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_roles (
name TEXT PRIMARY KEY,
description TEXT
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_user_prefs (
client_id TEXT PRIMARY KEY,
theme TEXT NOT NULL DEFAULT 'light',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_timezones (
identifier TEXT PRIMARY KEY,
label TEXT NOT NULL,
group_name TEXT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboards (
id BIGSERIAL PRIMARY KEY,
owner_key TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
sort_order INTEGER NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT false,
config TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_dashboards_owner_slug_idx ON nexus_dashboards (owner_key, slug)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboard_items (
id BIGSERIAL PRIMARY KEY,
dashboard_id BIGINT NOT NULL,
item_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NULL,
grid_column INTEGER NULL,
grid_row INTEGER NULL,
column_span INTEGER NOT NULL DEFAULT 1,
row_span INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_items_dashboard_idx ON nexus_dashboard_items (dashboard_id, sort_order, id)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_integrations (
id BIGSERIAL PRIMARY KEY,
owner_key TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
base_url TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
is_active BOOLEAN NOT NULL DEFAULT true,
config TEXT NOT NULL DEFAULT '{}',
secrets TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_page_modules (
id BIGSERIAL PRIMARY KEY,
owner_key TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
module_type TEXT NOT NULL,
target_url TEXT NULL,
description TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
open_mode TEXT NOT NULL DEFAULT 'embed',
is_active BOOLEAN NOT NULL DEFAULT true,
config TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_page_modules_owner_slug_idx ON nexus_page_modules (owner_key, slug)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboard_shares (
id BIGSERIAL PRIMARY KEY,
dashboard_id BIGINT NOT NULL,
share_type TEXT NOT NULL,
share_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_shares_dashboard_idx ON nexus_dashboard_shares (dashboard_id, share_type, share_value)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_widget_templates (
id BIGSERIAL PRIMARY KEY,
owner_key TEXT NOT NULL,
name TEXT NOT NULL,
widget_type TEXT NOT NULL,
description TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
config TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_search_engines (
id BIGSERIAL PRIMARY KEY,
owner_key TEXT NOT NULL,
name TEXT NOT NULL,
short_code TEXT NOT NULL,
engine_type TEXT NOT NULL DEFAULT 'template',
template_url TEXT NULL,
integration_id BIGINT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
is_default BOOLEAN NOT NULL DEFAULT false,
config TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_search_engines_owner_short_idx ON nexus_search_engines (owner_key, short_code)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_apps (
id BIGSERIAL PRIMARY KEY,
owner_key TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NULL,
app_url TEXT NOT NULL,
icon_url TEXT NULL,
integration_id BIGINT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
config TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
}
private static function ensureSqlite(\PDO $pdo): void
{
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_modules (
name TEXT PRIMARY KEY,
title TEXT,
version TEXT,
enabled INTEGER NOT NULL DEFAULT 0,
installed_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_settings (
name TEXT PRIMARY KEY,
settings TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
module_name TEXT NOT NULL,
migration TEXT NOT NULL,
version TEXT,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (module_name, migration)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
name TEXT PRIMARY KEY,
required INTEGER NOT NULL DEFAULT 0,
users TEXT NOT NULL DEFAULT '[]',
groups TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_task_runs (
module_name TEXT NOT NULL,
task_name TEXT NOT NULL,
last_started_at TEXT NULL,
last_finished_at TEXT NULL,
last_success_at TEXT NULL,
last_status TEXT NULL,
last_message TEXT NULL,
lock_until TEXT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (module_name, task_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
module_name TEXT NOT NULL,
job_name TEXT NOT NULL,
last_scheduled_for TEXT NULL,
last_started_at TEXT NULL,
last_finished_at TEXT NULL,
last_success_at TEXT NULL,
last_status TEXT NULL,
last_message TEXT NULL,
lock_until TEXT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (module_name, job_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub TEXT PRIMARY KEY,
username TEXT,
email TEXT,
name TEXT,
groups TEXT NOT NULL DEFAULT '[]',
last_login_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_roles (
name TEXT PRIMARY KEY,
description TEXT
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_user_prefs (
client_id TEXT PRIMARY KEY,
theme TEXT NOT NULL DEFAULT 'light',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_timezones (
identifier TEXT PRIMARY KEY,
label TEXT NOT NULL,
group_name TEXT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_key TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
sort_order INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_dashboards_owner_slug_idx ON nexus_dashboards (owner_key, slug)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboard_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dashboard_id INTEGER NOT NULL,
item_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NULL,
grid_column INTEGER NULL,
grid_row INTEGER NULL,
column_span INTEGER NOT NULL DEFAULT 1,
row_span INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_items_dashboard_idx ON nexus_dashboard_items (dashboard_id, sort_order, id)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_integrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_key TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
base_url TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
is_active INTEGER NOT NULL DEFAULT 1,
config TEXT NOT NULL DEFAULT '{}',
secrets TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_page_modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_key TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
module_type TEXT NOT NULL,
target_url TEXT NULL,
description TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
open_mode TEXT NOT NULL DEFAULT 'embed',
is_active INTEGER NOT NULL DEFAULT 1,
config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_page_modules_owner_slug_idx ON nexus_page_modules (owner_key, slug)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboard_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dashboard_id INTEGER NOT NULL,
share_type TEXT NOT NULL,
share_value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec("CREATE INDEX IF NOT EXISTS nexus_dashboard_shares_dashboard_idx ON nexus_dashboard_shares (dashboard_id, share_type, share_value)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_widget_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_key TEXT NOT NULL,
name TEXT NOT NULL,
widget_type TEXT NOT NULL,
description TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'private',
config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_search_engines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_key TEXT NOT NULL,
name TEXT NOT NULL,
short_code TEXT NOT NULL,
engine_type TEXT NOT NULL DEFAULT 'template',
template_url TEXT NULL,
integration_id INTEGER NULL,
visibility TEXT NOT NULL DEFAULT 'private',
is_default INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS nexus_search_engines_owner_short_idx ON nexus_search_engines (owner_key, short_code)");
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_apps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_key TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NULL,
app_url TEXT NOT NULL,
icon_url TEXT NULL,
integration_id INTEGER NULL,
visibility TEXT NOT NULL DEFAULT 'private',
config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
private static function ensureGeneric(\PDO $pdo): void
{
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_modules (
name VARCHAR(190) PRIMARY KEY,
title VARCHAR(190),
version VARCHAR(64),
enabled TINYINT NOT NULL DEFAULT 0,
installed_at DATETIME,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_settings (
name VARCHAR(190) PRIMARY KEY,
settings TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_migrations (
module_name VARCHAR(190) NOT NULL,
migration VARCHAR(190) NOT NULL,
version VARCHAR(64),
checksum VARCHAR(64) NOT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (module_name, migration)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_auth (
name VARCHAR(190) PRIMARY KEY,
required TINYINT NOT NULL DEFAULT 0,
users TEXT NOT NULL,
groups TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_task_runs (
module_name VARCHAR(190) NOT NULL,
task_name VARCHAR(190) NOT NULL,
last_started_at DATETIME NULL,
last_finished_at DATETIME NULL,
last_success_at DATETIME NULL,
last_status VARCHAR(32) NULL,
last_message TEXT NULL,
lock_until DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (module_name, task_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_module_cron_runs (
module_name VARCHAR(190) NOT NULL,
job_name VARCHAR(190) NOT NULL,
last_scheduled_for DATETIME NULL,
last_started_at DATETIME NULL,
last_finished_at DATETIME NULL,
last_success_at DATETIME NULL,
last_status VARCHAR(32) NULL,
last_message TEXT NULL,
lock_until DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (module_name, job_name)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_auth_users (
sub VARCHAR(190) PRIMARY KEY,
username VARCHAR(190),
email VARCHAR(190),
name VARCHAR(190),
groups TEXT NOT NULL,
last_login_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_settings (
`key` VARCHAR(190) PRIMARY KEY,
`value` TEXT,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'user',
is_active TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_roles (
name VARCHAR(64) PRIMARY KEY,
description VARCHAR(255)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_user_prefs (
client_id VARCHAR(190) PRIMARY KEY,
theme VARCHAR(64) NOT NULL DEFAULT 'light',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_timezones (
identifier VARCHAR(190) PRIMARY KEY,
label VARCHAR(255) NOT NULL,
group_name VARCHAR(190) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_key VARCHAR(190) NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(190) NOT NULL,
description TEXT NULL,
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
sort_order INT NOT NULL DEFAULT 0,
is_default TINYINT NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY nexus_dashboards_owner_slug_idx (owner_key, slug)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboard_items (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
dashboard_id BIGINT NOT NULL,
item_type VARCHAR(64) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
grid_column INT NULL,
grid_row INT NULL,
column_span INT NOT NULL DEFAULT 1,
row_span INT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY nexus_dashboard_items_dashboard_idx (dashboard_id, sort_order, id)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_integrations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_key VARCHAR(190) NOT NULL,
type VARCHAR(64) NOT NULL,
name VARCHAR(255) NOT NULL,
base_url TEXT NULL,
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
is_active TINYINT NOT NULL DEFAULT 1,
config TEXT NOT NULL,
secrets TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_page_modules (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_key VARCHAR(190) NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(190) NOT NULL,
module_type VARCHAR(64) NOT NULL,
target_url TEXT NULL,
description TEXT NULL,
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
open_mode VARCHAR(32) NOT NULL DEFAULT 'embed',
is_active TINYINT NOT NULL DEFAULT 1,
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY nexus_page_modules_owner_slug_idx (owner_key, slug)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dashboard_shares (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
dashboard_id BIGINT NOT NULL,
share_type VARCHAR(32) NOT NULL,
share_value VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY nexus_dashboard_shares_dashboard_idx (dashboard_id, share_type, share_value)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_widget_templates (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_key VARCHAR(190) NOT NULL,
name VARCHAR(255) NOT NULL,
widget_type VARCHAR(64) NOT NULL,
description TEXT NULL,
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_search_engines (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_key VARCHAR(190) NOT NULL,
name VARCHAR(255) NOT NULL,
short_code VARCHAR(64) NOT NULL,
engine_type VARCHAR(32) NOT NULL DEFAULT 'template',
template_url TEXT NULL,
integration_id BIGINT NULL,
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
is_default TINYINT NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY nexus_search_engines_owner_short_idx (owner_key, short_code)
)"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_apps (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
owner_key VARCHAR(190) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
app_url TEXT NOT NULL,
icon_url TEXT NULL,
integration_id BIGINT NULL,
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
config TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
}
private static function seedTimezones(\PDO $pdo): void
{
try {
$count = (int) $pdo->query("SELECT COUNT(*) FROM nexus_timezones")->fetchColumn();
} catch (\Throwable) {
return;
}
if ($count > 0) {
return;
}
$driver = (string) $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
$sql = match ($driver) {
'pgsql' => "INSERT INTO nexus_timezones (identifier, label, group_name, updated_at)
VALUES (:identifier, :label, :group_name, NOW())
ON CONFLICT (identifier) DO UPDATE SET
label = EXCLUDED.label,
group_name = EXCLUDED.group_name,
updated_at = NOW()",
'sqlite' => "INSERT INTO nexus_timezones (identifier, label, group_name, updated_at)
VALUES (:identifier, :label, :group_name, datetime('now'))
ON CONFLICT(identifier) DO UPDATE SET
label = excluded.label,
group_name = excluded.group_name,
updated_at = datetime('now')",
default => "INSERT INTO nexus_timezones (identifier, label, group_name, updated_at)
VALUES (:identifier, :label, :group_name, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
group_name = VALUES(group_name),
updated_at = CURRENT_TIMESTAMP",
};
$stmt = $pdo->prepare($sql);
foreach (timezone_identifiers_list() as $identifier) {
$parts = explode('/', $identifier, 2);
$group = $parts[0] ?? 'Other';
$label = str_replace('_', ' ', $identifier);
if ($identifier === 'UTC') {
$group = 'UTC';
$label = 'UTC';
}
$stmt->execute([
'identifier' => $identifier,
'label' => $label,
'group_name' => $group,
]);
}
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App;
final class Community
{
public function __construct(private \PDO $pdo, private array $config)
{
}
public function createThread(int $userId, string $title, string $body): void
{
$stmt = $this->pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)');
$stmt->execute([
':uid' => $userId,
':title' => trim($title),
':body' => trim($body),
]);
}
public function createPost(int $userId, int $threadId, string $body): void
{
$stmt = $this->pdo->prepare('INSERT INTO forum_posts (thread_id, user_id, body) VALUES (:tid, :uid, :body)');
$stmt->execute([
':tid' => $threadId,
':uid' => $userId,
':body' => trim($body),
]);
}
public function searchThreads(string $query, int $limit = 50): array
{
$conditions = [];
$params = [];
$tokens = array_filter(preg_split('/\s+/', trim($query)) ?: [], fn($t) => $t !== '');
$i = 0;
foreach ($tokens as $tok) {
$ph1 = ':t' . $i . 'a';
$ph2 = ':t' . $i . 'b';
$conditions[] = "(ft.title LIKE $ph1 OR ft.body LIKE $ph2)";
$params[$ph1] = '%' . $tok . '%';
$params[$ph2] = '%' . $tok . '%';
$i++;
}
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at,
u.id as uid, u.created_at as user_created,
p.display_name,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
(SELECT COUNT(*) FROM forum_posts fp2 WHERE fp2.user_id = u.id) +
(SELECT COUNT(*) FROM forum_threads ft2 WHERE ft2.user_id = u.id) AS user_posts
FROM forum_threads ft
JOIN users u ON u.id = ft.user_id
LEFT JOIN user_profiles p ON p.user_id = u.id
WHERE 1=1 $where
ORDER BY ft.created_at DESC
LIMIT :lim";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, \PDO::PARAM_STR);
}
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function listThreads(int $limit = 50): array
{
return $this->searchThreads('', $limit);
}
public function getThread(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT ft.*, p.display_name FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id WHERE ft.id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null;
}
public function listPosts(int $threadId): array
{
$stmt = $this->pdo->prepare('SELECT fp.*, p.display_name FROM forum_posts fp LEFT JOIN user_profiles p ON p.user_id = fp.user_id WHERE fp.thread_id = :id ORDER BY fp.created_at ASC');
$stmt->execute([':id' => $threadId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function computePoints(int $userId): float
{
// Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points
$stmt = $this->pdo->prepare('SELECT total FROM user_points_totals WHERE user_id = :uid');
$stmt->execute([':uid' => $userId]);
$total = $stmt->fetchColumn();
if ($total !== false && $total !== null) {
return (float)$total;
}
$stmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid');
$stmt->execute([':uid' => $userId]);
return (float)$stmt->fetchColumn();
}
/**
* Vergibt Punkte persistent und berücksichtigt Caps/Bonis gemäß config actions.
*/
public function addPoints(int $userId, string $group, string $key, array $meta = []): float
{
$actions = $this->config['actions'][$group][$key] ?? null;
if (!$actions || empty($actions['points'])) {
return 0.0;
}
$basePoints = (float)$actions['points'];
// Boni (einfacher first-Check)
$bonusPoints = 0.0;
if (!empty($actions['bonuses'])) {
if (isset($actions['bonuses']['first'])) {
$bonusPoints += (float)$actions['bonuses']['first'];
}
if (isset($actions['bonuses']['first_helpful_5']) && isset($meta['helpful_count']) && (int)$meta['helpful_count'] >= 5) {
$bonusPoints += (float)$actions['bonuses']['first_helpful_5'];
}
}
$amount = $basePoints + $bonusPoints;
if ($amount <= 0) {
return 0.0;
}
$caps = $actions['caps'] ?? [];
$capDaily = $caps['daily'] ?? null;
$capTotal = $caps['total'] ?? null;
$todayStart = (new \DateTimeImmutable('today'))->format('Y-m-d 00:00:00');
$todayEnd = (new \DateTimeImmutable('today'))->format('Y-m-d 23:59:59');
$actionKey = $group . '.' . $key;
if ($capDaily !== null) {
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action AND created_at BETWEEN :s AND :e");
$stmt->execute([
':uid' => $userId,
':action' => $actionKey,
':s' => $todayStart,
':e' => $todayEnd,
]);
$usedToday = (float)$stmt->fetchColumn();
$remaining = max(0.0, (float)$capDaily - $usedToday);
if ($remaining <= 0) {
return 0.0;
}
$amount = min($amount, $remaining);
}
if ($capTotal !== null) {
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action");
$stmt->execute([':uid' => $userId, ':action' => $actionKey]);
$usedTotal = (float)$stmt->fetchColumn();
$remaining = max(0.0, (float)$capTotal - $usedTotal);
if ($remaining <= 0) {
return 0.0;
}
$amount = min($amount, $remaining);
}
$stmt = $this->pdo->prepare('INSERT INTO user_points (user_id, action, amount, meta) VALUES (:uid, :action, :amount, :meta)');
$stmt->execute([
':uid' => $userId,
':action' => $actionKey,
':amount' => $amount,
':meta' => $meta ? json_encode($meta) : null,
]);
$stmt = $this->pdo->prepare('INSERT INTO user_points_totals (user_id, total) VALUES (:uid, :amt) ON DUPLICATE KEY UPDATE total = total + VALUES(total)');
$stmt->execute([':uid' => $userId, ':amt' => $amount]);
return $amount;
}
public function membershipLevel(float $points): array
{
$levels = $this->config['levels'] ?? [];
usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0));
foreach ($levels as $lvl) {
if ($points >= (float)($lvl['min'] ?? 0)) {
return [
'label' => $lvl['label'] ?? 'New Daddy',
'icon' => $lvl['icon'] ?? '',
];
}
}
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? ''];
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App;
class Config
{
public string $assetVersion;
public bool $dbAutoInit;
public ?string $dbInitScript;
public ?string $dbInitCmd;
public string $keaDbVersion;
public array $baseDb;
public bool $baseDbEnabled;
public bool $authEnabled;
public string $oidcIssuer;
public string $oidcClientId;
public string $oidcClientSecret;
public string $oidcRedirectUri;
public string $oidcAuthEndpoint;
public string $oidcTokenEndpoint;
public string $oidcUserinfoEndpoint;
public string $oidcLogoutEndpoint;
public string $oidcPostLogoutRedirectUri;
public string $oidcGroupClaim;
public string $oidcAdminGroup;
public string $oidcUserGroup;
public string $modulesPath;
public array $dbConfig;
public function __construct(
public array $db,
public bool $dbEnabled = true,
array $baseDb = [],
bool $baseDbEnabled = false
) {
$this->assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : '';
$this->dbAutoInit = defined('APP_DB_AUTO_INIT') ? (bool)APP_DB_AUTO_INIT : false;
$this->dbInitScript = defined('APP_DB_INIT_SCRIPT') ? (string)APP_DB_INIT_SCRIPT : null;
$this->dbInitCmd = defined('APP_DB_INIT_CMD') ? (string)APP_DB_INIT_CMD : null;
$this->keaDbVersion = defined('APP_KEA_DB_VERSION') ? (string)APP_KEA_DB_VERSION : '';
$this->baseDb = $baseDb;
$this->baseDbEnabled = $baseDbEnabled;
$this->dbConfig = $baseDbEnabled && !empty($baseDb) ? $baseDb : $db;
$this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : (defined('KEYCLOAK_ENABLED') ? (bool)KEYCLOAK_ENABLED : false);
$this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : (defined('KEYCLOAK_ISSUER') ? (string)KEYCLOAK_ISSUER : '');
$this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : (defined('KEYCLOAK_CLIENT_ID') ? (string)KEYCLOAK_CLIENT_ID : '');
$this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : (defined('KEYCLOAK_CLIENT_SECRET') ? (string)KEYCLOAK_CLIENT_SECRET : '');
$this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : (defined('KEYCLOAK_REDIRECT_URI') ? (string)KEYCLOAK_REDIRECT_URI : '');
$this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : (defined('KEYCLOAK_AUTH_ENDPOINT') ? (string)KEYCLOAK_AUTH_ENDPOINT : '');
$this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : (defined('KEYCLOAK_TOKEN_ENDPOINT') ? (string)KEYCLOAK_TOKEN_ENDPOINT : '');
$this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : (defined('KEYCLOAK_USERINFO_ENDPOINT') ? (string)KEYCLOAK_USERINFO_ENDPOINT : '');
$this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : (defined('KEYCLOAK_LOGOUT_ENDPOINT') ? (string)KEYCLOAK_LOGOUT_ENDPOINT : '');
$this->oidcPostLogoutRedirectUri = defined('APP_OIDC_POST_LOGOUT_REDIRECT_URI') ? (string)APP_OIDC_POST_LOGOUT_REDIRECT_URI : (defined('KEYCLOAK_POST_LOGOUT_REDIRECT_URI') ? (string)KEYCLOAK_POST_LOGOUT_REDIRECT_URI : '');
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : (defined('KEYCLOAK_GROUP_CLAIM') ? (string)KEYCLOAK_GROUP_CLAIM : 'groups');
$this->oidcAdminGroup = defined('APP_OIDC_ADMIN_GROUP') ? (string)APP_OIDC_ADMIN_GROUP : 'admin';
$this->oidcUserGroup = defined('APP_OIDC_USER_GROUP') ? (string)APP_OIDC_USER_GROUP : 'user';
$this->modulesPath = defined('APP_MODULES_PATH') ? (string)APP_MODULES_PATH : '';
}
public function primaryUrl(): string
{
if (defined('APP_URL_PRIMARY') && APP_URL_PRIMARY !== '') {
return APP_URL_PRIMARY;
}
$domain = $this->cookieDomain();
if ($domain) {
return 'https://' . $domain;
}
return '';
}
public function cookiePrefix(): string
{
if (defined('APP_PREFIX') && APP_PREFIX !== '') {
return APP_PREFIX . '_';
}
return 'nexus_';
}
public function cookieDomain(): ?string
{
if (defined('APP_DOMAIN_PRIMARY') && APP_DOMAIN_PRIMARY !== '') {
return APP_DOMAIN_PRIMARY;
}
if (defined('APP_DOMAIN_NAME') && APP_DOMAIN_NAME !== '') {
return APP_DOMAIN_NAME;
}
return null;
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class CronExpression
{
/** @var array<int, true> */
private array $minutes;
/** @var array<int, true> */
private array $hours;
/** @var array<int, true> */
private array $daysOfMonth;
/** @var array<int, true> */
private array $months;
/** @var array<int, true> */
private array $daysOfWeek;
private function __construct(
private string $expression,
array $minutes,
array $hours,
array $daysOfMonth,
array $months,
array $daysOfWeek,
private bool $daysOfMonthWildcard,
private bool $daysOfWeekWildcard
) {
$this->minutes = $minutes;
$this->hours = $hours;
$this->daysOfMonth = $daysOfMonth;
$this->months = $months;
$this->daysOfWeek = $daysOfWeek;
}
public static function parse(string $expression): self
{
$normalized = preg_replace('/\s+/', ' ', trim($expression)) ?? '';
if ($normalized === '') {
throw new \InvalidArgumentException('Cron-Ausdruck fehlt.');
}
$parts = explode(' ', $normalized);
if (count($parts) !== 5) {
throw new \InvalidArgumentException('Cron-Ausdruck muss aus 5 Feldern bestehen.');
}
return new self(
$normalized,
self::parseField($parts[0], 0, 59),
self::parseField($parts[1], 0, 23),
self::parseField($parts[2], 1, 31),
self::parseField($parts[3], 1, 12, [
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12,
]),
self::parseField($parts[4], 0, 6, [
'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
'THU' => 4, 'FRI' => 5, 'SAT' => 6,
'7' => 0,
]),
trim($parts[2]) === '*',
trim($parts[4]) === '*'
);
}
public function expression(): string
{
return $this->expression;
}
public function matches(DateTimeImmutable $utcDateTime, DateTimeZone $timezone): bool
{
$local = $utcDateTime->setTimezone($timezone);
$minute = (int) $local->format('i');
$hour = (int) $local->format('G');
$dayOfMonth = (int) $local->format('j');
$month = (int) $local->format('n');
$dayOfWeek = (int) $local->format('w');
if (!isset($this->minutes[$minute]) || !isset($this->hours[$hour]) || !isset($this->months[$month])) {
return false;
}
$dayOfMonthMatch = isset($this->daysOfMonth[$dayOfMonth]);
$dayOfWeekMatch = isset($this->daysOfWeek[$dayOfWeek]);
if ($this->daysOfMonthWildcard && $this->daysOfWeekWildcard) {
$dayMatches = true;
} elseif ($this->daysOfMonthWildcard) {
$dayMatches = $dayOfWeekMatch;
} elseif ($this->daysOfWeekWildcard) {
$dayMatches = $dayOfMonthMatch;
} else {
$dayMatches = $dayOfMonthMatch || $dayOfWeekMatch;
}
return $dayMatches;
}
public function previousRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookbackMinutes = 527040): ?DateTimeImmutable
{
$cursor = $this->floorToMinute($utcDateTime);
for ($i = 0; $i <= $lookbackMinutes; $i++) {
if ($this->matches($cursor, $timezone)) {
return $cursor;
}
$cursor = $cursor->sub(new DateInterval('PT1M'));
}
return null;
}
public function nextRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookaheadMinutes = 527040): ?DateTimeImmutable
{
$cursor = $this->floorToMinute($utcDateTime)->add(new DateInterval('PT1M'));
for ($i = 0; $i <= $lookaheadMinutes; $i++) {
if ($this->matches($cursor, $timezone)) {
return $cursor;
}
$cursor = $cursor->add(new DateInterval('PT1M'));
}
return null;
}
/** @return array<int, true> */
private static function parseField(string $field, int $min, int $max, array $aliases = []): array
{
$field = strtoupper(trim($field));
if ($field === '*') {
$all = [];
for ($value = $min; $value <= $max; $value++) {
$all[$value] = true;
}
return $all;
}
$values = [];
foreach (explode(',', $field) as $segment) {
$segment = strtoupper(trim($segment));
if ($segment === '') {
continue;
}
$step = 1;
if (str_contains($segment, '/')) {
[$segment, $stepPart] = explode('/', $segment, 2);
if (!is_numeric($stepPart) || (int) $stepPart <= 0) {
throw new \InvalidArgumentException('Ungueltiger Cron-Step in Feld "' . $field . '".');
}
$step = (int) $stepPart;
}
if ($segment === '*') {
$start = $min;
$end = $max;
} elseif (str_contains($segment, '-')) {
[$startPart, $endPart] = explode('-', $segment, 2);
$start = self::normalizePart($startPart, $aliases);
$end = self::normalizePart($endPart, $aliases);
} else {
$start = self::normalizePart($segment, $aliases);
$end = $start;
}
if ($start < $min || $start > $max || $end < $min || $end > $max || $end < $start) {
throw new \InvalidArgumentException('Cron-Feld "' . $field . '" liegt ausserhalb des erlaubten Bereichs.');
}
for ($value = $start; $value <= $end; $value += $step) {
$values[$value] = true;
}
}
if ($values === []) {
throw new \InvalidArgumentException('Cron-Feld "' . $field . '" ist leer.');
}
ksort($values);
return $values;
}
private static function normalizePart(string $part, array $aliases): int
{
$part = strtoupper(trim($part));
if ($part === '') {
throw new \InvalidArgumentException('Leerer Cron-Wert.');
}
if (array_key_exists($part, $aliases)) {
return (int) $aliases[$part];
}
if (!is_numeric($part)) {
throw new \InvalidArgumentException('Ungueltiger Cron-Wert "' . $part . '".');
}
return (int) $part;
}
private function floorToMinute(DateTimeImmutable $utcDateTime): DateTimeImmutable
{
return $utcDateTime->setTime(
(int) $utcDateTime->format('H'),
(int) $utcDateTime->format('i'),
0
);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App;
final class Crypto
{
private string $key;
public function __construct(Config $config)
{
if (!extension_loaded('sodium')) {
throw new \RuntimeException('libsodium extension not available');
}
$raw = getenv('DATA_KEY') ?: '';
$raw = trim($raw);
if ($raw === '') {
throw new \RuntimeException('DATA_KEY env not set');
}
// base64?
if (str_starts_with($raw, 'base64:')) {
$raw = substr($raw, 7);
}
$decoded = base64_decode($raw, true);
if ($decoded !== false && strlen($decoded) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
$raw = $decoded;
} elseif (ctype_xdigit($raw) && strlen($raw) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES * 2) {
$raw = hex2bin($raw);
}
if (strlen($raw) < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new \RuntimeException('DATA_KEY invalid length');
}
$this->key = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
}
public function encrypt(string $plaintext): string
{
if ($plaintext === '') {
return '';
}
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key);
return base64_encode($nonce . $cipher);
}
public function decrypt(?string $blob): string
{
if ($blob === null || $blob === '') {
return '';
}
$raw = base64_decode($blob, true);
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
return '';
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
try {
$plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, '', $nonce, $this->key);
return $plain === false ? '' : $plain;
} catch (\Throwable) {
return '';
}
}
}

View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App;
final class Database
{
public static function createPdo(Config $config): ?\PDO
{
if (!$config->dbEnabled) {
return null;
}
try {
$pdo = self::createFromArray($config->db);
self::ensureKeaSchema($pdo, [
'auto_init' => $config->dbAutoInit,
'init_cmd' => $config->dbInitCmd,
'init_script' => $config->dbInitScript,
'kea_db_version' => $config->keaDbVersion,
]);
return $pdo;
} catch (\PDOException $e) {
http_response_code(500);
$dbDebug = defined('APP_DB_DEBUG') && APP_DB_DEBUG;
$details = 'Database connection error.';
if ($dbDebug) {
$details .= ' ' . $e->getMessage();
}
error_log('[DB] ' . $e->getMessage());
echo $details;
exit;
}
}
public static function createBasePdo(Config $config): ?\PDO
{
if (!$config->baseDbEnabled || empty($config->baseDb)) {
return null;
}
try {
return self::createFromArray($config->baseDb);
} catch (\PDOException $e) {
http_response_code(500);
$details = 'Base database connection error.';
error_log('[Base DB] ' . $e->getMessage());
echo $details;
exit;
}
}
public static function createFromArray(array $db): \PDO
{
$driver = (string)($db['driver'] ?? '');
if ($driver === '') {
throw new \RuntimeException('DB config missing "driver"');
}
$dsn = match ($driver) {
'mysql' => self::buildMysqlDsn($db),
'pgsql' => self::buildPgsqlDsn($db),
'sqlite' => self::buildSqliteDsn($db),
default => throw new \RuntimeException('Unsupported PDO driver: ' . $driver),
};
$pdo = new \PDO(
$dsn,
(string)($db['user'] ?? ''),
(string)($db['password'] ?? ''),
(array)($db['options'] ?? [])
);
if ($driver === 'pgsql' && !empty($db['schema'])) {
$schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db['schema']);
if ($schema !== '') {
$pdo->exec('SET search_path TO ' . $schema);
}
}
return $pdo;
}
public static function ensureKeaSchema(\PDO $pdo, array $options): void
{
$driver = (string)$pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
if ($driver !== 'pgsql') {
return;
}
if (!self::tableExists($pdo, 'hosts')) {
if (!empty($options['auto_init'])) {
self::initKeaSchema($pdo, $options);
}
}
}
private static function tableExists(\PDO $pdo, string $table): bool
{
$stmt = $pdo->prepare(
"SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = :table
LIMIT 1"
);
$stmt->execute(['table' => $table]);
return (bool)$stmt->fetchColumn();
}
private static function initKeaSchema(\PDO $pdo, array $options): void
{
if (!empty($options['init_cmd'])) {
self::runInitCommand((string)$options['init_cmd']);
return;
}
$script = $options['init_script'] ?? null;
if (!$script && !empty($options['kea_db_version'])) {
$script = __DIR__ . '/../../tools/sql/kea/' . $options['kea_db_version'] . '/dhcpdb_create.pgsql';
}
if ($script) {
self::execSqlFile($pdo, (string)$script);
return;
}
throw new \RuntimeException(
'DB auto-init enabled but no APP_DB_INIT_CMD or APP_DB_INIT_SCRIPT configured.'
);
}
private static function runInitCommand(string $cmd): void
{
$descriptor = [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($cmd, $descriptor, $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('Failed to start DB init command.');
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
foreach ($pipes as $pipe) {
fclose($pipe);
}
$code = proc_close($process);
if ($code !== 0) {
throw new \RuntimeException('DB init command failed: ' . trim($stderr ?: $stdout));
}
}
private static function execSqlFile(\PDO $pdo, string $path): void
{
if (!is_readable($path)) {
throw new \RuntimeException('DB init script not readable: ' . $path);
}
$sql = file_get_contents($path);
if ($sql === false) {
throw new \RuntimeException('Failed to read DB init script: ' . $path);
}
// Strip psql meta-commands (lines starting with backslash)
$sql = preg_replace('/^\\\\.+$/m', '', $sql);
$pdo->exec($sql);
}
private static function buildMysqlDsn(array $db): string
{
if (empty($db['dbname'])) {
throw new \RuntimeException('MySQL config missing "dbname"');
}
$charset = (string)($db['charset'] ?? 'utf8mb4');
// Unix socket takes precedence
if (!empty($db['unix_socket'])) {
return sprintf(
'mysql:unix_socket=%s;dbname=%s;charset=%s',
(string)$db['unix_socket'],
(string)$db['dbname'],
$charset
);
}
$host = (string)($db['host'] ?? 'localhost');
$port = (int)($db['port'] ?? 3306);
return sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$host,
$port,
(string)$db['dbname'],
$charset
);
}
private static function buildPgsqlDsn(array $db): string
{
if (empty($db['dbname'])) {
throw new \RuntimeException('PostgreSQL config missing "dbname"');
}
$host = (string)($db['host'] ?? 'localhost');
$port = (int)($db['port'] ?? 5432);
// Hinweis: charset gehört bei pgsql nicht in den DSN
return sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$host,
$port,
(string)$db['dbname']
);
}
private static function buildSqliteDsn(array $db): string
{
// SQLite kann :memory: oder einen Pfad nutzen
$path = (string)($db['path'] ?? '');
if ($path === '') {
// Default: Memory-DB
$path = ':memory:';
}
// Wenn es ein Pfad ist, stelle sicher, dass das Verzeichnis existiert.
if ($path !== ':memory:') {
$dir = \dirname($path);
if ($dir && !is_dir($dir)) {
@mkdir($dir, 0775, true);
}
}
return 'sqlite:' . $path;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App;
final class Flash
{
public function __construct(private SessionManager $session) {}
public function set(string $type, string $message): void
{
$this->session->start();
$_SESSION['flash'] = [
'type' => $type,
'message' => $message,
];
}
public function get(): ?array
{
$this->session->start();
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
return null;
}
$f = $_SESSION['flash'];
unset($_SESSION['flash']);
return [
'type' => (string)($f['type'] ?? 'info'),
'message' => (string)($f['message'] ?? ''),
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App;
final class I18n
{
private array $current = [];
private array $fallback = [];
public function __construct(private Config $config, private string $lang = 'en')
{
// Minimal example translations (normally load JSON/PHP arrays from disk)
$this->fallback = [
'common' => [
'title' => 'Nexus',
'intro' => 'Internal configuration editor',
],
'cta' => [
'primary' => 'Weiter',
],
];
$this->current = $this->fallback;
}
private function traverse(array $data, string $key): mixed
{
$node = $data;
foreach (explode('.', $key) as $seg) {
if (!is_array($node) || !array_key_exists($seg, $node)) {
return null;
}
$node = $node[$seg];
}
return $node;
}
public function get(string $key, $default = '', array $vars = []): string
{
$val = $this->traverse($this->current, $key);
if ($val === null) {
$val = $this->traverse($this->fallback, $key);
}
if (!is_string($val)) {
$val = (string)($default ?? '');
}
// Built-ins
$val = str_replace('{year}', date('Y'), $val);
$val = str_replace('{{primary_url}}', $this->config->primaryUrl(), $val);
foreach ($vars as $k => $v) {
$val = str_replace('{' . $k . '}', (string)$v, $val);
$val = str_replace('{{' . $k . '}}', (string)$v, $val);
}
return $val;
}
}

View File

@@ -0,0 +1,352 @@
<?php
declare(strict_types=1);
namespace App;
final class Mailer
{
private string $logFile;
private bool $logCleared = false;
public function __construct(private App $app)
{
$base = dirname(__DIR__, 2);
$this->logFile = $base . '/debug/mailer_debug.log';
}
private function log(string $msg, array $ctx = []): void
{
if (!defined('APP_DEBUG') || APP_DEBUG !== true) {
return;
}
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg;
if ($ctx) {
$line .= ' ' . json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$line .= "\n";
$dir = dirname($this->logFile);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
// For clarity keep only the latest run in the log: truncate once per request
if ($this->logCleared === false) {
@file_put_contents($this->logFile, '');
$this->logCleared = true;
}
@file_put_contents($this->logFile, $line, FILE_APPEND);
}
private function templates(): array
{
$env = $this->app->config()->env;
$root = __DIR__ . '/../../config/emailtemplates.php';
$envPath = __DIR__ . "/../../config/{$env}/emailtemplates.php";
$file = is_file($root) ? $root : $envPath;
$emailtemplates = [];
if (is_file($file)) {
/** @noinspection PhpIncludeInspection */
include $file; // populates $emailtemplates variable from included file
}
return is_array($emailtemplates ?? null) ? $emailtemplates : [];
}
private function renderTemplate(string $key, array $vars): array
{
$templates = $this->templates();
$id = $templates[$key] ?? $key;
$this->log('template_resolved_id', ['key' => $key, 'id' => $id]);
$apiBase = getenv('EMAILTEMPLATE_API_BASE') ?: '';
$apiToken = getenv('EMAILTEMPLATE_API_TOKEN') ?: '';
if ($apiBase && $apiToken) {
$payload = [
'template' => $id,
'placeholders' => $vars,
];
$payload['token'] = $apiToken;
$payloadForLog = $payload;
$payloadForLog['token'] = '[hidden length ' . strlen((string)$apiToken) . ']';
$this->log('template_api_request_payload', [
'url' => $apiBase,
'payload' => $payloadForLog,
]);
$this->log('template_api_request', ['template' => $id, 'placeholders' => array_keys($vars)]);
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'timeout' => 15,
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
]);
$resp = @file_get_contents($apiBase, false, $ctx);
if ($resp !== false) {
$status = null;
if (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header as $hdr) {
if (preg_match('~^HTTP/\\S+\\s+(\\d+)~i', $hdr, $m)) {
$status = (int)$m[1];
break;
}
}
}
$this->log('template_api_response_raw', [
'status' => $status,
'body' => $resp,
]);
$decoded = json_decode($resp, true);
if (is_array($decoded) && !empty($decoded['ok']) && !empty($decoded['html'])) {
$this->log('template_api_success', ['template' => $id, 'subject' => $decoded['subject'] ?? null, 'html_len' => strlen((string)$decoded['html'])]);
return [
'id' => $id,
'subject' => $decoded['subject'] ?? 'Nexus',
'html' => $decoded['html'],
];
}
$this->log('template_api_response_invalid', ['template' => $id, 'response' => $decoded]);
} else {
$this->log('template_api_unreachable', ['template' => $id]);
}
}
// Fallback: einfacher Text
$subject = 'Nexus';
$body = $id;
foreach ($vars as $k => $v) {
$body = str_replace(['{' . $k . '}', '{{' . $k . '}}'], (string)$v, $body);
}
$this->log('template_fallback_used', ['template' => $id]);
return [
'id' => $id,
'subject' => $subject,
'html' => nl2br(htmlspecialchars($body, ENT_QUOTES)),
];
}
public function sendTemplate(string $templateKey, string $to, array $vars = []): void
{
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid recipient email.');
}
$tpl = $this->renderTemplate($templateKey, $vars);
$resolvedId = $tpl['id'] ?? $templateKey;
$subject = $tpl['subject'] ?? 'Nexus';
$html = $tpl['html'] ?? '';
$this->log('mail_rendered_template', [
'template_key' => $templateKey,
'template_id' => $resolvedId,
'subject' => $subject,
'html_len' => strlen((string)$html),
'html_preview' => substr((string)$html, 0, 200),
]);
$transport = getenv('MAIL_TRANSPORT') ?: 'mail';
$fromEmail = getenv('MAIL_FROM') ?: 'no-reply@' . $this->app->config()->primaryDomain;
$fromName = getenv('MAIL_FROM_NAME') ?: 'Nexus';
$this->log('mail_send_start', [
'template_key' => $templateKey,
'template_id' => $resolvedId,
'to' => $to,
'transport' => $transport,
'subject' => $subject
]);
if ($transport === 'smtp') {
$this->sendSmtp($to, $subject, $html, $fromEmail, $fromName);
} else {
$this->sendMailFn($to, $subject, $html, $fromEmail, $fromName);
}
}
private function sendMailFn(string $to, string $subject, string $html, string $from, string $fromName): void
{
$headers = [];
if ($from) {
$headers[] = 'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $from);
}
$headers[] = 'Content-Type: text/html; charset=utf-8';
$ok = @mail($to, $subject, $html, implode("\r\n", $headers));
$this->log('mail_mail_transport', ['to' => $to, 'ok' => $ok]);
if (!$ok) {
throw new \RuntimeException('mail() transport failed');
}
}
private function sendSmtp(string $to, string $subject, string $html, string $from, string $fromName): void
{
$host = getenv('SMTP_HOST') ?: '';
$port = (int)(getenv('SMTP_PORT') ?: 587);
$user = getenv('SMTP_USER') ?: '';
$pass = getenv('SMTP_PASS') ?: '';
$secure = strtolower(getenv('SMTP_SECURE') ?: 'tls'); // tls|ssl|none
if (!$host) {
$this->log('mail_smtp_missing_host_fallback_mail', []);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$proto = ($secure === 'ssl') ? 'ssl://' : '';
$fp = @stream_socket_client($proto . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT);
if (!$fp) {
$this->log('mail_smtp_connect_failed', ['host' => $host, 'port' => $port, 'error' => $errstr]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
stream_set_timeout($fp, 15);
$transcript = [];
$readResponse = function (array $expectCodes = [], string $label = 'read') use ($fp, &$transcript): array {
$lines = [];
while (($line = fgets($fp, 515)) !== false) {
$line = rtrim($line, "\r\n");
$lines[] = $line;
$transcript[] = $label . ': ' . $line;
// SMTP multiline: code + '-' means more lines, code + ' ' means end
if (strlen($line) >= 4 && $line[3] === ' ') {
break;
}
}
$code = 0;
if ($lines) {
$code = (int)substr($lines[0], 0, 3);
}
return [
'ok' => !$expectCodes || in_array($code, $expectCodes, true),
'code' => $code,
'lines' => $lines,
];
};
$write = function (string $cmd, string $label = 'write', bool $mask = false) use ($fp, &$transcript): void {
$transcript[] = $label . ': ' . ($mask ? '[omitted]' : $cmd);
fwrite($fp, $cmd . "\r\n");
};
$resp = $readResponse([220], 'greeting');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_greeting_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$write('EHLO ' . $this->app->config()->primaryDomain);
$resp = $readResponse([250], 'ehlo');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_ehlo_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
if ($secure === 'tls') {
$write('STARTTLS');
$resp = $readResponse([220], 'starttls');
if (!$resp['ok'] || !stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($fp);
$this->log('mail_smtp_starttls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$write('EHLO ' . $this->app->config()->primaryDomain);
$resp = $readResponse([250], 'ehlo-tls');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_ehlo_tls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
}
if ($user !== '') {
$write('AUTH LOGIN');
$resp = $readResponse([334], 'auth-login');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_auth_login_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$write(base64_encode($user), 'auth-user', true);
$resp = $readResponse([334], 'auth-user');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_auth_user_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$write(base64_encode($pass), 'auth-pass', true);
$resp = $readResponse([235], 'auth-pass');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_auth_pass_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
}
$write('MAIL FROM: <' . $from . '>');
$resp = $readResponse([250], 'mail-from');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_mailfrom_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$write('RCPT TO: <' . $to . '>');
$resp = $readResponse([250, 251], 'rcpt-to');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_rcpt_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$write('DATA');
$resp = $readResponse([354], 'data-start');
if (!$resp['ok']) {
fclose($fp);
$this->log('mail_smtp_data_start_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$msg = "From: {$fromName} <{$from}>\r\n";
$msg .= "To: <{$to}>\r\n";
$msg .= "Subject: {$subject}\r\n";
$msg .= "MIME-Version: 1.0\r\n";
$msg .= "Content-Type: text/html; charset=utf-8\r\n\r\n";
$msg .= $html . "\r\n.\r\n";
$write($msg, 'data', false);
$resp = $readResponse([250], 'data-end');
$write('QUIT');
$readResponse([221], 'quit');
fclose($fp);
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
if (!$resp['ok']) {
$this->log('mail_smtp_send_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
$this->sendMailFn($to, $subject, $html, $from, $fromName);
return;
}
$this->log('mail_smtp_sent', ['to' => $to, 'host' => $host, 'port' => $port, 'secure' => $secure]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleConfigException extends \RuntimeException
{
private string $module;
private ?string $details;
public function __construct(
string $module,
string $message,
?string $details = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
$this->module = $module;
$this->details = $details;
}
public function module(): string
{
return $this->module;
}
public function details(): ?string
{
return $this->details;
}
}

View File

@@ -0,0 +1,526 @@
<?php
declare(strict_types=1);
namespace App;
use DateTimeImmutable;
use DateTimeZone;
final class ModuleCronScheduler
{
public function __construct(
private ModuleManager $modules,
private ?\PDO $pdo
) {
}
public function definitions(string $moduleName): array
{
$module = $this->modules->get($moduleName);
$tasks = is_array($module['scheduler_jobs'] ?? null) ? $module['scheduler_jobs'] : (is_array($module['cron_tasks'] ?? null) ? $module['cron_tasks'] : []);
$definitions = [];
foreach ($tasks as $task) {
if (!is_array($task)) {
continue;
}
$name = trim((string) ($task['name'] ?? ''));
$callback = trim((string) ($task['callback'] ?? ''));
if ($name === '' || $callback === '') {
continue;
}
$definitions[$name] = [
'name' => $name,
'label' => trim((string) ($task['label'] ?? $name)),
'callback' => $callback,
'mode' => in_array((string) ($task['mode'] ?? 'single'), ['single', 'multi'], true) ? (string) ($task['mode'] ?? 'single') : 'single',
'default_enabled' => array_key_exists('default_enabled', $task) ? (bool) $task['default_enabled'] : false,
'default_cron' => trim((string) ($task['default_cron'] ?? '0 * * * *')),
'default_timezone' => trim((string) ($task['default_timezone'] ?? 'UTC')) ?: 'UTC',
'timezone_setting' => trim((string) ($task['timezone_setting'] ?? '')),
'lock_minutes' => max(1, (int) ($task['lock_minutes'] ?? 10)),
'builder' => is_array($task['builder'] ?? null) ? $task['builder'] : [],
'help' => trim((string) ($task['help'] ?? '')),
];
}
return $definitions;
}
public function statuses(string $moduleName): array
{
$definitions = $this->definitions($moduleName);
if ($definitions === []) {
return [];
}
$settings = $this->modules->settings($moduleName);
$states = $this->fetchStates($moduleName);
$nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$statuses = [];
foreach ($definitions as $name => $definition) {
foreach ($this->jobConfigs($definition, $settings) as $entryIndex => $config) {
$stateKey = $this->stateKey($name, $entryIndex);
$state = $states[$stateKey] ?? [];
$timezone = $this->safeTimezone((string) ($config['timezone'] ?? 'UTC'));
$expression = trim((string) ($config['cron_expression'] ?? ''));
$isLocked = $this->isLockActive($state, $nowUtc->getTimestamp());
$parseError = null;
$previousDueUtc = null;
$nextDueUtc = null;
$isDue = false;
try {
$cron = CronExpression::parse($expression);
if (!empty($config['enabled'])) {
$previousDueUtc = $cron->previousRun($nowUtc, $timezone);
$lastScheduledFor = $this->parseUtc((string) ($state['last_scheduled_for'] ?? ''));
$isDue = !$isLocked
&& $previousDueUtc instanceof DateTimeImmutable
&& ($lastScheduledFor === null || $previousDueUtc > $lastScheduledFor);
$nextDueUtc = $isDue
? $previousDueUtc
: $cron->nextRun($nowUtc, $timezone);
}
} catch (\Throwable $exception) {
$parseError = $exception->getMessage();
}
$statuses[] = $definition + [
'job_name' => $name,
'entry_index' => $entryIndex,
'state_key' => $stateKey,
'config' => $config,
'state' => $state,
'last_started_at_local' => ($this->parseUtc((string) ($state['last_started_at'] ?? '')))?->setTimezone($timezone)->format('Y-m-d H:i:s'),
'last_success_at_local' => ($this->parseUtc((string) ($state['last_success_at'] ?? '')))?->setTimezone($timezone)->format('Y-m-d H:i:s'),
'enabled' => !empty($config['enabled']),
'cron_expression' => $expression,
'timezone' => $timezone->getName(),
'is_due' => $isDue,
'is_locked' => $isLocked,
'parse_error' => $parseError,
'previous_due_at' => $previousDueUtc?->format('Y-m-d H:i:s'),
'next_due_at' => $nextDueUtc?->format('Y-m-d H:i:s'),
'previous_due_at_local' => $previousDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
'next_due_at_local' => $nextDueUtc?->setTimezone($timezone)->format('Y-m-d H:i:s'),
];
}
}
return $statuses;
}
public function runDue(string $moduleName): array
{
$results = [];
foreach ($this->statuses($moduleName) as $task) {
if (empty($task['enabled']) || empty($task['is_due']) || !empty($task['parse_error'])) {
continue;
}
if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
$results[] = [
'task' => $task['job_name'],
'entry_index' => $task['entry_index'],
'ok' => false,
'message' => 'Callback nicht registriert.',
];
continue;
}
if (!$this->acquireLock($moduleName, (string) $task['state_key'], (int) $task['lock_minutes'])) {
continue;
}
$startedAt = gmdate('Y-m-d H:i:s');
$scheduledForUtc = trim((string) ($task['previous_due_at'] ?? ''));
$timezone = $this->safeTimezone((string) ($task['timezone'] ?? 'UTC'));
$this->persistState($moduleName, (string) $task['state_key'], [
'last_started_at' => $startedAt,
'last_status' => 'running',
'last_message' => 'Cron-Lauf gestartet.',
]);
try {
$result = $this->modules->call($moduleName, (string) $task['callback'], [
'task' => $task,
'trigger' => 'cron_runner',
'started_at' => $startedAt,
'scheduled_for_utc' => $scheduledForUtc,
'scheduled_for_local' => $scheduledForUtc !== ''
? $this->parseUtc($scheduledForUtc)?->setTimezone($timezone)?->format('Y-m-d H:i:s')
: null,
'timezone' => $timezone->getName(),
]);
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
$skipped = is_array($result) && !empty($result['skipped']);
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
$finishedAt = gmdate('Y-m-d H:i:s');
$payload = [
'last_finished_at' => $finishedAt,
'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
'last_message' => $message,
'lock_until' => null,
'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
];
if ($ok && !$skipped) {
$payload['last_success_at'] = $finishedAt;
}
$this->persistState($moduleName, (string) $task['state_key'], $payload);
$results[] = [
'task' => $task['job_name'],
'entry_index' => $task['entry_index'],
'ok' => $ok,
'message' => $message,
];
} catch (\Throwable $e) {
$finishedAt = gmdate('Y-m-d H:i:s');
$this->persistState($moduleName, (string) $task['state_key'], [
'last_finished_at' => $finishedAt,
'last_status' => 'error',
'last_message' => $e->getMessage(),
'lock_until' => null,
'last_scheduled_for' => $scheduledForUtc !== '' ? $scheduledForUtc : null,
]);
$results[] = [
'task' => $task['job_name'],
'entry_index' => $task['entry_index'],
'ok' => false,
'message' => $e->getMessage(),
];
}
}
return $results;
}
public function runNow(string $moduleName, string $jobName, int $entryIndex): array
{
$task = null;
foreach ($this->statuses($moduleName) as $status) {
if ((string) ($status['job_name'] ?? '') === $jobName && (int) ($status['entry_index'] ?? -1) === $entryIndex) {
$task = $status;
break;
}
}
if (!is_array($task)) {
return [
'task' => $jobName,
'entry_index' => $entryIndex,
'ok' => false,
'message' => 'Cron-Eintrag nicht gefunden.',
];
}
if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
return [
'task' => $jobName,
'entry_index' => $entryIndex,
'ok' => false,
'message' => 'Callback nicht registriert.',
];
}
if (!$this->acquireLock($moduleName, (string) $task['state_key'], (int) $task['lock_minutes'])) {
return [
'task' => $jobName,
'entry_index' => $entryIndex,
'ok' => false,
'message' => 'Cron-Eintrag ist aktuell gesperrt.',
];
}
$startedAt = gmdate('Y-m-d H:i:s');
$timezone = $this->safeTimezone((string) ($task['timezone'] ?? 'UTC'));
$this->persistState($moduleName, (string) $task['state_key'], [
'last_started_at' => $startedAt,
'last_status' => 'running',
'last_message' => 'Manueller Testlauf gestartet.',
]);
try {
$result = $this->modules->call($moduleName, (string) $task['callback'], [
'task' => $task,
'trigger' => 'manual_test',
'started_at' => $startedAt,
'scheduled_for_utc' => null,
'scheduled_for_local' => null,
'timezone' => $timezone->getName(),
]);
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
$skipped = is_array($result) && !empty($result['skipped']);
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
$finishedAt = gmdate('Y-m-d H:i:s');
$payload = [
'last_finished_at' => $finishedAt,
'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
'last_message' => $message !== '' ? $message : ($ok ? 'Manueller Testlauf erfolgreich.' : 'Manueller Testlauf fehlgeschlagen.'),
'lock_until' => null,
];
if ($ok && !$skipped) {
$payload['last_success_at'] = $finishedAt;
}
$this->persistState($moduleName, (string) $task['state_key'], $payload);
return [
'task' => $jobName,
'entry_index' => $entryIndex,
'ok' => $ok,
'message' => (string) $payload['last_message'],
];
} catch (\Throwable $e) {
$finishedAt = gmdate('Y-m-d H:i:s');
$this->persistState($moduleName, (string) $task['state_key'], [
'last_finished_at' => $finishedAt,
'last_status' => 'error',
'last_message' => $e->getMessage(),
'lock_until' => null,
]);
return [
'task' => $jobName,
'entry_index' => $entryIndex,
'ok' => false,
'message' => $e->getMessage(),
];
}
}
private function jobConfigs(array $definition, array $settings): array
{
$jobs = is_array($settings['scheduler_jobs'] ?? null)
? $settings['scheduler_jobs']
: (is_array($settings['cron_jobs'] ?? null) ? $settings['cron_jobs'] : []);
$jobExists = array_key_exists($definition['name'], $jobs) && is_array($jobs[$definition['name']] ?? null);
$job = $jobExists ? $jobs[$definition['name']] : [];
$timezoneSetting = trim((string) ($definition['timezone_setting'] ?? ''));
$fallbackTimezone = $timezoneSetting !== '' ? trim((string) ($settings[$timezoneSetting] ?? '')) : '';
if ($fallbackTimezone === '' && function_exists('nexus_cron_timezone_name')) {
$fallbackTimezone = trim((string) nexus_cron_timezone_name());
}
$defaultEntry = [
'enabled' => (bool) $definition['default_enabled'],
'cron_expression' => trim((string) ($definition['default_cron'] ?? '0 * * * *')),
'timezone' => trim((string) ($fallbackTimezone !== '' ? $fallbackTimezone : ($definition['default_timezone'] ?? 'UTC'))),
'builder' => [],
];
$entries = is_array($job['entries'] ?? null) ? $job['entries'] : [];
if (!$jobExists && $entries === []) {
$legacyEntry = [];
foreach (['enabled', 'cron_expression', 'timezone', 'builder'] as $field) {
if (array_key_exists($field, $job)) {
$legacyEntry[$field] = $job[$field];
}
}
$entries = [$legacyEntry !== [] ? $legacyEntry : $defaultEntry];
}
if ($jobExists && $entries === [] && ($definition['mode'] ?? 'single') === 'multi') {
return [];
}
$result = [];
foreach (array_values($entries) as $entry) {
if (!is_array($entry)) {
continue;
}
$result[] = [
'enabled' => array_key_exists('enabled', $entry) ? $this->settingBool($entry['enabled'], (bool) $defaultEntry['enabled']) : (bool) $defaultEntry['enabled'],
'cron_expression' => trim((string) ($entry['cron_expression'] ?? $defaultEntry['cron_expression'])),
'timezone' => (($entryTimezone = trim((string) ($entry['timezone'] ?? ''))) !== '' ? $entryTimezone : $defaultEntry['timezone']),
'builder' => is_array($entry['builder'] ?? null) ? $entry['builder'] : [],
];
}
if ($result === []) {
$result[] = $defaultEntry;
}
if (($definition['mode'] ?? 'single') !== 'multi') {
return [0 => $result[0]];
}
return $result;
}
private function stateKey(string $jobName, int $entryIndex): string
{
return $jobName . '#' . $entryIndex;
}
private function fetchStates(string $moduleName): array
{
if (!$this->pdo) {
return [];
}
$stmt = $this->pdo->prepare(
'SELECT *
FROM nexus_module_cron_runs
WHERE module_name = :module_name'
);
$stmt->execute(['module_name' => $moduleName]);
$states = [];
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$name = trim((string) ($row['job_name'] ?? ''));
if ($name !== '') {
$states[$name] = $row;
}
}
return $states;
}
private function acquireLock(string $moduleName, string $jobName, int $lockMinutes): bool
{
if (!$this->pdo) {
return true;
}
$states = $this->fetchStates($moduleName);
$state = $states[$jobName] ?? [];
if ($this->isLockActive($state, time())) {
return false;
}
$lockUntil = gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60));
$this->persistState($moduleName, $jobName, [
'lock_until' => $lockUntil,
]);
return true;
}
private function persistState(string $moduleName, string $jobName, array $values): void
{
if (!$this->pdo) {
return;
}
$current = $this->fetchStates($moduleName)[$jobName] ?? [];
$payload = array_merge($current, $values, [
'module_name' => $moduleName,
'job_name' => $jobName,
'updated_at' => gmdate('Y-m-d H:i:s'),
]);
$params = [
'module_name' => $moduleName,
'job_name' => $jobName,
'last_scheduled_for' => $this->nullOrString($payload['last_scheduled_for'] ?? null),
'last_started_at' => $this->nullOrString($payload['last_started_at'] ?? null),
'last_finished_at' => $this->nullOrString($payload['last_finished_at'] ?? null),
'last_success_at' => $this->nullOrString($payload['last_success_at'] ?? null),
'last_status' => $this->nullOrString($payload['last_status'] ?? null),
'last_message' => $this->nullOrString($payload['last_message'] ?? null),
'lock_until' => $this->nullOrString($payload['lock_until'] ?? null),
'updated_at' => $payload['updated_at'],
];
$updateStmt = $this->pdo->prepare(
'UPDATE nexus_module_cron_runs
SET last_scheduled_for = :last_scheduled_for,
last_started_at = :last_started_at,
last_finished_at = :last_finished_at,
last_success_at = :last_success_at,
last_status = :last_status,
last_message = :last_message,
lock_until = :lock_until,
updated_at = :updated_at
WHERE module_name = :module_name
AND job_name = :job_name'
);
$updateStmt->execute($params);
if ($updateStmt->rowCount() > 0) {
return;
}
$insertStmt = $this->pdo->prepare(
'INSERT INTO nexus_module_cron_runs (
module_name,
job_name,
last_scheduled_for,
last_started_at,
last_finished_at,
last_success_at,
last_status,
last_message,
lock_until,
updated_at
) VALUES (
:module_name,
:job_name,
:last_scheduled_for,
:last_started_at,
:last_finished_at,
:last_success_at,
:last_status,
:last_message,
:lock_until,
:updated_at
)'
);
$insertStmt->execute($params);
}
private function nullOrString(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function settingBool(mixed $value, bool $default): bool
{
if ($value === null || $value === '') {
return $default;
}
return in_array((string) $value, ['1', 'true', 'yes', 'on'], true);
}
private function isLockActive(array $state, int $nowTs): bool
{
$lockUntil = trim((string) ($state['lock_until'] ?? ''));
if ($lockUntil === '') {
return false;
}
$lockTs = strtotime($lockUntil);
return $lockTs !== false && $lockTs > $nowTs;
}
private function parseUtc(string $value): ?DateTimeImmutable
{
$value = trim($value);
if ($value === '') {
return null;
}
try {
return new DateTimeImmutable($value, new DateTimeZone('UTC'));
} catch (\Throwable) {
return null;
}
}
private function safeTimezone(string $timezone): DateTimeZone
{
try {
return new DateTimeZone(trim($timezone) !== '' ? trim($timezone) : 'UTC');
} catch (\Throwable) {
return new DateTimeZone('UTC');
}
}
}

View File

@@ -0,0 +1,581 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleManager
{
private array $modules = [];
private array $callbacks = [];
private ?ModuleTaskScheduler $taskScheduler = null;
private ?ModuleCronScheduler $cronScheduler = null;
public function __construct(
private ?\PDO $basePdo,
private string $modulesPath
) {
if ($this->basePdo) {
BaseSchema::ensure($this->basePdo);
}
$this->scanModules();
}
public function all(): array
{
return $this->modules;
}
public function get(string $name): ?array
{
return $this->modules[$name] ?? null;
}
public function isEnabled(string $name): bool
{
$module = $this->get($name);
return (bool)($module['enabled'] ?? false);
}
public function setEnabled(string $name, bool $enabled): void
{
if (!$this->basePdo) {
return;
}
$module = $this->get($name);
if (!$module) {
return;
}
$stmt = $this->basePdo->prepare(
"INSERT INTO nexus_modules (name, title, version, enabled, installed_at, updated_at)
VALUES (:name, :title, :version, :enabled, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
enabled = excluded.enabled,
title = excluded.title,
version = excluded.version,
updated_at = CURRENT_TIMESTAMP"
);
$stmt->execute([
'name' => $name,
'title' => (string)($module['title'] ?? $name),
'version' => (string)($module['version'] ?? ''),
'enabled' => $enabled ? 1 : 0,
]);
$this->modules[$name]['enabled'] = $enabled;
}
public function migrationStatus(string $name): array
{
if (!$this->basePdo || !isset($this->modules[$name])) {
return ['available' => 0, 'applied' => 0, 'pending' => [], 'changed' => []];
}
return (new ModuleMigrationService($this->basePdo, $this))->status($this->modules[$name]);
}
public function applyPendingMigrations(string $name): array
{
if (!$this->basePdo || !isset($this->modules[$name])) {
return [];
}
return (new ModuleMigrationService($this->basePdo, $this))->applyPending($this->modules[$name]);
}
public function settings(string $name): array
{
if (!$this->basePdo) {
return [];
}
$stmt = $this->basePdo->prepare(
"SELECT settings FROM nexus_module_settings WHERE name = :name LIMIT 1"
);
$stmt->execute(['name' => $name]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || empty($row['settings'])) {
return [];
}
$decoded = json_decode((string)$row['settings'], true);
return is_array($decoded) ? $decoded : [];
}
public function saveSettings(string $name, array $settings): void
{
if (!$this->basePdo) {
return;
}
$payload = json_encode($settings, JSON_UNESCAPED_UNICODE);
if ($payload === false) {
return;
}
$stmt = $this->basePdo->prepare(
"INSERT INTO nexus_module_settings (name, settings, updated_at)
VALUES (:name, :settings, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
settings = excluded.settings,
updated_at = CURRENT_TIMESTAMP"
);
$stmt->execute([
'name' => $name,
'settings' => $payload,
]);
}
public function timezones(): array
{
if (!$this->basePdo) {
return $this->fallbackTimezones();
}
try {
$stmt = $this->basePdo->query(
"SELECT identifier, label, group_name
FROM nexus_timezones
ORDER BY group_name ASC, identifier ASC"
);
$rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : [];
if (!is_array($rows) || $rows === []) {
return $this->fallbackTimezones();
}
return array_values(array_filter(array_map(
static function (array $row): ?array {
$identifier = trim((string) ($row['identifier'] ?? ''));
if ($identifier === '') {
return null;
}
return [
'value' => $identifier,
'label' => trim((string) ($row['label'] ?? $identifier)),
'group' => trim((string) ($row['group_name'] ?? '')),
];
},
$rows
)));
} catch (\Throwable) {
return $this->fallbackTimezones();
}
}
public function modulePdo(string $name, array $fallback = []): ?\PDO
{
$settings = $this->settings($name);
$db = $settings['db'] ?? $fallback;
if (!is_array($db) || empty($db)) {
throw new ModuleConfigException(
$name,
'Modul nicht konfiguriert. Bitte Setup ausfuehren.'
);
}
if (!isset($db['options'])) {
$db['options'] = [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
];
}
try {
$pdo = Database::createFromArray($db);
} catch (\Throwable $e) {
if (defined('APP_DEBUG_TOOL') && APP_DEBUG_TOOL) {
@file_put_contents(
__DIR__ . '/../../debug/module_db_error.log',
'[' . date('c') . '] ' . $name . ': ' . $e->getMessage() . PHP_EOL,
FILE_APPEND
);
}
throw new ModuleConfigException(
$name,
'Modul-Datenbank nicht korrekt konfiguriert.',
$e->getMessage(),
0,
$e
);
}
if ($name === 'kea' && !empty($settings['kea_auto_init'])) {
try {
Database::ensureKeaSchema($pdo, [
'auto_init' => true,
'init_cmd' => $settings['kea_init_cmd'] ?? null,
'init_script' => $settings['kea_init_script'] ?? null,
'kea_db_version' => $settings['kea_db_version'] ?? '',
]);
} catch (\Throwable $e) {
if (defined('APP_DEBUG_TOOL') && APP_DEBUG_TOOL) {
@file_put_contents(
__DIR__ . '/../../debug/module_db_error.log',
'[' . date('c') . '] ' . $name . ': ' . $e->getMessage() . PHP_EOL,
FILE_APPEND
);
}
throw new ModuleConfigException(
$name,
'Modul-Datenbank nicht korrekt konfiguriert.',
$e->getMessage(),
0,
$e
);
}
}
return $pdo;
}
public function resolvePage(string $name, string $page): ?string
{
$module = $this->get($name);
if (!$module) {
return null;
}
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $page)) {
return null;
}
$path = $module['path'] . '/pages/' . $page . '.php';
if (is_file($path)) {
return $path;
}
return null;
}
public function bootEnabled(): void
{
foreach ($this->modules as $name => $module) {
if (!empty($module['enabled'])) {
$bootstrap = $module['path'] . '/bootstrap.php';
if (is_file($bootstrap)) {
$modules = $this;
require_once $bootstrap;
}
}
}
}
public function registerFunction(string $module, string $name, callable $fn): void
{
$key = $module . ':' . $name;
$this->callbacks[$key] = $fn;
}
public function call(string $module, string $name, mixed ...$args): mixed
{
$key = $module . ':' . $name;
if (!isset($this->callbacks[$key])) {
throw new \RuntimeException("Module callback not found: {$key}");
}
return ($this->callbacks[$key])(...$args);
}
public function hasFunction(string $module, string $name): bool
{
return isset($this->callbacks[$module . ':' . $name]);
}
public function intervalTasks(string $name): array
{
return $this->taskScheduler()->definitions($name);
}
public function intervalTaskStatuses(string $name): array
{
return $this->taskScheduler()->statuses($name);
}
public function runDueIntervalTasks(string $name): array
{
return $this->taskScheduler()->runDue($name);
}
public function cronTasks(string $name): array
{
return $this->cronScheduler()->definitions($name);
}
public function cronTaskStatuses(string $name): array
{
return $this->cronScheduler()->statuses($name);
}
public function runDueCronTasks(string $name): array
{
return $this->cronScheduler()->runDue($name);
}
public function runCronTaskNow(string $name, string $jobName, int $entryIndex): array
{
return $this->cronScheduler()->runNow($name, $jobName, $entryIndex);
}
private function scanModules(): void
{
$this->modules = [];
if (!is_dir($this->modulesPath)) {
if (defined('APP_DEBUG_TOOL') && APP_DEBUG_TOOL) {
@file_put_contents(__DIR__ . '/../../debug/module_scan.log', 'Modules path not found: ' . $this->modulesPath . PHP_EOL, FILE_APPEND);
}
return;
}
foreach (glob($this->modulesPath . '/*', GLOB_ONLYDIR) as $dir) {
$name = basename($dir);
$manifest = $dir . '/module.json';
if (!is_file($manifest)) {
continue;
}
$raw = file_get_contents($manifest);
$data = $raw ? json_decode($raw, true) : null;
if (!is_array($data)) {
continue;
}
$manifestAuth = is_array($data['auth'] ?? null) ? $data['auth'] : ['required' => false, 'users' => [], 'groups' => []];
$module = [
'name' => $name,
'slug' => $name,
'title' => $data['title'] ?? $data['name'] ?? $name,
'version' => $data['version'] ?? '',
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
'interval_tasks' => $data['interval_tasks'] ?? [],
'scheduler_jobs' => $data['scheduler_jobs'] ?? ($data['cron_tasks'] ?? []),
'cron_tasks' => $data['cron_tasks'] ?? [],
'menu' => $data['menu'] ?? [],
'sidebar' => $data['sidebar'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
'metadata_db_defaults' => $data['metadata_db_defaults'] ?? [],
'schema_version' => (int)($data['schema_version'] ?? 0),
'path' => $dir,
'entry' => '/module/' . rawurlencode($name),
'auth' => $this->loadAuth($name, $manifestAuth),
'enabled_by_default' => (bool)($data['enabled_by_default'] ?? false),
'enabled' => false,
];
$module['enabled'] = $this->loadEnabledState($name, $module);
$this->modules[$name] = $module;
}
if (defined('APP_DEBUG_TOOL') && APP_DEBUG_TOOL) {
@file_put_contents(__DIR__ . '/../../debug/module_scan.log', 'Modules loaded: ' . implode(', ', array_keys($this->modules)) . PHP_EOL, FILE_APPEND);
}
}
private function loadEnabledState(string $name, array $module): bool
{
if (!$this->basePdo) {
return (bool)($module['enabled_by_default'] ?? false);
}
$stmt = $this->basePdo->prepare(
"SELECT enabled FROM nexus_modules WHERE name = :name LIMIT 1"
);
$stmt->execute(['name' => $name]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($row !== false) {
return (bool)$row['enabled'];
}
$stmt = $this->basePdo->prepare(
"INSERT INTO nexus_modules (name, title, version, enabled, installed_at, updated_at)
VALUES (:name, :title, :version, :enabled, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
);
$stmt->bindValue(':name', $name, \PDO::PARAM_STR);
$stmt->bindValue(':title', (string)$module['title'], \PDO::PARAM_STR);
$stmt->bindValue(':version', (string)$module['version'], \PDO::PARAM_STR);
$enabledByDefault = (bool)($module['enabled_by_default'] ?? false);
$stmt->bindValue(':enabled', $enabledByDefault, \PDO::PARAM_BOOL);
$stmt->execute();
return $enabledByDefault;
}
private function fallbackTimezones(): array
{
$result = [];
foreach (timezone_identifiers_list() as $identifier) {
$parts = explode('/', $identifier, 2);
$result[] = [
'value' => $identifier,
'label' => $identifier === 'UTC' ? 'UTC' : str_replace('_', ' ', $identifier),
'group' => $parts[0] ?? 'Other',
];
}
return $result;
}
public function saveAuth(string $name, array $auth): array
{
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
throw new \RuntimeException('Invalid module name.');
}
$module = $this->get($name);
if (!$module) {
throw new \RuntimeException('Module not found.');
}
$authConfig = [
'required' => (bool) ($auth['required'] ?? false),
'users' => $this->normalizeList($auth['users'] ?? []),
'groups' => $this->normalizeList($auth['groups'] ?? []),
];
if ($this->basePdo) {
$usersJson = json_encode($authConfig['users'], JSON_UNESCAPED_UNICODE);
$groupsJson = json_encode($authConfig['groups'], JSON_UNESCAPED_UNICODE);
if (!is_string($usersJson) || !is_string($groupsJson)) {
throw new \RuntimeException('Could not encode module auth.');
}
$stmt = $this->basePdo->prepare(
"INSERT INTO nexus_module_auth (name, required, users, groups, updated_at)
VALUES (:name, :required, :users, :groups, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
required = excluded.required,
users = excluded.users,
groups = excluded.groups,
updated_at = CURRENT_TIMESTAMP"
);
$stmt->execute([
'name' => $name,
'required' => $authConfig['required'] ? 1 : 0,
'users' => $usersJson,
'groups' => $groupsJson,
]);
$this->modules[$name]['auth'] = $authConfig;
return $authConfig;
}
$this->modules[$name]['auth'] = $authConfig;
return $authConfig;
}
public function knownAuthUsers(): array
{
if (!$this->basePdo) {
return [];
}
$stmt = $this->basePdo->query(
"SELECT sub, username, email, name, groups, last_login_at
FROM nexus_auth_users
ORDER BY COALESCE(NULLIF(name, ''), NULLIF(username, ''), email, sub)"
);
$users = [];
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
$groups = json_decode((string)($row['groups'] ?? '[]'), true);
$row['groups'] = is_array($groups) ? array_values(array_filter(array_map('strval', $groups))) : [];
$users[] = $row;
}
return $users;
}
public function knownAuthGroups(): array
{
$groups = [];
foreach ($this->knownAuthUsers() as $user) {
foreach (($user['groups'] ?? []) as $group) {
$group = trim((string)$group);
if ($group !== '') {
$groups[] = $group;
}
}
}
foreach ($this->modules as $module) {
$auth = is_array($module['auth'] ?? null) ? $module['auth'] : [];
foreach (($auth['groups'] ?? []) as $group) {
$group = trim((string)$group);
if ($group !== '') {
$groups[] = $group;
}
}
}
sort($groups, SORT_NATURAL | SORT_FLAG_CASE);
return array_values(array_unique($groups));
}
private function loadAuth(string $name, array $fallback): array
{
$auth = [
'required' => (bool)($fallback['required'] ?? false),
'users' => $this->normalizeList($fallback['users'] ?? []),
'groups' => $this->normalizeList($fallback['groups'] ?? []),
];
if (!$this->basePdo) {
return $auth;
}
$stmt = $this->basePdo->prepare(
"SELECT required, users, groups
FROM nexus_module_auth
WHERE name = :name
LIMIT 1"
);
$stmt->execute(['name' => $name]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($row === false) {
return $auth;
}
$users = json_decode((string)($row['users'] ?? '[]'), true);
$groups = json_decode((string)($row['groups'] ?? '[]'), true);
return [
'required' => (bool)($row['required'] ?? false),
'users' => $this->normalizeList(is_array($users) ? $users : []),
'groups' => $this->normalizeList(is_array($groups) ? $groups : []),
];
}
private function normalizeList(mixed $values): array
{
if (is_string($values)) {
$values = preg_split('/[,\\n]+/', $values) ?: [];
}
if (!is_array($values)) {
$values = [$values];
}
$normalized = [];
foreach ($values as $value) {
$item = trim((string) $value);
if ($item !== '') {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
private function taskScheduler(): ModuleTaskScheduler
{
if (!$this->taskScheduler instanceof ModuleTaskScheduler) {
$this->taskScheduler = new ModuleTaskScheduler($this, $this->basePdo);
}
return $this->taskScheduler;
}
private function cronScheduler(): ModuleCronScheduler
{
if (!$this->cronScheduler instanceof ModuleCronScheduler) {
$this->cronScheduler = new ModuleCronScheduler($this, $this->basePdo);
}
return $this->cronScheduler;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleMigrationContext
{
public function __construct(
public readonly \PDO $basePdo,
public readonly ModuleManager $modules,
public readonly array $module
) {}
public function settings(): array
{
return $this->modules->settings((string)$this->module['name']);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleMigrationService
{
public function __construct(
private \PDO $basePdo,
private ModuleManager $modules
) {}
public function status(array $module): array
{
$migrations = $this->discover($module);
$applied = $this->applied((string)$module['name']);
$pending = [];
$changed = [];
foreach ($migrations as $migration) {
$id = $migration['id'];
if (!isset($applied[$id])) {
$pending[] = $migration;
continue;
}
if (($applied[$id]['checksum'] ?? '') !== $migration['checksum']) {
$changed[] = $migration;
}
}
return [
'available' => count($migrations),
'applied' => count($applied),
'pending' => $pending,
'changed' => $changed,
];
}
public function applyPending(array $module): array
{
$status = $this->status($module);
$applied = [];
$moduleName = (string)$module['name'];
$context = new ModuleMigrationContext($this->basePdo, $this->modules, $module);
foreach ($status['pending'] as $migration) {
$this->basePdo->beginTransaction();
try {
$runner = require $migration['path'];
if (is_object($runner) && method_exists($runner, 'up')) {
$runner->up($context);
} elseif (is_callable($runner)) {
$runner($context);
} else {
throw new \RuntimeException('Migration does not return a callable or object with up().');
}
$this->record($moduleName, $migration);
$this->basePdo->commit();
$applied[] = $migration;
} catch (\Throwable $e) {
if ($this->basePdo->inTransaction()) {
$this->basePdo->rollBack();
}
throw new \RuntimeException(
'Migration fehlgeschlagen: ' . $moduleName . '/' . $migration['id'] . ' - ' . $e->getMessage(),
0,
$e
);
}
}
if ($applied !== []) {
$this->recordModuleVersion($moduleName, (string)($module['version'] ?? ''));
}
return $applied;
}
private function discover(array $module): array
{
$path = (string)($module['path'] ?? '') . '/migrations';
if (!is_dir($path)) {
return [];
}
$items = [];
foreach (glob($path . '/*.php') ?: [] as $file) {
$id = basename($file, '.php');
$items[] = [
'id' => $id,
'version' => $this->versionFromId($id),
'path' => $file,
'checksum' => hash_file('sha256', $file) ?: '',
];
}
usort($items, static fn(array $a, array $b): int => strnatcmp($a['id'], $b['id']));
return $items;
}
private function applied(string $moduleName): array
{
$stmt = $this->basePdo->prepare(
"SELECT migration, version, checksum, applied_at
FROM nexus_module_migrations
WHERE module_name = :module
ORDER BY migration ASC"
);
$stmt->execute(['module' => $moduleName]);
$items = [];
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
$items[(string)$row['migration']] = $row;
}
return $items;
}
private function record(string $moduleName, array $migration): void
{
$driver = (string)$this->basePdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql' || $driver === 'sqlite') {
$sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at)
VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP)
ON CONFLICT(module_name, migration) DO UPDATE SET
version = excluded.version,
checksum = excluded.checksum,
applied_at = CURRENT_TIMESTAMP";
} else {
$sql = "INSERT INTO nexus_module_migrations (module_name, migration, version, checksum, applied_at)
VALUES (:module, :migration, :version, :checksum, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
version = VALUES(version),
checksum = VALUES(checksum),
applied_at = CURRENT_TIMESTAMP";
}
$stmt = $this->basePdo->prepare($sql);
$stmt->execute([
'module' => $moduleName,
'migration' => $migration['id'],
'version' => $migration['version'],
'checksum' => $migration['checksum'],
]);
}
private function recordModuleVersion(string $moduleName, string $version): void
{
if ($version === '') {
return;
}
$stmt = $this->basePdo->prepare(
"UPDATE nexus_modules
SET version = :version,
updated_at = CURRENT_TIMESTAMP
WHERE name = :module"
);
$stmt->execute([
'module' => $moduleName,
'version' => $version,
]);
}
private function versionFromId(string $id): string
{
if (preg_match('/(?:^|_)(\d+\.\d+\.\d+)(?:_|$)/', $id, $m)) {
return $m[1];
}
return '';
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class ModuleSqlImportService
{
public function __construct(private ModuleManager $modules)
{
}
public function importableModules(): array
{
$items = [];
foreach ($this->modules->all() as $name => $module) {
if ($this->supportsGenericImport($name, $module)) {
$items[] = [
'name' => $name,
'title' => (string) ($module['title'] ?? $name),
'description' => (string) ($module['description'] ?? ''),
];
}
}
usort($items, static fn (array $left, array $right): int => strcmp($left['title'], $right['title']));
return $items;
}
public function importUploadedFile(string $moduleName, array $uploadedFile): array
{
$module = $this->modules->get($moduleName);
if ($module === null) {
throw new \RuntimeException('Unbekanntes Modul.');
}
$file = UploadedSqlFile::read($uploadedFile);
$target = $this->resolveImportTarget($moduleName, $module);
$statementCount = (new SqlDataImporter($target['pdo']))->importString((string) $file['sql']);
return [
'module' => $moduleName,
'module_title' => (string) ($module['title'] ?? $moduleName),
'target_label' => $target['label'],
'file' => (string) $file['file'],
'statement_count' => $statementCount,
'message' => 'SQL-Datei wurde erfolgreich importiert.',
];
}
private function supportsGenericImport(string $moduleName, array $module): bool
{
if ($this->modules->hasFunction($moduleName, 'sql_import_target')) {
return true;
}
if ($this->modules->hasFunction($moduleName, 'pdo')) {
return true;
}
$settings = $this->modules->settings($moduleName);
if (is_array($settings['db'] ?? null) && !empty($settings['db'])) {
return true;
}
return is_array($module['db_defaults'] ?? null) && !empty($module['db_defaults']);
}
private function resolveImportTarget(string $moduleName, array $module): array
{
if ($this->modules->hasFunction($moduleName, 'sql_import_target')) {
return $this->normalizeTarget(
$moduleName,
$this->modules->call($moduleName, 'sql_import_target')
);
}
if ($this->modules->hasFunction($moduleName, 'pdo')) {
return $this->normalizeTarget(
$moduleName,
$this->modules->call($moduleName, 'pdo')
);
}
$fallback = is_array($module['db_defaults'] ?? null) ? $module['db_defaults'] : [];
$pdo = $this->modules->modulePdo($moduleName, $fallback);
if (!$pdo instanceof PDO) {
throw new \RuntimeException('Fuer dieses Modul ist keine SQL-Datenbank verfuegbar.');
}
return [
'pdo' => $pdo,
'label' => (string) ($module['title'] ?? $moduleName) . ' Datenbank',
];
}
private function normalizeTarget(string $moduleName, mixed $target): array
{
if ($target instanceof PDO) {
return [
'pdo' => $target,
'label' => $moduleName . ' Datenbank',
];
}
if (is_array($target) && ($target['pdo'] ?? null) instanceof PDO) {
return [
'pdo' => $target['pdo'],
'label' => (string) ($target['label'] ?? ($moduleName . ' Datenbank')),
];
}
throw new \RuntimeException('Ungueltiges SQL-Import-Ziel fuer Modul ' . $moduleName . '.');
}
}

View File

@@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleTaskScheduler
{
public function __construct(
private ModuleManager $modules,
private ?\PDO $pdo
) {
}
public function definitions(string $moduleName): array
{
$module = $this->modules->get($moduleName);
$tasks = is_array($module['interval_tasks'] ?? null) ? $module['interval_tasks'] : [];
$definitions = [];
foreach ($tasks as $task) {
if (!is_array($task)) {
continue;
}
$name = trim((string) ($task['name'] ?? ''));
$callback = trim((string) ($task['callback'] ?? ''));
if ($name === '' || $callback === '') {
continue;
}
$definitions[$name] = [
'name' => $name,
'label' => trim((string) ($task['label'] ?? $name)),
'callback' => $callback,
'enabled_setting' => trim((string) ($task['enabled_setting'] ?? '')),
'interval_setting' => trim((string) ($task['interval_setting'] ?? '')),
'default_enabled' => array_key_exists('default_enabled', $task) ? (bool) $task['default_enabled'] : false,
'default_interval_hours' => max(1.0, (float) ($task['default_interval_hours'] ?? 6)),
'lock_minutes' => max(1, (int) ($task['lock_minutes'] ?? 10)),
];
}
return $definitions;
}
public function statuses(string $moduleName): array
{
$definitions = $this->definitions($moduleName);
if ($definitions === []) {
return [];
}
$settings = $this->modules->settings($moduleName);
$states = $this->fetchStates($moduleName);
$nowTs = time();
$statuses = [];
foreach ($definitions as $name => $definition) {
$state = $states[$name] ?? [];
$enabledSetting = $definition['enabled_setting'];
$intervalSetting = $definition['interval_setting'];
$enabled = $definition['default_enabled'];
if ($enabledSetting !== '') {
$enabled = $this->settingBool($settings[$enabledSetting] ?? null, $definition['default_enabled']);
}
$intervalHours = $definition['default_interval_hours'];
if ($intervalSetting !== '') {
$intervalHours = $this->settingFloat($settings[$intervalSetting] ?? null, $definition['default_interval_hours']);
}
$intervalHours = max(1.0, $intervalHours);
$referenceAt = trim((string) ($state['last_success_at'] ?? ''));
$referenceTs = $referenceAt !== '' ? strtotime($referenceAt) : false;
$nextDueTs = $referenceTs !== false ? ((int) $referenceTs + (int) round($intervalHours * 3600)) : null;
$isLocked = $this->isLockActive($state, $nowTs);
$isDue = $enabled && !$isLocked && ($nextDueTs === null || $nextDueTs <= $nowTs);
$statuses[] = $definition + [
'enabled' => $enabled,
'interval_hours' => $intervalHours,
'state' => $state,
'is_due' => $isDue,
'is_locked' => $isLocked,
'next_due_at' => $nextDueTs !== null ? gmdate('Y-m-d H:i:s', $nextDueTs) : null,
];
}
return $statuses;
}
public function runDue(string $moduleName): array
{
$results = [];
foreach ($this->statuses($moduleName) as $task) {
if (empty($task['enabled']) || empty($task['is_due'])) {
continue;
}
if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
$results[] = [
'task' => $task['name'],
'ok' => false,
'message' => 'Callback nicht registriert.',
];
continue;
}
if (!$this->acquireLock($moduleName, (string) $task['name'], (int) $task['lock_minutes'])) {
continue;
}
$startedAt = gmdate('Y-m-d H:i:s');
$this->persistState($moduleName, (string) $task['name'], [
'last_started_at' => $startedAt,
'last_status' => 'running',
'last_message' => 'Automatischer Lauf gestartet.',
]);
try {
$result = $this->modules->call($moduleName, (string) $task['callback'], [
'task' => $task,
'trigger' => 'interval_runner',
'started_at' => $startedAt,
]);
$ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
$skipped = is_array($result) && !empty($result['skipped']);
$message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
$finishedAt = gmdate('Y-m-d H:i:s');
$payload = [
'last_finished_at' => $finishedAt,
'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
'last_message' => $message,
'lock_until' => null,
];
if ($ok && !$skipped) {
$payload['last_success_at'] = $finishedAt;
}
$this->persistState($moduleName, (string) $task['name'], $payload);
$results[] = [
'task' => $task['name'],
'ok' => $ok,
'message' => $message,
];
} catch (\Throwable $e) {
$finishedAt = gmdate('Y-m-d H:i:s');
$this->persistState($moduleName, (string) $task['name'], [
'last_finished_at' => $finishedAt,
'last_status' => 'error',
'last_message' => $e->getMessage(),
'lock_until' => null,
]);
$results[] = [
'task' => $task['name'],
'ok' => false,
'message' => $e->getMessage(),
];
}
}
return $results;
}
private function fetchStates(string $moduleName): array
{
if (!$this->pdo) {
return [];
}
$stmt = $this->pdo->prepare(
"SELECT *
FROM nexus_module_task_runs
WHERE module_name = :module_name"
);
$stmt->execute(['module_name' => $moduleName]);
$states = [];
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$name = trim((string) ($row['task_name'] ?? ''));
if ($name !== '') {
$states[$name] = $row;
}
}
return $states;
}
private function acquireLock(string $moduleName, string $taskName, int $lockMinutes): bool
{
if (!$this->pdo) {
return true;
}
$states = $this->fetchStates($moduleName);
$state = $states[$taskName] ?? [];
if ($this->isLockActive($state, time())) {
return false;
}
$lockUntil = gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60));
$this->persistState($moduleName, $taskName, [
'lock_until' => $lockUntil,
]);
return true;
}
private function persistState(string $moduleName, string $taskName, array $values): void
{
if (!$this->pdo) {
return;
}
$current = $this->fetchStates($moduleName)[$taskName] ?? [];
$payload = array_merge($current, $values, [
'module_name' => $moduleName,
'task_name' => $taskName,
'updated_at' => gmdate('Y-m-d H:i:s'),
]);
$params = [
'module_name' => $moduleName,
'task_name' => $taskName,
'last_started_at' => $this->nullOrString($payload['last_started_at'] ?? null),
'last_finished_at' => $this->nullOrString($payload['last_finished_at'] ?? null),
'last_success_at' => $this->nullOrString($payload['last_success_at'] ?? null),
'last_status' => $this->nullOrString($payload['last_status'] ?? null),
'last_message' => $this->nullOrString($payload['last_message'] ?? null),
'lock_until' => $this->nullOrString($payload['lock_until'] ?? null),
'updated_at' => $payload['updated_at'],
];
$updateStmt = $this->pdo->prepare(
"UPDATE nexus_module_task_runs
SET last_started_at = :last_started_at,
last_finished_at = :last_finished_at,
last_success_at = :last_success_at,
last_status = :last_status,
last_message = :last_message,
lock_until = :lock_until,
updated_at = :updated_at
WHERE module_name = :module_name
AND task_name = :task_name"
);
$updateStmt->execute($params);
if ($updateStmt->rowCount() > 0) {
return;
}
$insertStmt = $this->pdo->prepare(
"INSERT INTO nexus_module_task_runs (
module_name,
task_name,
last_started_at,
last_finished_at,
last_success_at,
last_status,
last_message,
lock_until,
updated_at
) VALUES (
:module_name,
:task_name,
:last_started_at,
:last_finished_at,
:last_success_at,
:last_status,
:last_message,
:lock_until,
:updated_at
)"
);
$insertStmt->execute($params);
}
private function nullOrString(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function settingBool(mixed $value, bool $default): bool
{
if ($value === null || $value === '') {
return $default;
}
return in_array((string) $value, ['1', 'true', 'yes', 'on'], true);
}
private function settingFloat(mixed $value, float $default): float
{
if (!is_numeric($value)) {
return $default;
}
$numeric = (float) $value;
return $numeric > 0 ? $numeric : $default;
}
private function isLockActive(array $state, int $nowTs): bool
{
$lockUntil = trim((string) ($state['lock_until'] ?? ''));
if ($lockUntil === '') {
return false;
}
$lockTs = strtotime($lockUntil);
return $lockTs !== false && $lockTs > $nowTs;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App;
final class OidcClient
{
public function __construct(private Config $config) {}
public function authUrl(string $state, string $nonce): string
{
$params = [
'client_id' => $this->config->oidcClientId,
'response_type' => 'code',
'scope' => 'openid profile email groups',
'redirect_uri' => $this->config->oidcRedirectUri,
'state' => $state,
'nonce' => $nonce,
];
$endpoint = $this->endpoint('auth');
return $endpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}
public function exchangeCode(string $code): array
{
$endpoint = $this->endpoint('token');
$post = [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->config->oidcRedirectUri,
'client_id' => $this->config->oidcClientId,
];
if ($this->config->oidcClientSecret !== '') {
$post['client_secret'] = $this->config->oidcClientSecret;
}
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new \RuntimeException('OIDC token request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($raw, true);
if (!is_array($data)) {
throw new \RuntimeException('OIDC token response invalid.');
}
if ($status >= 400) {
$msg = $data['error_description'] ?? $data['error'] ?? 'OIDC token error';
throw new \RuntimeException($msg);
}
return $data;
}
public function decodeJwt(string $jwt): array
{
$parts = explode('.', $jwt);
if (count($parts) < 2) {
throw new \RuntimeException('Invalid JWT');
}
$payload = $this->b64url_decode($parts[1]);
$data = json_decode($payload, true);
if (!is_array($data)) {
throw new \RuntimeException('Invalid JWT payload');
}
return $data;
}
public function validateIdToken(array $claims, string $nonce): void
{
if (!empty($this->config->oidcIssuer) && ($claims['iss'] ?? '') !== $this->config->oidcIssuer) {
throw new \RuntimeException('Invalid token issuer');
}
$aud = $claims['aud'] ?? null;
if (is_array($aud)) {
if (!in_array($this->config->oidcClientId, $aud, true)) {
throw new \RuntimeException('Invalid token audience');
}
} elseif (is_string($aud)) {
if ($aud !== $this->config->oidcClientId) {
throw new \RuntimeException('Invalid token audience');
}
}
if (!empty($claims['nonce']) && $claims['nonce'] !== $nonce) {
throw new \RuntimeException('Invalid token nonce');
}
if (!empty($claims['exp']) && time() >= (int)$claims['exp']) {
throw new \RuntimeException('Token expired');
}
}
public function groupsFromClaims(array $claims): array
{
$claim = $this->config->oidcGroupClaim ?: 'groups';
$value = $claims[$claim] ?? [];
if (is_string($value)) {
$value = [$value];
}
if (!is_array($value)) {
return [];
}
return array_values(array_filter(array_map('strval', $value)));
}
public function logoutUrl(?string $idToken): ?string
{
$endpoint = $this->config->oidcLogoutEndpoint;
if ($endpoint === '') {
return null;
}
$params = [
'post_logout_redirect_uri' => $this->config->oidcPostLogoutRedirectUri !== ''
? $this->config->oidcPostLogoutRedirectUri
: ($this->config->oidcRedirectUri ? dirname($this->config->oidcRedirectUri) : '/'),
];
if ($idToken) {
$params['id_token_hint'] = $idToken;
}
return $endpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}
private function endpoint(string $type): string
{
return match ($type) {
'auth' => $this->config->oidcAuthEndpoint !== '' ? $this->config->oidcAuthEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/auth',
'token' => $this->config->oidcTokenEndpoint !== '' ? $this->config->oidcTokenEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/token',
'userinfo' => $this->config->oidcUserinfoEndpoint !== '' ? $this->config->oidcUserinfoEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/userinfo',
default => throw new \RuntimeException('Unknown OIDC endpoint'),
};
}
private function b64url_decode(string $data): string
{
$data = strtr($data, '-_', '+/');
$pad = strlen($data) % 4;
if ($pad) {
$data .= str_repeat('=', 4 - $pad);
}
return base64_decode($data) ?: '';
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App;
final class RedisClient
{
private string $host;
private int $port;
private ?string $password;
private int $db;
private ?\Socket $socket = null;
private $stream = null;
public function __construct(string $host, int $port = 6379, ?string $password = null, int $db = 0)
{
$this->host = $host;
$this->port = $port;
$this->password = $password;
$this->db = $db;
}
private function connect(): void
{
if ($this->stream) {
return;
}
$errNo = 0;
$errStr = '';
$this->stream = stream_socket_client(
"tcp://{$this->host}:{$this->port}",
$errNo,
$errStr,
3.0,
STREAM_CLIENT_CONNECT
);
if (!$this->stream) {
throw new \RuntimeException("Redis connect failed: {$errStr}");
}
stream_set_timeout($this->stream, 3);
if ($this->password !== null && $this->password !== '') {
$this->command(['AUTH', $this->password]);
}
if ($this->db > 0) {
$this->command(['SELECT', (string)$this->db]);
}
}
public function command(array $args)
{
$this->connect();
$payload = '*' . count($args) . "\r\n";
foreach ($args as $arg) {
$arg = (string)$arg;
$payload .= '$' . strlen($arg) . "\r\n" . $arg . "\r\n";
}
fwrite($this->stream, $payload);
return $this->readResponse();
}
private function readResponse()
{
$line = $this->readLine();
if ($line === '') {
if ($this->stream) {
fclose($this->stream);
$this->stream = null;
}
throw new \RuntimeException('Redis empty response');
}
$type = $line[0];
$payload = substr($line, 1);
switch ($type) {
case '+':
return $payload;
case '-':
throw new \RuntimeException('Redis error: ' . $payload);
case ':':
return (int)$payload;
case '$':
$len = (int)$payload;
if ($len === -1) {
return null;
}
$data = $this->readBytes($len);
$this->readLine();
return $data;
case '*':
$count = (int)$payload;
if ($count === -1) {
return null;
}
$items = [];
for ($i = 0; $i < $count; $i++) {
$items[] = $this->readResponse();
}
return $items;
default:
throw new \RuntimeException('Redis protocol error');
}
}
private function readLine(): string
{
$line = '';
while (!feof($this->stream)) {
$chunk = fgets($this->stream);
if ($chunk === false) {
break;
}
$line .= $chunk;
if (str_ends_with($line, "\r\n")) {
break;
}
}
return rtrim($line, "\r\n");
}
private function readBytes(int $length): string
{
$data = '';
$remaining = $length;
while ($remaining > 0 && !feof($this->stream)) {
$chunk = fread($this->stream, $remaining);
if ($chunk === false || $chunk === '') {
break;
}
$data .= $chunk;
$remaining -= strlen($chunk);
}
return $data;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App;
final class Request
{
public function scheme(): string
{
// Proxy / LB
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$proto = strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']);
if ($proto === 'https' || $proto === 'http') {
return $proto;
}
}
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return 'https';
}
return 'http';
}
public function host(): string
{
return (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
}
public function baseUrl(): string
{
return $this->scheme() . '://' . $this->host();
}
public function path(): string
{
return (string) strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
}
public function currentUrl(bool $withQuery = true): string
{
$base = $this->baseUrl();
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
if ($withQuery) {
return $base . $uri;
}
return $base . (string) strtok($uri, '?');
}
}

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App;
final class Search
{
public function __construct(private ?\PDO $pdo) {}
public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array
{
if (!$this->pdo) return [];
$q = trim($query);
$hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']);
if ($q === '' && !$hasGeo) return [];
$tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== '');
if (!$tokens) {
$tokens = [$q];
}
// Nur Tokens ab 3 Zeichen für fuzzy/LIKE berücksichtigen
$tokens = array_values(array_filter($tokens, fn($t) => mb_strlen($t) >= 3));
if (!$tokens && !$hasGeo) return [];
$conditions = [];
$bindTokens = [];
$i = 0;
foreach ($tokens as $tok) {
$tok = trim($tok);
if ($tok === '') continue;
// LIKE + phonetic (SOUNDEX) to allow partial and typo-tolerant matches
$conditions[] = "(title LIKE CONCAT('%', ?, '%') OR teaser_public LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%') OR city LIKE CONCAT('%', ?, '%') OR region LIKE CONCAT('%', ?, '%') OR zip LIKE CONCAT('%', ?, '%') OR SOUNDEX(title)=SOUNDEX(?) OR SOUNDEX(teaser_public)=SOUNDEX(?) OR SOUNDEX(description)=SOUNDEX(?) OR SOUNDEX(city)=SOUNDEX(?) OR SOUNDEX(region)=SOUNDEX(?))";
// LIKE bindings
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
// SOUNDEX bindings
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$i++;
}
$whereParts = [
"starts_at >= NOW()",
"status != 'cancelled'",
];
if ($conditions) {
// "OR" so that partial matches across tokens are allowed
$whereParts[] = '(' . implode(' OR ', $conditions) . ')';
}
$distanceFiltering = false;
$bind = [];
if ($hasGeo) {
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
(6371 * ACOS(LEAST(1,
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
SIN(RADIANS(?)) * SIN(RADIANS(lat))
))) AS distance_km";
$lat = (float)$geo['lat'];
$lng = (float)$geo['lng'];
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
$distanceFiltering = true;
$latRange = $radius / 111.0;
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
$whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
$whereParts[] = "(lat BETWEEN ? AND ?)";
$whereParts[] = "(lng BETWEEN ? AND ?)";
// Haversine params (order must match SQL): first three
$bind[] = $lat; // COS(RADIANS(?))
$bind[] = $lng; // COS(RADIANS(lng) - RADIANS(?))
$bind[] = $lat; // SIN(RADIANS(?))
// THEN token binds
$bind = array_merge($bind, $bindTokens);
// Bounding box
$bind[] = $lat - $latRange;
$bind[] = $lat + $latRange;
$bind[] = $lng - $lngRange;
$bind[] = $lng + $lngRange;
// Radius for HAVING
$bind[] = $radius;
} else {
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, NULL AS distance_km";
$bind = $bindTokens;
}
$where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : '';
$sql .= " FROM events $where";
if ($distanceFiltering) {
$sql .= " HAVING distance_km <= ?";
$sql .= " ORDER BY distance_km ASC, starts_at ASC";
} else {
$sql .= " ORDER BY starts_at ASC";
}
$limit = (int)$limit;
$sql .= " LIMIT {$limit}";
$stmt = $this->pdo->prepare($sql);
try {
$stmt->execute($bind);
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if (!$hasGeo) {
foreach ($rows as &$r) {
unset($r['distance_km']);
}
unset($r);
}
// Fuzzy filter: allow slight typos (Levenshtein <= 1 or 2)
if ($tokens) {
$rows = array_values(array_filter($rows, function ($row) use ($tokens) {
$haystack = strtolower(
($row['title'] ?? '') . ' ' .
($row['teaser_public'] ?? '') . ' ' .
($row['description'] ?? '') . ' ' .
($row['city'] ?? '') . ' ' .
($row['region'] ?? '')
);
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
foreach ($tokens as $tok) {
$t = strtolower($tok);
if ($t === '') continue;
if (str_contains($haystack, $t)) {
return true;
}
foreach ($words as $w) {
if ($w === '') continue;
$dist = levenshtein($t, $w);
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
return true;
}
}
}
return false;
}));
}
// Fallback: wenn keine Treffer, erneut ohne Token-Filter laden und nur fuzzy filtern
if (!$rows && $tokens) {
$wherePartsFallback = [
"starts_at >= NOW()",
"status != 'cancelled'",
];
$bindFb = [];
$sqlFb = '';
if ($hasGeo) {
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
(6371 * ACOS(LEAST(1,
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
SIN(RADIANS(?)) * SIN(RADIANS(lat))
))) AS distance_km";
$lat = (float)$geo['lat'];
$lng = (float)$geo['lng'];
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
$latRange = $radius / 111.0;
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
$wherePartsFallback[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
$wherePartsFallback[] = "(lat BETWEEN ? AND ?)";
$wherePartsFallback[] = "(lng BETWEEN ? AND ?)";
$bindFb[] = $lat;
$bindFb[] = $lng;
$bindFb[] = $lat;
$bindFb[] = $lat - $latRange;
$bindFb[] = $lat + $latRange;
$bindFb[] = $lng - $lngRange;
$bindFb[] = $lng + $lngRange;
$bindFb[] = $radius;
$havingFb = true;
} else {
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, 1 AS distance_km";
$havingFb = false;
}
$whereFb = $wherePartsFallback ? ('WHERE ' . implode(' AND ', $wherePartsFallback)) : '';
$sqlFb .= " FROM events $whereFb";
if ($havingFb) {
$sqlFb .= " HAVING distance_km <= ?";
$sqlFb .= " ORDER BY distance_km ASC, starts_at ASC";
} else {
$sqlFb .= " ORDER BY starts_at ASC";
}
$sqlFb .= " LIMIT {$limit}";
$stmtFb = $this->pdo->prepare($sqlFb);
$stmtFb->execute($bindFb);
$rowsFb = $stmtFb->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if ($rowsFb) {
$rows = array_values(array_filter($rowsFb, function ($row) use ($tokens) {
$haystack = strtolower(
($row['title'] ?? '') . ' ' .
($row['teaser_public'] ?? '') . ' ' .
($row['description'] ?? '') . ' ' .
($row['city'] ?? '') . ' ' .
($row['region'] ?? '')
);
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
foreach ($tokens as $tok) {
$t = strtolower($tok);
if ($t === '') continue;
if (str_contains($haystack, $t)) {
return true;
}
foreach ($words as $w) {
if ($w === '') continue;
$dist = levenshtein($t, $w);
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
return true;
}
}
}
return false;
}));
}
}
if (defined('APP_SEARCH_DEBUG') && APP_SEARCH_DEBUG) {
$logOk = [
'status' => 'ok',
'sql' => $sql,
'bind' => $bind,
'count' => count($rows),
'fallback' => ($rows ? 'primary' : 'fallback'),
];
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logOk, true));
}
return $rows;
} catch (\PDOException $e) {
// Log into /debug/search_debug.log and continue with empty results
$logErr = [
'error' => $e->getMessage(),
'sql' => $sql,
'bind' => $bind,
];
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logErr, true));
return [];
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App;
final class SessionManager
{
private const SESSION_TTL = 604800;
private string $sessionCookieName;
private string $clientCookieName;
public function __construct(private Config $config)
{
$prefix = $config->cookiePrefix();
$this->sessionCookieName = $prefix . 'session';
$this->clientCookieName = $prefix . 'client';
}
public function start(): void
{
if (PHP_SAPI === 'cli') {
return;
}
if (session_status() !== PHP_SESSION_NONE) {
return;
}
session_name($this->sessionCookieName);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
session_set_cookie_params([
'lifetime' => self::SESSION_TTL,
'path' => '/',
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$this->extendSessionCookie($secure);
}
public function ensureClientId(int $lifetimeSeconds = 31536000): string
{
if (PHP_SAPI === 'cli') {
return 'cli';
}
$id = $_COOKIE[$this->clientCookieName] ?? null;
if (!is_string($id) || !preg_match('/^[a-f0-9]{64}$/', $id)) {
$id = bin2hex(random_bytes(32));
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
setcookie($this->clientCookieName, $id, [
'expires' => time() + $lifetimeSeconds,
'path' => '/',
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => false, // accessible to JS if needed
'samesite' => 'Lax',
]);
$_COOKIE[$this->clientCookieName] = $id;
}
return $id;
}
private function extendSessionCookie(bool $secure): void
{
$name = session_name();
$value = session_id();
if ($name === '' || $value === '') {
return;
}
setcookie($name, $value, [
'expires' => time() + self::SESSION_TTL,
'path' => '/',
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
}
}

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class SqlDataExporter
{
public function export(PDO $pdo, string $label = 'nexus'): string
{
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$tables = $this->orderedTables($pdo, $driver);
$lines = [
'-- Nexus SQL data export',
'-- Generated at: ' . gmdate('c'),
'-- Driver: ' . $driver,
'-- Label: ' . $label,
'-- Restore target: empty database with existing schema/migrations.',
'',
'BEGIN;',
'',
];
foreach ($tables as $table) {
$columns = $this->columns($pdo, $driver, $table);
if ($columns === []) {
continue;
}
$lines[] = '-- Table: ' . $table;
$quotedTable = $this->quoteIdentifier($table, $driver);
$quotedColumns = array_map(fn(string $column): string => $this->quoteIdentifier($column, $driver), $columns);
$select = 'SELECT ' . implode(', ', $quotedColumns) . ' FROM ' . $quotedTable;
$count = 0;
$stmt = $pdo->query($select);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$values = [];
foreach ($columns as $column) {
$values[] = $this->quoteValue($pdo, $row[$column] ?? null);
}
$lines[] = 'INSERT INTO ' . $quotedTable . ' (' . implode(', ', $quotedColumns) . ') VALUES (' . implode(', ', $values) . ');';
$count++;
}
$lines[] = '-- Rows: ' . $count;
foreach ($this->sequenceSetvalLines($pdo, $driver, $table, $columns) as $sequenceLine) {
$lines[] = $sequenceLine;
}
$lines[] = '';
}
$lines[] = 'COMMIT;';
$lines[] = '';
return implode("\n", $lines);
}
private function orderedTables(PDO $pdo, string $driver): array
{
$tables = $this->tables($pdo, $driver);
$dependencies = $this->tableDependencies($pdo, $driver, $tables);
$ordered = [];
$remaining = array_fill_keys($tables, true);
while ($remaining !== []) {
$progress = false;
foreach (array_keys($remaining) as $table) {
$parents = array_filter(
$dependencies[$table] ?? [],
static fn(string $parent): bool => isset($remaining[$parent])
);
if ($parents !== []) {
continue;
}
$ordered[] = $table;
unset($remaining[$table]);
$progress = true;
}
if (!$progress) {
foreach (array_keys($remaining) as $table) {
$ordered[] = $table;
unset($remaining[$table]);
}
}
}
return $ordered;
}
private function tables(PDO $pdo, string $driver): array
{
if ($driver === 'pgsql') {
$stmt = $pdo->query(
"SELECT table_name
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_type = 'BASE TABLE'
ORDER BY table_name"
);
return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
}
if ($driver === 'mysql') {
$stmt = $pdo->query(
"SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_type = 'BASE TABLE'
ORDER BY table_name"
);
return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
}
if ($driver === 'sqlite') {
$stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
}
throw new \RuntimeException('SQL export supports pgsql, mysql and sqlite only.');
}
private function columns(PDO $pdo, string $driver, string $table): array
{
if ($driver === 'pgsql') {
$stmt = $pdo->prepare(
"SELECT column_name
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = :table
ORDER BY ordinal_position"
);
$stmt->execute(['table' => $table]);
return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
}
if ($driver === 'mysql') {
$stmt = $pdo->prepare(
"SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table
ORDER BY ordinal_position"
);
$stmt->execute(['table' => $table]);
return array_values(array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN)));
}
if ($driver === 'sqlite') {
$stmt = $pdo->query('PRAGMA table_info(' . $this->quoteIdentifier($table, $driver) . ')');
$columns = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$columns[] = (string)$row['name'];
}
return $columns;
}
return [];
}
private function tableDependencies(PDO $pdo, string $driver, array $tables): array
{
$known = array_fill_keys($tables, true);
$dependencies = [];
foreach ($tables as $table) {
$dependencies[$table] = [];
}
if ($driver === 'pgsql') {
$stmt = $pdo->query(
"SELECT tc.table_name AS child_table, ccu.table_name AS parent_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = current_schema()
AND ccu.table_schema = current_schema()"
);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$child = (string)$row['child_table'];
$parent = (string)$row['parent_table'];
if (isset($known[$child], $known[$parent]) && $child !== $parent) {
$dependencies[$child][] = $parent;
}
}
}
if ($driver === 'mysql') {
$stmt = $pdo->query(
"SELECT table_name AS child_table, referenced_table_name AS parent_table
FROM information_schema.key_column_usage
WHERE table_schema = DATABASE()
AND referenced_table_name IS NOT NULL"
);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$child = (string)$row['child_table'];
$parent = (string)$row['parent_table'];
if (isset($known[$child], $known[$parent]) && $child !== $parent) {
$dependencies[$child][] = $parent;
}
}
}
if ($driver === 'sqlite') {
foreach ($tables as $table) {
$stmt = $pdo->query('PRAGMA foreign_key_list(' . $this->quoteIdentifier($table, $driver) . ')');
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$parent = (string)($row['table'] ?? '');
if (isset($known[$parent]) && $parent !== $table) {
$dependencies[$table][] = $parent;
}
}
}
}
foreach ($dependencies as $table => $parents) {
$dependencies[$table] = array_values(array_unique($parents));
}
return $dependencies;
}
private function quoteIdentifier(string $identifier, string $driver): string
{
$parts = explode('.', $identifier);
$quote = $driver === 'mysql' ? '`' : '"';
return implode('.', array_map(
static fn(string $part): string => $quote . str_replace($quote, $quote . $quote, $part) . $quote,
$parts
));
}
private function sequenceSetvalLines(PDO $pdo, string $driver, string $table, array $columns): array
{
if ($driver !== 'pgsql') {
return [];
}
$lines = [];
foreach ($columns as $column) {
$sequenceStmt = $pdo->prepare('SELECT pg_get_serial_sequence(:table, :column)');
$sequenceStmt->execute(['table' => $table, 'column' => $column]);
$sequence = (string)($sequenceStmt->fetchColumn() ?: '');
if ($sequence === '') {
continue;
}
$maxStmt = $pdo->query(
'SELECT MAX(' . $this->quoteIdentifier($column, $driver) . ') FROM ' . $this->quoteIdentifier($table, $driver)
);
$max = $maxStmt->fetchColumn();
$maxValue = is_numeric($max) ? (int)$max : 0;
if ($maxValue > 0) {
$lines[] = 'SELECT setval(' . $this->quoteValue($pdo, $sequence) . ', ' . $maxValue . ', true);';
} else {
$lines[] = 'SELECT setval(' . $this->quoteValue($pdo, $sequence) . ', 1, false);';
}
}
return $lines;
}
private function quoteValue(PDO $pdo, mixed $value): string
{
if ($value === null) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? 'TRUE' : 'FALSE';
}
if (is_int($value) || is_float($value)) {
return is_finite((float)$value) ? (string)$value : 'NULL';
}
if (is_resource($value)) {
$value = stream_get_contents($value);
}
return $pdo->quote((string)$value);
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class SqlDataImporter
{
private PDO $pdo;
private string $driver;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
$this->driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
}
public function importString(string $sql): int
{
$statements = [];
foreach ($this->splitStatements($sql) as $statement) {
$trimmed = trim($statement);
if ($trimmed === '') {
continue;
}
$statements[] = $this->normalizeImportStatement($trimmed);
}
return $this->executeStatements($statements);
}
/**
* @param list<string> $statements
*/
private function executeStatements(array $statements): int
{
$useTransaction = $this->driver === 'pgsql' && !$this->pdo->inTransaction();
try {
if ($useTransaction) {
$this->pdo->beginTransaction();
}
$executed = $this->executeImportPass($statements);
if ($useTransaction && $this->pdo->inTransaction()) {
$this->pdo->commit();
}
return $executed;
} catch (\Throwable $exception) {
if ($useTransaction && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
/**
* @param list<string> $statements
*/
private function executeImportPass(array $statements): int
{
$pendingStatements = $statements;
$executed = 0;
$maxPasses = max(1, count($pendingStatements));
for ($pass = 0; $pass < $maxPasses && $pendingStatements !== []; $pass++) {
$deferredStatements = [];
$progressMade = false;
foreach ($pendingStatements as $statement) {
try {
$this->execStatementWithRecovery($statement);
$executed++;
$progressMade = true;
} catch (\Throwable $exception) {
if ($this->shouldRetryDeferredImportStatement($exception, $statement)) {
$deferredStatements[] = $statement;
continue;
}
throw new \RuntimeException($statement, 0, $exception);
}
}
if ($deferredStatements === []) {
return $executed;
}
if (!$progressMade) {
try {
$this->execStatementWithRecovery($deferredStatements[0]);
} catch (\Throwable $exception) {
throw new \RuntimeException($deferredStatements[0], 0, $exception);
}
}
$pendingStatements = $deferredStatements;
}
return $executed;
}
private function execStatementWithRecovery(string $statement): void
{
if ($this->driver !== 'pgsql' || !$this->pdo->inTransaction()) {
$this->pdo->exec($statement);
return;
}
$savepoint = 'sql_import_' . substr(sha1($statement . microtime(true)), 0, 12);
$this->pdo->exec('SAVEPOINT ' . $savepoint);
try {
$this->pdo->exec($statement);
$this->pdo->exec('RELEASE SAVEPOINT ' . $savepoint);
} catch (\Throwable $exception) {
try {
$this->pdo->exec('ROLLBACK TO SAVEPOINT ' . $savepoint);
$this->pdo->exec('RELEASE SAVEPOINT ' . $savepoint);
} catch (\Throwable) {
}
throw $exception;
}
}
private function shouldRetryDeferredImportStatement(\Throwable $exception, string $statement): bool
{
if ($this->driver !== 'pgsql') {
return false;
}
if (!preg_match('/^(INSERT|COPY)\b/i', ltrim($statement))) {
return false;
}
return (string) $exception->getCode() === '23503';
}
private function normalizeImportStatement(string $statement): string
{
if ($this->driver !== 'pgsql') {
return $statement;
}
$resolvedSetval = $this->normalizePgsqlSetvalStatement($statement);
return $resolvedSetval ?? $statement;
}
private function normalizePgsqlSetvalStatement(string $statement): ?string
{
$pattern = "/^SELECT\\s+setval\\(\\s*'([^']+)'\\s*,\\s*([0-9]+)\\s*,\\s*(true|false)\\s*\\)$/i";
if (!preg_match($pattern, trim($statement), $matches)) {
return null;
}
$sequenceReference = $matches[1];
$nextValue = $matches[2];
$isCalled = strtolower($matches[3]);
if ($this->pgsqlRelationExists($sequenceReference)) {
return $statement;
}
$sequenceName = $sequenceReference;
$schemaName = 'public';
if (str_contains($sequenceReference, '.')) {
[$schemaName, $sequenceName] = explode('.', $sequenceReference, 2);
}
$schemaName = trim($schemaName, "\"'");
$sequenceName = trim($sequenceName, "\"'");
if (!preg_match('/^(.*)_([A-Za-z0-9]+)_seq\d*$/', $sequenceName, $parts)) {
return null;
}
$tableName = $parts[1];
$columnName = $parts[2];
$actualSequence = $this->resolvePgsqlSerialSequence($schemaName, $tableName, $columnName);
if ($actualSequence === null) {
return null;
}
return sprintf("SELECT setval('%s', %s, %s)", $actualSequence, $nextValue, $isCalled);
}
private function pgsqlRelationExists(string $qualifiedName): bool
{
$statement = $this->pdo->prepare('SELECT to_regclass(:name) IS NOT NULL');
$statement->execute(['name' => $qualifiedName]);
return (bool) $statement->fetchColumn();
}
private function resolvePgsqlSerialSequence(string $schemaName, string $tableName, string $columnName): ?string
{
$qualifiedTable = sprintf('"%s"."%s"', str_replace('"', '""', $schemaName), str_replace('"', '""', $tableName));
$statement = $this->pdo->prepare('SELECT pg_get_serial_sequence(:table_name, :column_name)');
$statement->execute([
'table_name' => $qualifiedTable,
'column_name' => $columnName,
]);
$result = $statement->fetchColumn();
return is_string($result) && $result !== '' ? $result : null;
}
/**
* @return list<string>
*/
private function splitStatements(string $sql): array
{
$statements = [];
$buffer = '';
$length = strlen($sql);
$inSingleQuote = false;
$inDoubleQuote = false;
$inBacktickQuote = false;
$inLineComment = false;
$inBlockComment = false;
for ($index = 0; $index < $length; $index++) {
$char = $sql[$index];
$next = $index + 1 < $length ? $sql[$index + 1] : '';
if ($inLineComment) {
if ($char === "\n") {
$inLineComment = false;
$buffer .= $char;
}
continue;
}
if ($inBlockComment) {
if ($char === '*' && $next === '/') {
$inBlockComment = false;
$index++;
}
continue;
}
if (!$inSingleQuote && !$inDoubleQuote && !$inBacktickQuote) {
if ($char === '-' && $next === '-') {
$afterNext = $index + 2 < $length ? $sql[$index + 2] : '';
if ($afterNext === '' || ctype_space($afterNext)) {
$inLineComment = true;
$index++;
continue;
}
}
if ($char === '#') {
$inLineComment = true;
continue;
}
if ($char === '/' && $next === '*') {
$inBlockComment = true;
$index++;
continue;
}
}
if ($char === "'" && !$inDoubleQuote && !$inBacktickQuote) {
$escaped = $index > 0 && $sql[$index - 1] === '\\';
if (!$escaped) {
$inSingleQuote = !$inSingleQuote;
}
$buffer .= $char;
continue;
}
if ($char === '"' && !$inSingleQuote && !$inBacktickQuote) {
$escaped = $index > 0 && $sql[$index - 1] === '\\';
if (!$escaped) {
$inDoubleQuote = !$inDoubleQuote;
}
$buffer .= $char;
continue;
}
if ($char === '`' && !$inSingleQuote && !$inDoubleQuote) {
$inBacktickQuote = !$inBacktickQuote;
$buffer .= $char;
continue;
}
if ($char === ';' && !$inSingleQuote && !$inDoubleQuote && !$inBacktickQuote) {
$trimmed = trim($buffer);
if ($trimmed !== '') {
$statements[] = $trimmed;
}
$buffer = '';
continue;
}
$buffer .= $char;
}
$trimmed = trim($buffer);
if ($trimmed !== '') {
$statements[] = $trimmed;
}
return $statements;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App;
final class UploadedSqlFile
{
public static function read(array $uploadedFile): array
{
$errorCode = (int) ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE);
if ($errorCode !== UPLOAD_ERR_OK) {
throw new \RuntimeException('SQL-Datei konnte nicht hochgeladen werden. Upload-Fehler: ' . $errorCode);
}
$originalName = (string) ($uploadedFile['name'] ?? 'import.sql');
$tmpPath = (string) ($uploadedFile['tmp_name'] ?? '');
if ($tmpPath === '' || !is_uploaded_file($tmpPath)) {
throw new \RuntimeException('Ungueltige Upload-Datei fuer SQL-Import.');
}
if (!preg_match('/\.sql$/i', $originalName)) {
throw new \RuntimeException('Bitte eine SQL-Datei mit Endung .sql hochladen.');
}
$sql = @file_get_contents($tmpPath);
if (!is_string($sql) || trim($sql) === '') {
throw new \RuntimeException('Die hochgeladene SQL-Datei ist leer oder konnte nicht gelesen werden.');
}
return [
'file' => $originalName,
'sql' => $sql,
];
}
}

View File

@@ -0,0 +1,656 @@
<?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_user_key(): string
{
$user = auth_user();
if (!$user) {
return '';
}
foreach (['sub', 'email', 'username', 'name'] as $field) {
$value = trim((string) ($user[$field] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
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 dashboards(): \App\NexusDashboardService
{
return app()->dashboards();
}
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 nexus_debug_enabled(): bool
{
if (function_exists('auth_enabled') && auth_enabled()) {
return auth_is_admin();
}
return true;
}
function nexus_debug_entries(): array
{
if (!nexus_debug_enabled()) {
return [];
}
app()->session()->start();
$entries = $_SESSION['nexus_debug_entries'] ?? [];
return is_array($entries) ? array_values(array_filter($entries, 'is_array')) : [];
}
function nexus_debug_push(string $source, array $entry): void
{
if (!nexus_debug_enabled()) {
return;
}
$source = trim($source);
if ($source === '') {
$source = 'nexus';
}
app()->session()->start();
if (!isset($_SESSION['nexus_debug_entries']) || !is_array($_SESSION['nexus_debug_entries'])) {
$_SESSION['nexus_debug_entries'] = [];
}
$entry['source'] = $entry['source'] ?? $source;
$entry['at'] = $entry['at'] ?? date('Y-m-d H:i:s');
array_unshift($_SESSION['nexus_debug_entries'], $entry);
$_SESSION['nexus_debug_entries'] = array_slice($_SESSION['nexus_debug_entries'], 0, 250);
}
function nexus_debug_clear(?string $source = null): void
{
app()->session()->start();
if ($source === null || trim($source) === '') {
unset($_SESSION['nexus_debug_entries']);
return;
}
$entries = $_SESSION['nexus_debug_entries'] ?? [];
if (!is_array($entries)) {
return;
}
$_SESSION['nexus_debug_entries'] = array_values(array_filter($entries, static function ($entry) use ($source): bool {
return !is_array($entry) || (string) ($entry['source'] ?? '') !== $source;
}));
}
function nexus_settings(): array
{
try {
return modules()->settings('_nexus');
} catch (\Throwable) {
return [];
}
}
function nexus_save_settings(array $settings): void
{
modules()->saveSettings('_nexus', $settings);
}
function nexus_system_timezone_name(): string
{
$timezone = trim((string) date_default_timezone_get());
if ($timezone === '') {
return 'UTC';
}
try {
new \DateTimeZone($timezone);
return $timezone;
} catch (\Throwable) {
return 'UTC';
}
}
function nexus_display_timezone_name(): string
{
$settings = nexus_settings();
$useCustom = !empty($settings['display_timezone_custom']);
$timezone = trim((string) ($settings['display_timezone'] ?? ''));
if ($useCustom && $timezone !== '') {
try {
new \DateTimeZone($timezone);
return $timezone;
} catch (\Throwable) {
}
}
return nexus_system_timezone_name();
}
function nexus_cron_timezone_name(): string
{
$settings = nexus_settings();
$timezone = trim((string) ($settings['cron_timezone'] ?? ''));
if ($timezone !== '') {
try {
new \DateTimeZone($timezone);
return $timezone;
} catch (\Throwable) {
}
}
return nexus_display_timezone_name();
}
function module_debug_enabled(string $module): bool
{
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
return false;
}
if (!nexus_debug_enabled()) {
return false;
}
try {
$settings = modules()->settings($module);
} catch (\Throwable $e) {
return false;
}
$value = $settings['debug_enabled'] ?? '0';
return $value === true || $value === 1 || $value === '1' || $value === 'true';
}
function module_debug_entries(string $module): array
{
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
return [];
}
if (!module_debug_enabled($module)) {
return [];
}
return array_values(array_filter(nexus_debug_entries(), static function ($entry) use ($module): bool {
return is_array($entry) && (string) ($entry['source'] ?? '') === $module;
}));
}
function module_debug_push(string $module, array $entry): void
{
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
return;
}
if (!module_debug_enabled($module)) {
return;
}
nexus_debug_push($module, $entry);
}
function module_debug_clear(string $module): void
{
if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) {
return;
}
nexus_debug_clear($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;
}
$class = 'module-button module-button--secondary 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 submenu-box">';
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 module-submenu-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
{
return '</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;
}

View File

@@ -0,0 +1,686 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
final class KeaHostMetadataRepository
{
public function __construct(private PDO $pdo) {}
public function ensureSchema(): void
{
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_host_meta (
host_id BIGINT PRIMARY KEY,
hardware_address TEXT,
ip_address TEXT,
real_name TEXT,
device_name TEXT,
owner TEXT,
location TEXT,
device_type TEXT,
group_name TEXT,
desired_ip TEXT,
notes TEXT,
tags_json JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
$this->ensureGroupSchema($driver);
$this->ensureCheckSchema($driver);
return;
}
if ($driver === 'sqlite') {
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_host_meta (
host_id INTEGER PRIMARY KEY,
hardware_address TEXT,
ip_address TEXT,
real_name TEXT,
device_name TEXT,
owner TEXT,
location TEXT,
device_type TEXT,
group_name TEXT,
desired_ip TEXT,
notes TEXT,
tags_json TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
$this->ensureGroupSchema($driver);
$this->ensureCheckSchema($driver);
return;
}
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_host_meta (
host_id BIGINT PRIMARY KEY,
hardware_address TEXT,
ip_address TEXT,
real_name TEXT,
device_name TEXT,
owner TEXT,
location TEXT,
device_type TEXT,
group_name TEXT,
desired_ip TEXT,
notes TEXT,
tags_json TEXT,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$this->ensureColumn('nexus_dhcp_host_meta', 'group_name', 'TEXT');
$this->ensureColumn('nexus_dhcp_host_meta', 'desired_ip', 'TEXT');
$this->ensureGroupSchema($driver);
$this->ensureCheckSchema($driver);
}
public function findByHostIds(array $hostIds): array
{
$hostIds = array_values(array_unique(array_filter(array_map('intval', $hostIds))));
if ($hostIds === []) {
return [];
}
$params = [];
$placeholders = [];
foreach ($hostIds as $idx => $hostId) {
$key = ':host_id_' . $idx;
$placeholders[] = $key;
$params[$key] = $hostId;
}
$stmt = $this->pdo->prepare(
'SELECT host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
FROM nexus_dhcp_host_meta
WHERE host_id IN (' . implode(', ', $placeholders) . ')'
);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, PDO::PARAM_INT);
}
$stmt->execute();
$items = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$items[(int)$row['host_id']] = $row;
}
return $items;
}
public function saveForHost(int $hostId, string $hardwareAddress, string $ipAddress, array $metadata): void
{
$metadata = array_merge([
'real_name' => null,
'device_name' => null,
'owner' => null,
'location' => null,
'device_type' => null,
'group_name' => null,
'desired_ip' => null,
'notes' => null,
'tags' => [],
], $metadata);
$tagsJson = json_encode($metadata['tags'], JSON_UNESCAPED_UNICODE);
if ($tagsJson === false) {
$tagsJson = '[]';
}
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
"INSERT INTO nexus_dhcp_host_meta (
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
) VALUES (
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, CAST(:tags_json AS jsonb), NOW()
)
ON CONFLICT (host_id) DO UPDATE SET
hardware_address = EXCLUDED.hardware_address,
ip_address = EXCLUDED.ip_address,
real_name = EXCLUDED.real_name,
device_name = EXCLUDED.device_name,
owner = EXCLUDED.owner,
location = EXCLUDED.location,
device_type = EXCLUDED.device_type,
group_name = EXCLUDED.group_name,
desired_ip = EXCLUDED.desired_ip,
notes = EXCLUDED.notes,
tags_json = EXCLUDED.tags_json,
updated_at = NOW()"
);
} else {
$stmt = $this->pdo->prepare(
"REPLACE INTO nexus_dhcp_host_meta (
host_id, hardware_address, ip_address, real_name, device_name, owner, location, device_type, group_name, desired_ip, notes, tags_json, updated_at
) VALUES (
:host_id, :hardware_address, :ip_address, :real_name, :device_name, :owner, :location, :device_type, :group_name, :desired_ip, :notes, :tags_json, CURRENT_TIMESTAMP
)"
);
}
$stmt->execute([
'host_id' => $hostId,
'hardware_address' => $hardwareAddress,
'ip_address' => $ipAddress,
'real_name' => $this->nullableString($metadata['real_name']),
'device_name' => $this->nullableString($metadata['device_name']),
'owner' => $this->nullableString($metadata['owner']),
'location' => $this->nullableString($metadata['location']),
'device_type' => $this->nullableString($metadata['device_type']),
'group_name' => $this->nullableString($metadata['group_name']),
'desired_ip' => $this->nullableString($metadata['desired_ip']),
'notes' => $this->nullableString($metadata['notes']),
'tags_json' => $tagsJson,
]);
}
public function listGroups(): array
{
$groups = [];
if ($this->tableExists('nexus_dhcp_groups')) {
$stmt = $this->pdo->query("SELECT name FROM nexus_dhcp_groups ORDER BY name ASC");
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$name = trim((string)($row['name'] ?? ''));
if ($name !== '') {
$groups[] = $name;
}
}
}
$stmt = $this->pdo->query(
"SELECT DISTINCT group_name
FROM nexus_dhcp_host_meta
WHERE group_name IS NOT NULL AND group_name <> ''"
);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$name = trim((string)($row['group_name'] ?? ''));
if ($name !== '') {
$groups[] = $name;
}
}
sort($groups, SORT_NATURAL | SORT_FLAG_CASE);
return array_values(array_unique($groups));
}
public function listGroupsWithRanges(): array
{
if (!$this->tableExists('nexus_dhcp_groups')) {
return [];
}
$groups = [];
$stmt = $this->pdo->query(
"SELECT child.id, child.name, child.description, child.parent_id, parent.name AS parent_name
FROM nexus_dhcp_groups child
LEFT JOIN nexus_dhcp_groups parent ON parent.id = child.parent_id
ORDER BY parent.name ASC, child.name ASC"
);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$id = (int)$row['id'];
$groups[$id] = [
'id' => $id,
'name' => (string)$row['name'],
'description' => (string)($row['description'] ?? ''),
'parent_id' => $row['parent_id'] !== null ? (int)$row['parent_id'] : null,
'parent_name' => (string)($row['parent_name'] ?? ''),
'ranges' => [],
];
}
if ($groups !== [] && $this->tableExists('nexus_dhcp_group_ranges')) {
$stmt = $this->pdo->query(
"SELECT id, group_id, start_ip, end_ip
FROM nexus_dhcp_group_ranges
ORDER BY start_ip ASC"
);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$groupId = (int)$row['group_id'];
if (isset($groups[$groupId])) {
$groups[$groupId]['ranges'][] = [
'id' => (int)$row['id'],
'start_ip' => (string)$row['start_ip'],
'end_ip' => (string)$row['end_ip'],
];
}
}
}
return array_values($groups);
}
public function saveGroup(string $name, string $description = '', string $parentName = ''): void
{
$name = trim($name);
if ($name === '') {
throw new \RuntimeException('Gruppenname fehlt.');
}
$parentName = trim($parentName);
if ($parentName !== '' && strcasecmp($parentName, $name) === 0) {
throw new \RuntimeException('Eine Gruppe kann nicht ihre eigene Untergruppe sein.');
}
$parentId = $parentName !== '' ? $this->groupIdByName($parentName) : null;
if ($parentName !== '' && !$parentId) {
throw new \RuntimeException('Uebergeordnete Gruppe wurde nicht gefunden.');
}
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql' || $driver === 'sqlite') {
$sql = "INSERT INTO nexus_dhcp_groups (name, description, parent_id, updated_at)
VALUES (:name, :description, :parent_id, CURRENT_TIMESTAMP)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
parent_id = excluded.parent_id,
updated_at = CURRENT_TIMESTAMP";
} else {
$sql = "INSERT INTO nexus_dhcp_groups (name, description, parent_id, updated_at)
VALUES (:name, :description, :parent_id, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
description = VALUES(description),
parent_id = VALUES(parent_id),
updated_at = CURRENT_TIMESTAMP";
}
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue('name', $name);
$stmt->bindValue('description', $this->nullableString($description));
if ($parentId === null) {
$stmt->bindValue('parent_id', null, PDO::PARAM_NULL);
} else {
$stmt->bindValue('parent_id', $parentId, PDO::PARAM_INT);
}
$stmt->execute();
}
public function addRange(string $groupName, string $startIp, string $endIp): void
{
$groupName = trim($groupName);
$startIp = trim($startIp);
$endIp = trim($endIp);
if ($groupName === '' || !filter_var($startIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) || !filter_var($endIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
throw new \RuntimeException('Gruppe oder IP-Bereich ist ungueltig.');
}
if (ip2long($startIp) > ip2long($endIp)) {
throw new \RuntimeException('Start-IP muss vor der End-IP liegen.');
}
$groupId = $this->groupIdByName($groupName);
if ($groupId <= 0) {
throw new \RuntimeException('Gruppe wurde nicht gefunden.');
}
$stmt = $this->pdo->prepare(
"INSERT INTO nexus_dhcp_group_ranges (group_id, start_ip, end_ip, updated_at)
VALUES (:group_id, :start_ip, :end_ip, CURRENT_TIMESTAMP)"
);
$stmt->execute([
'group_id' => $groupId,
'start_ip' => $startIp,
'end_ip' => $endIp,
]);
}
public function availableIpsByGroup(array $usedIps, int $limitPerGroup = 512): array
{
$used = array_flip(array_filter(array_map('strval', $usedIps)));
$items = [];
foreach ($this->listGroupsWithRanges() as $group) {
$available = [];
foreach ($group['ranges'] as $range) {
$start = ip2long((string)$range['start_ip']);
$end = ip2long((string)$range['end_ip']);
if ($start === false || $end === false) {
continue;
}
for ($ip = $start; $ip <= $end && count($available) < $limitPerGroup; $ip++) {
$address = long2ip($ip);
if ($address !== false && !isset($used[$address])) {
$available[] = $address;
}
}
}
$items[(string)$group['name']] = $available;
}
return $items;
}
public function desiredIps(): array
{
$stmt = $this->pdo->query(
"SELECT desired_ip
FROM nexus_dhcp_host_meta
WHERE desired_ip IS NOT NULL AND desired_ip <> ''"
);
return array_values(array_filter(array_map(
static fn(array $row): string => (string)($row['desired_ip'] ?? ''),
$stmt->fetchAll(PDO::FETCH_ASSOC)
)));
}
public function saveCheck(string $hostKey, string $checkType, string $status, array $result = [], ?string $nextCheckAt = null): void
{
$hostKey = trim($hostKey);
$checkType = trim($checkType);
$status = trim($status);
if ($hostKey === '' || $checkType === '' || $status === '') {
throw new \RuntimeException('Pruefdaten sind unvollstaendig.');
}
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($resultJson === false) {
$resultJson = '{}';
}
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
"INSERT INTO nexus_dhcp_device_checks (
host_key, check_type, status, result_json, checked_at, next_check_at
) VALUES (
:host_key, :check_type, :status, CAST(:result_json AS jsonb), NOW(), :next_check_at
)"
);
} else {
$stmt = $this->pdo->prepare(
"INSERT INTO nexus_dhcp_device_checks (
host_key, check_type, status, result_json, checked_at, next_check_at
) VALUES (
:host_key, :check_type, :status, :result_json, CURRENT_TIMESTAMP, :next_check_at
)"
);
}
$stmt->execute([
'host_key' => $hostKey,
'check_type' => $checkType,
'status' => $status,
'result_json' => $resultJson,
'next_check_at' => $this->nullableString($nextCheckAt),
]);
}
public function latestChecks(array $hostKeys): array
{
$hostKeys = array_values(array_unique(array_filter(array_map(
static fn(mixed $value): string => trim((string)$value),
$hostKeys
))));
if ($hostKeys === [] || !$this->tableExists('nexus_dhcp_device_checks')) {
return [];
}
$params = [];
$placeholders = [];
foreach ($hostKeys as $idx => $hostKey) {
$key = ':host_key_' . $idx;
$placeholders[] = $key;
$params[$key] = $hostKey;
}
$stmt = $this->pdo->prepare(
'SELECT host_key, check_type, status, result_json, checked_at
FROM nexus_dhcp_device_checks
WHERE host_key IN (' . implode(', ', $placeholders) . ')
ORDER BY checked_at DESC, id DESC'
);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
$items = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$hostKey = (string)$row['host_key'];
$type = (string)$row['check_type'];
if (!isset($items[$hostKey][$type])) {
$items[$hostKey][$type] = $row;
}
}
return $items;
}
private function ensureGroupSchema(string $driver): void
{
if ($driver === 'pgsql') {
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_groups (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
parent_id BIGINT REFERENCES nexus_dhcp_groups(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
$this->ensureColumn('nexus_dhcp_groups', 'parent_id', 'BIGINT');
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_group_ranges (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES nexus_dhcp_groups(id) ON DELETE CASCADE,
start_ip TEXT NOT NULL,
end_ip TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)"
);
return;
}
if ($driver === 'sqlite') {
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
parent_id INTEGER,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
$this->ensureColumn('nexus_dhcp_groups', 'parent_id', 'INTEGER');
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_group_ranges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
start_ip TEXT NOT NULL,
end_ip TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
return;
}
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_groups (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL UNIQUE,
description TEXT,
parent_id BIGINT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"
);
$this->ensureColumn('nexus_dhcp_groups', 'parent_id', 'BIGINT NULL');
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_group_ranges (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
group_id BIGINT NOT NULL,
start_ip VARCHAR(45) NOT NULL,
end_ip VARCHAR(45) NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_nexus_dhcp_group_ranges_group_id (group_id)
)"
);
}
private function ensureCheckSchema(string $driver): void
{
if ($driver === 'pgsql') {
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_device_checks (
id BIGSERIAL PRIMARY KEY,
host_key TEXT NOT NULL,
check_type TEXT NOT NULL,
status TEXT NOT NULL,
result_json JSONB,
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
next_check_at TIMESTAMPTZ
)"
);
$this->pdo->exec(
'CREATE INDEX IF NOT EXISTS idx_nexus_dhcp_device_checks_host_type
ON nexus_dhcp_device_checks (host_key, check_type, checked_at)'
);
return;
}
if ($driver === 'sqlite') {
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_device_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_key TEXT NOT NULL,
check_type TEXT NOT NULL,
status TEXT NOT NULL,
result_json TEXT,
checked_at TEXT NOT NULL DEFAULT (datetime('now')),
next_check_at TEXT
)"
);
$this->pdo->exec(
'CREATE INDEX IF NOT EXISTS idx_nexus_dhcp_device_checks_host_type
ON nexus_dhcp_device_checks (host_key, check_type, checked_at)'
);
return;
}
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS nexus_dhcp_device_checks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
host_key VARCHAR(190) NOT NULL,
check_type VARCHAR(80) NOT NULL,
status VARCHAR(40) NOT NULL,
result_json JSON,
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
next_check_at DATETIME NULL,
INDEX idx_nexus_dhcp_device_checks_host_type (host_key, check_type, checked_at)
)"
);
}
private function groupIdByName(string $name): int
{
$stmt = $this->pdo->prepare('SELECT id FROM nexus_dhcp_groups WHERE name = :name LIMIT 1');
$stmt->execute(['name' => $name]);
return (int)$stmt->fetchColumn();
}
private function tableExists(string $table): bool
{
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$stmt = $this->pdo->prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = :table LIMIT 1"
);
$stmt->execute(['table' => $table]);
return (bool)$stmt->fetchColumn();
}
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = :table
LIMIT 1"
);
} else {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = :table
LIMIT 1"
);
}
$stmt->execute(['table' => $table]);
return (bool)$stmt->fetchColumn();
}
private function ensureColumn(string $table, string $column, string $definition): void
{
if ($this->columnExists($table, $column)) {
return;
}
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$quote = $driver === 'mysql' ? '`' : '"';
try {
$this->pdo->exec(
'ALTER TABLE ' . $quote . $table . $quote
. ' ADD COLUMN ' . $quote . $column . $quote . ' ' . $definition
);
} catch (\PDOException $e) {
if (!$this->columnExists($table, $column)) {
throw $e;
}
}
}
private function columnExists(string $table, string $column): bool
{
$driver = (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$stmt = $this->pdo->query('PRAGMA table_info(' . $table . ')');
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
if (($row['name'] ?? '') === $column) {
return true;
}
}
return false;
}
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = :table
AND column_name = :column
LIMIT 1"
);
} else {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table
AND column_name = :column
LIMIT 1"
);
}
$stmt->execute(['table' => $table, 'column' => $column]);
return (bool)$stmt->fetchColumn();
}
private function nullableString(mixed $value): ?string
{
$value = trim((string)$value);
return $value !== '' ? $value : null;
}
}

View File

@@ -0,0 +1,518 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
/**
* Repository für das KEA DHCP Host-Management.
* Interagiert direkt mit der 'hosts' Tabelle in der KEA-Datenbank.
*/
final class KeaHostRepository
{
public function __construct(
private PDO $pdo,
private ?KeaHostMetadataRepository $metadata = null
) {}
/**
* Ruft eine Liste aller Host-Reservierungen ab (Geräte-Inventar).
*/
public function findAll(int $limit = 50): array
{
try {
$hasReservations = $this->tableExists('hosts');
$hasLeases = $this->tableExists('lease4');
if (!$hasReservations && !$hasLeases) {
throw new \RuntimeException(
'Im aktuellen KEA-DB-Schema wurden weder hosts noch lease4 gefunden. Bitte Datenbank, Schema/Search-Path und Benutzerrechte pruefen.'
);
}
$hosts = [];
if ($hasReservations) {
foreach ($this->findReservations($limit) as $host) {
$hosts[] = $host;
}
}
if ($hasLeases) {
foreach ($this->findLeases($limit) as $lease) {
$hosts[] = $lease;
}
}
usort($hosts, static function (array $a, array $b): int {
$aTime = (string)($a['sort_time'] ?? '');
$bTime = (string)($b['sort_time'] ?? '');
if ($aTime !== '' || $bTime !== '') {
return strcmp($bTime, $aTime);
}
return (int)($b['sort_id'] ?? 0) <=> (int)($a['sort_id'] ?? 0);
});
return array_slice($this->withMetadata($hosts), 0, $limit);
} catch (\PDOException $e) {
if ($this->isMissingTable($e)) {
throw new \RuntimeException(
'KEA schema not initialized or expected tables are missing. Expected hosts and/or lease4.',
0,
$e
);
}
throw $e;
}
}
public function usedIpAddresses(int $limit = 10000): array
{
return array_values(array_unique(array_filter(array_map(
static fn(array $host): string => (string)($host['ipv4_address'] ?? ''),
$this->findAll($limit)
))));
}
public function countReservations(): int
{
return $this->countTableRows('hosts');
}
public function countLeases(): int
{
return $this->countTableRows('lease4');
}
private function findReservations(int $limit): array
{
if (!$this->tableExists('hosts')) {
return [];
}
$driver = $this->driver();
$macExpr = $this->hexExpression('dhcp_identifier');
$ipExpr = $this->ipv4Expression('ipv4_address');
$sortExpr = $driver === 'pgsql' ? 'host_id::text' : 'CAST(host_id AS CHAR)';
$stmt = $this->pdo->prepare(
"SELECT
host_id,
{$macExpr} AS dhcp_identifier_hex,
{$ipExpr} AS ipv4_address_text,
hostname,
user_context,
dhcp4_subnet_id AS subnet_id,
'reservation' AS source,
NULL AS lease_state,
NULL AS valid_lifetime,
NULL AS lease_expires_at,
NULL AS last_seen_at,
host_id AS sort_id,
{$sortExpr} AS sort_time
FROM hosts
ORDER BY host_id DESC
LIMIT :limit"
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return array_map(fn(array $row): array => $this->normalizeRow($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
private function findLeases(int $limit): array
{
if (!$this->tableExists('lease4')) {
return [];
}
$macExpr = $this->hexExpression('hwaddr');
$ipExpr = $this->ipv4Expression('address');
$driver = $this->driver();
$expireExpr = $driver === 'pgsql' ? 'expire::text' : 'CAST(expire AS CHAR)';
$lastSeenExpr = match ($driver) {
'pgsql' => '(expire - make_interval(secs => valid_lifetime))::text',
'mysql' => 'CAST(DATE_SUB(expire, INTERVAL valid_lifetime SECOND) AS CHAR)',
default => 'NULL',
};
$stmt = $this->pdo->prepare(
"SELECT
address AS host_id,
{$macExpr} AS dhcp_identifier_hex,
{$ipExpr} AS ipv4_address_text,
hostname,
user_context,
subnet_id,
'lease' AS source,
state AS lease_state,
valid_lifetime,
{$expireExpr} AS lease_expires_at,
{$lastSeenExpr} AS last_seen_at,
address AS sort_id,
{$expireExpr} AS sort_time
FROM lease4
ORDER BY expire DESC
LIMIT :limit"
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return array_map(fn(array $row): array => $this->normalizeRow($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
/**
* Sucht einen Host anhand der MAC-Adresse (dhcp_identifier).
*/
public function findByMac(string $mac): ?array
{
// Hinweis: KEA speichert MACs in PostgreSQL oft als BYTEA.
// Je nach Treiber-Konfiguration muss $mac hier ggf. als Hex-String (z.B. '\x...')
// formatiert übergeben werden.
try {
$stmt = $this->pdo->prepare(
'SELECT host_id, dhcp_identifier, ipv4_address, hostname, user_context
FROM hosts
WHERE dhcp_identifier = :mac
LIMIT 1'
);
$stmt->execute(['mac' => $mac]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
} catch (\PDOException $e) {
if ($this->isMissingTable($e)) {
throw new \RuntimeException(
'KEA schema not initialized. Enable APP_DB_AUTO_INIT or run kea-admin db-init pgsql.',
0,
$e
);
}
throw $e;
}
}
public function findDisplayByKey(string $source, int $id): ?array
{
if ($source === 'lease') {
foreach ($this->findLeases(500) as $lease) {
if ((int)($lease['host_id'] ?? 0) === $id) {
return $this->withMetadata([$lease])[0] ?? $lease;
}
}
return null;
}
if (!$this->tableExists('hosts')) {
return null;
}
$macExpr = $this->hexExpression('dhcp_identifier');
$ipExpr = $this->ipv4Expression('ipv4_address');
$driver = $this->driver();
$sortExpr = $driver === 'pgsql' ? 'host_id::text' : 'CAST(host_id AS CHAR)';
$stmt = $this->pdo->prepare(
"SELECT
host_id,
{$macExpr} AS dhcp_identifier_hex,
{$ipExpr} AS ipv4_address_text,
hostname,
user_context,
dhcp4_subnet_id AS subnet_id,
'reservation' AS source,
NULL AS lease_state,
NULL AS valid_lifetime,
NULL AS lease_expires_at,
NULL AS last_seen_at,
host_id AS sort_id,
{$sortExpr} AS sort_time
FROM hosts
WHERE host_id = :id
LIMIT 1"
);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$row = $this->normalizeRow($row);
return $this->withMetadata([$row])[0] ?? $row;
}
private function isMissingTable(\PDOException $e): bool
{
return in_array((string)$e->getCode(), ['42P01', '42S02'], true);
}
private function countTableRows(string $table): int
{
if (!$this->tableExists($table)) {
return 0;
}
$stmt = $this->pdo->query('SELECT COUNT(*) FROM ' . $table);
return (int)$stmt->fetchColumn();
}
/**
* Erstellt eine neue Host-Reservierung inkl. Metadaten.
*
* @param string $mac MAC-Adresse
* @param string $ip IPv4-Adresse
* @param int $subnetId Die ID des Subnets (dhcp4_subnet_id), in dem die IP liegt
* @param string|null $hostname Optionaler Hostname
* @param array $metadata Zusätzliche Infos fuer die separate Nexus-DHCP-Metadatenbank.
*/
public function create(string $mac, string $ip, int $subnetId, ?string $hostname = null, array $metadata = []): int
{
$macHex = $this->macToHex($mac);
$ipNumber = $this->ipv4ToNumber($ip);
$identifierExpr = $this->binaryFromHexExpression(':mac_hex');
$stmt = $this->pdo->prepare(
"INSERT INTO hosts (dhcp_identifier, dhcp_identifier_type, dhcp4_subnet_id, ipv4_address, hostname)
VALUES ({$identifierExpr}, 1, :subnetId, :ip, :hostname)"
);
$stmt->execute([
'mac_hex' => $macHex,
'subnetId' => $subnetId,
'ip' => $ipNumber,
'hostname' => $hostname,
]);
$hostId = $this->lastInsertIdSafe();
if ($this->metadata !== null && $metadata !== []) {
$this->metadata->saveForHost($hostId, $mac, $ip, $metadata);
}
return $hostId;
}
public function reserveDisplayEntry(array $host, string $ip, array $metadata = []): int
{
$source = (string)($host['source'] ?? 'reservation');
$subnetId = (int)($host['subnet_id'] ?? 0);
if ($subnetId <= 0) {
throw new \RuntimeException('Subnet-ID fehlt. Ohne Subnet kann keine KEA-Reservierung angelegt werden.');
}
$hostname = (string)($host['hostname'] ?? '');
if ($source === 'reservation') {
$hostId = (int)($host['host_id'] ?? 0);
if ($hostId <= 0) {
throw new \RuntimeException('Reservierung hat keine gueltige Host-ID.');
}
$stmt = $this->pdo->prepare(
'UPDATE hosts
SET ipv4_address = :ip, hostname = :hostname, dhcp4_subnet_id = :subnet_id
WHERE host_id = :host_id'
);
$stmt->execute([
'ip' => $this->ipv4ToNumber($ip),
'hostname' => $hostname !== '' ? $hostname : null,
'subnet_id' => $subnetId,
'host_id' => $hostId,
]);
if ($this->metadata !== null) {
$this->metadata->saveForHost($hostId, (string)($host['dhcp_identifier'] ?? ''), $ip, $metadata);
}
return $hostId;
}
return $this->create(
(string)($host['dhcp_identifier'] ?? ''),
$ip,
$subnetId,
$hostname !== '' ? $hostname : null,
$metadata
);
}
private function withMetadata(array $hosts): array
{
if ($hosts === [] || $this->metadata === null) {
return $hosts;
}
$metadataByHost = $this->metadata->findByHostIds(array_column($hosts, 'host_id'));
foreach ($hosts as &$host) {
$hostId = (int)($host['host_id'] ?? 0);
$host['metadata'] = $metadataByHost[$hostId] ?? [];
}
unset($host);
return $hosts;
}
private function driver(): string
{
return (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
}
private function tableExists(string $table): bool
{
$driver = $this->driver();
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = :table
LIMIT 1"
);
$stmt->execute(['table' => $table]);
return (bool)$stmt->fetchColumn();
}
if ($driver === 'sqlite') {
$stmt = $this->pdo->prepare(
"SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name = :table
LIMIT 1"
);
$stmt->execute(['table' => $table]);
return (bool)$stmt->fetchColumn();
}
$stmt = $this->pdo->prepare(
"SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = :table
LIMIT 1"
);
$stmt->execute(['table' => $table]);
return (bool)$stmt->fetchColumn();
}
private function hexExpression(string $column): string
{
return match ($this->driver()) {
'pgsql' => "encode({$column}, 'hex')",
default => "HEX({$column})",
};
}
private function ipv4Expression(string $column): string
{
return match ($this->driver()) {
'pgsql' => "host('0.0.0.0'::inet + ({$column})::bigint)",
'mysql' => "INET_NTOA({$column})",
default => "CAST({$column} AS TEXT)",
};
}
private function normalizeRow(array $row): array
{
$row['dhcp_identifier'] = $this->formatMac((string)($row['dhcp_identifier_hex'] ?? ''));
$row['ipv4_address'] = (string)($row['ipv4_address_text'] ?? '');
$row['hostname'] = (string)($row['hostname'] ?? '');
$contextName = $this->nameFromUserContext((string)($row['user_context'] ?? ''));
$row['display_name'] = $row['hostname'] !== '' ? $row['hostname'] : ($contextName !== '' ? $contextName : 'Unbekannt');
$row['metadata'] = [];
return $row;
}
private function nameFromUserContext(string $userContext): string
{
if (trim($userContext) === '') {
return '';
}
$decoded = json_decode($userContext, true);
if (!is_array($decoded)) {
return '';
}
return $this->findContextValue($decoded, ['hostname', 'host-name', 'client-hostname', 'device_name', 'name', 'label']);
}
private function findContextValue(array $data, array $keys): string
{
foreach ($keys as $key) {
if (isset($data[$key]) && is_scalar($data[$key])) {
$value = trim((string)$data[$key]);
if ($value !== '') {
return $value;
}
}
}
foreach ($data as $value) {
if (is_array($value)) {
$found = $this->findContextValue($value, $keys);
if ($found !== '') {
return $found;
}
}
}
return '';
}
private function formatMac(string $hex): string
{
$hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $hex) ?? '');
if ($hex === '') {
return '';
}
return implode(':', str_split($hex, 2));
}
private function macToHex(string $mac): string
{
$hex = strtolower(preg_replace('/[^a-fA-F0-9]/', '', $mac) ?? '');
if ($hex === '') {
throw new \RuntimeException('MAC-Adresse fehlt.');
}
return $hex;
}
private function binaryFromHexExpression(string $placeholder): string
{
return match ($this->driver()) {
'pgsql' => "decode({$placeholder}, 'hex')",
'mysql' => "UNHEX({$placeholder})",
default => $placeholder,
};
}
private function ipv4ToNumber(string $ip): string
{
$long = ip2long($ip);
if ($long === false) {
throw new \RuntimeException('Ungueltige IPv4-Adresse.');
}
return sprintf('%u', $long);
}
private function lastInsertIdSafe(): int
{
$driver = $this->driver();
if ($driver === 'pgsql') {
// KEA Standard-Sequenz für die hosts Tabelle
$id = $this->pdo->lastInsertId('hosts_host_id_seq');
if ($id !== '') {
return (int)$id;
}
}
return (int)$this->pdo->lastInsertId();
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
final class UserRepository
{
public function __construct(private PDO $pdo) {}
public function findByEmail(string $email): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, email, name, created_at
FROM users
WHERE email = :email
LIMIT 1'
);
$stmt->execute(['email' => $email]);
$row = $stmt->fetch();
return $row ?: null;
}
public function create(string $email, string $name): int
{
// DB-agnostischer INSERT
$stmt = $this->pdo->prepare(
'INSERT INTO users (email, name, created_at)
VALUES (:email, :name, :created_at)'
);
$stmt->execute([
'email' => $email,
'name' => $name,
'created_at' => date('Y-m-d H:i:s'),
]);
// ID-Ermittlung: DB-spezifische Unterschiede gekapselt
return $this->lastInsertIdSafe();
}
public function listLatest(int $limit = 10): array
{
// LIMIT ist bei mysql/pgsql/sqlite gleich
$stmt = $this->pdo->prepare(
'SELECT id, email, name, created_at
FROM users
ORDER BY id DESC
LIMIT :limit'
);
// SQLite/MySQL/PG verstehen ints hier, aber PDO braucht oft PARAM_INT
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
private function driver(): string
{
return (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
}
private function lastInsertIdSafe(): int
{
$driver = $this->driver();
if ($driver === 'pgsql') {
// Option A: Sequenzname (wenn du ihn kennst)
// Standard wäre oft users_id_seq, kann aber anders heißen.
// Wenn du "GENERATED ... AS IDENTITY" nutzt, ist RETURNING meist die bessere Option.
$id = $this->pdo->lastInsertId();
if ($id !== '') {
return (int)$id;
}
// Fallback: versuche typische Sequenz
$id = $this->pdo->lastInsertId('users_id_seq');
return (int)$id;
}
// mysql + sqlite
return (int)$this->pdo->lastInsertId();
}
}