deploy
This commit is contained in:
0
docs/Umsetzungsanweisung/Old-Nexus/src/.gitkeep
Executable file
0
docs/Umsetzungsanweisung/Old-Nexus/src/.gitkeep
Executable file
398
docs/Umsetzungsanweisung/Old-Nexus/src/App/AccountPages.php
Executable file
398
docs/Umsetzungsanweisung/Old-Nexus/src/App/AccountPages.php
Executable 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];
|
||||
}
|
||||
}
|
||||
77
docs/Umsetzungsanweisung/Old-Nexus/src/App/App.php
Executable file
77
docs/Umsetzungsanweisung/Old-Nexus/src/App/App.php
Executable 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; }
|
||||
}
|
||||
49
docs/Umsetzungsanweisung/Old-Nexus/src/App/Assets.php
Executable file
49
docs/Umsetzungsanweisung/Old-Nexus/src/App/Assets.php
Executable 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);
|
||||
}
|
||||
}
|
||||
217
docs/Umsetzungsanweisung/Old-Nexus/src/App/Auth.php
Executable file
217
docs/Umsetzungsanweisung/Old-Nexus/src/App/Auth.php
Executable 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];
|
||||
}
|
||||
}
|
||||
206
docs/Umsetzungsanweisung/Old-Nexus/src/App/AuthService.php
Normal file
206
docs/Umsetzungsanweisung/Old-Nexus/src/App/AuthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
850
docs/Umsetzungsanweisung/Old-Nexus/src/App/BaseSchema.php
Normal file
850
docs/Umsetzungsanweisung/Old-Nexus/src/App/BaseSchema.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
196
docs/Umsetzungsanweisung/Old-Nexus/src/App/Community.php
Executable file
196
docs/Umsetzungsanweisung/Old-Nexus/src/App/Community.php
Executable 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'] ?? ''];
|
||||
}
|
||||
}
|
||||
91
docs/Umsetzungsanweisung/Old-Nexus/src/App/Config.php
Executable file
91
docs/Umsetzungsanweisung/Old-Nexus/src/App/Config.php
Executable 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;
|
||||
}
|
||||
}
|
||||
214
docs/Umsetzungsanweisung/Old-Nexus/src/App/CronExpression.php
Normal file
214
docs/Umsetzungsanweisung/Old-Nexus/src/App/CronExpression.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
68
docs/Umsetzungsanweisung/Old-Nexus/src/App/Crypto.php
Executable file
68
docs/Umsetzungsanweisung/Old-Nexus/src/App/Crypto.php
Executable 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
246
docs/Umsetzungsanweisung/Old-Nexus/src/App/Database.php
Executable file
246
docs/Umsetzungsanweisung/Old-Nexus/src/App/Database.php
Executable 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;
|
||||
}
|
||||
}
|
||||
33
docs/Umsetzungsanweisung/Old-Nexus/src/App/Flash.php
Executable file
33
docs/Umsetzungsanweisung/Old-Nexus/src/App/Flash.php
Executable 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'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
59
docs/Umsetzungsanweisung/Old-Nexus/src/App/I18n.php
Executable file
59
docs/Umsetzungsanweisung/Old-Nexus/src/App/I18n.php
Executable 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;
|
||||
}
|
||||
}
|
||||
352
docs/Umsetzungsanweisung/Old-Nexus/src/App/Mailer.php
Executable file
352
docs/Umsetzungsanweisung/Old-Nexus/src/App/Mailer.php
Executable 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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
581
docs/Umsetzungsanweisung/Old-Nexus/src/App/ModuleManager.php
Normal file
581
docs/Umsetzungsanweisung/Old-Nexus/src/App/ModuleManager.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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 . '.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1025
docs/Umsetzungsanweisung/Old-Nexus/src/App/NexusDashboardService.php
Normal file
1025
docs/Umsetzungsanweisung/Old-Nexus/src/App/NexusDashboardService.php
Normal file
File diff suppressed because it is too large
Load Diff
155
docs/Umsetzungsanweisung/Old-Nexus/src/App/OidcClient.php
Normal file
155
docs/Umsetzungsanweisung/Old-Nexus/src/App/OidcClient.php
Normal 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) ?: '';
|
||||
}
|
||||
}
|
||||
136
docs/Umsetzungsanweisung/Old-Nexus/src/App/RedisClient.php
Normal file
136
docs/Umsetzungsanweisung/Old-Nexus/src/App/RedisClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
47
docs/Umsetzungsanweisung/Old-Nexus/src/App/Request.php
Executable file
47
docs/Umsetzungsanweisung/Old-Nexus/src/App/Request.php
Executable 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, '?');
|
||||
}
|
||||
}
|
||||
241
docs/Umsetzungsanweisung/Old-Nexus/src/App/Search.php
Executable file
241
docs/Umsetzungsanweisung/Old-Nexus/src/App/Search.php
Executable 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
92
docs/Umsetzungsanweisung/Old-Nexus/src/App/SessionManager.php
Executable file
92
docs/Umsetzungsanweisung/Old-Nexus/src/App/SessionManager.php
Executable 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
285
docs/Umsetzungsanweisung/Old-Nexus/src/App/SqlDataExporter.php
Normal file
285
docs/Umsetzungsanweisung/Old-Nexus/src/App/SqlDataExporter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
312
docs/Umsetzungsanweisung/Old-Nexus/src/App/SqlDataImporter.php
Normal file
312
docs/Umsetzungsanweisung/Old-Nexus/src/App/SqlDataImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
656
docs/Umsetzungsanweisung/Old-Nexus/src/App/functions.php
Normal file
656
docs/Umsetzungsanweisung/Old-Nexus/src/App/functions.php
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
87
docs/Umsetzungsanweisung/Old-Nexus/src/Repository/UserRepository.php
Executable file
87
docs/Umsetzungsanweisung/Old-Nexus/src/Repository/UserRepository.php
Executable 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user