rebuild to module

This commit is contained in:
2026-03-03 02:13:17 +01:00
parent ffb52c6789
commit e61f9fb889
23 changed files with 7618 additions and 73 deletions

30
config/base_db.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* Base database for Nexus core (users, settings, modules).
* This uses the environment-specific config in /config/{env}/db_settings_basic.php.
*/
$env = getenv('APP_ENV');
if (!$env && defined('APP_ENV')) {
$env = APP_ENV;
}
if (!$env) {
$env = 'prod';
}
$env = strtolower((string)$env);
$candidates = [
__DIR__ . '/' . $env . '/db_settings_basic.php',
__DIR__ . '/staging/db_settings_basic.php',
__DIR__ . '/prod/db_settings_basic.php',
];
foreach ($candidates as $path) {
if (file_exists($path)) {
return require $path;
}
}
throw new RuntimeException('Missing base DB config: expected config/{env}/db_settings_basic.php');

View File

@@ -31,6 +31,7 @@ require_once __DIR__ . '/../src/App/functions.php';
$domainFile = __DIR__ . '/domaindata.php';
$settingsFile = __DIR__ . '/settings.php';
$configFile = __DIR__ . '/db.php';
$baseConfigFile = __DIR__ . '/base_db.php';
if (file_exists($domainFile)) {
require_once $domainFile;
@@ -45,10 +46,17 @@ if (file_exists($configFile)) {
$dbConfig = require $configFile;
}
// Base DB config (optional)
$baseDbConfig = [];
if (file_exists($baseConfigFile)) {
$baseDbConfig = require $baseConfigFile;
}
// Globales Config Objekt erstellen
global $appConfig;
$dbEnabled = defined('APP_DB_ENABLED') ? APP_DB_ENABLED : true;
$appConfig = new \App\Config($dbConfig, $dbEnabled);
$baseDbEnabled = defined('APP_BASE_DB_ENABLED') ? APP_BASE_DB_ENABLED : false;
$appConfig = new \App\Config($dbConfig, $dbEnabled, $baseDbConfig, $baseDbEnabled);
// App initialisieren
\App\App::init($appConfig);

View File

@@ -30,8 +30,8 @@ $pgsql = [
// optional: schema/search_path (commonly "public")
'schema' => 'public',
'user' => 'admin',
'password' => 'Hc7LAjDBZbXQBoSzDkaV',
'user' => 'nexusBasisLive',
'password' => 'JNr4REumUDPDjr4S9Lfq',
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,

View File

@@ -5,4 +5,7 @@
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
define('APP_DB_ENABLED', false); // set true to enable DB connection
define('APP_DB_DEBUG', false);
define('APP_DB_AUTO_INIT', true);
define('APP_KEA_DB_VERSION', '2.6.3');
define('APP_BASE_DB_ENABLED', true);

View File

@@ -30,7 +30,7 @@ $pgsql = [
// optional: schema/search_path (commonly "public")
'schema' => 'public',
'user' => 'admin',
'user' => 'nexusBasisStg',
'password' => '8HHtFt9ON6RkmwIS0c7U',
'options' => [

View File

@@ -4,5 +4,8 @@
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
define('APP_DB_ENABLED', true); // set true to enable DB connection
define('APP_DB_ENABLED', false); // legacy default DB disabled (modules handle their own DB)
define('APP_DB_DEBUG', true);
define('APP_DB_AUTO_INIT', true);
define('APP_KEA_DB_VERSION', '2.6.3');
define('APP_BASE_DB_ENABLED', true);

0
data/.gitkeep Normal file
View File

26
modules/kea/module.json Normal file
View File

@@ -0,0 +1,26 @@
{
"title": "KEA DHCP",
"version": "1.0.0",
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
"setup": {
"fields": [
{ "name": "db.host", "label": "DB Host", "type": "text", "required": true },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": true },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": true },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": true },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": true },
{ "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false },
{ "name": "kea_auto_init", "label": "KEA Auto-Init", "type": "checkbox", "required": false }
]
},
"db_defaults": {
"driver": "pgsql",
"host": "localhost",
"port": 5432,
"dbname": "",
"schema": "public",
"user": "",
"password": ""
}
}

View File

@@ -0,0 +1,22 @@
<?php
use App\Repository\KeaHostRepository;
$module = modules()->get('kea');
$fallback = $module['db_defaults'] ?? [];
$pdo = modules()->modulePdo('kea', $fallback);
$hosts = [];
$error = null;
if ($pdo) {
try {
$repo = new KeaHostRepository($pdo);
$hosts = $repo->findAll(50);
} catch (\Exception $e) {
$error = "Datenbankfehler: " . $e->getMessage();
}
} else {
$error = "Modul nicht konfiguriert. Bitte Setup ausführen.";
}
module_tpl('kea', 'dashboard', compact('hosts', 'error'));

View File

@@ -27,8 +27,8 @@ $app->assets()->addScript('/assets/js/app.js', 'footer', true);
</div>
<nav class="site-nav">
<a href="/" class="nav-link is-active">Dashboard</a>
<a href="/" class="nav-link">KEA DHCP</a>
<a href="/" class="nav-link">Inventory</a>
<a href="/module/kea" class="nav-link">KEA DHCP</a>
<a href="/modules" class="nav-link">Module</a>
</nav>
<div class="header-cta">
<button class="cta-button">+ Neuer Host</button>

View File

@@ -134,6 +134,21 @@ body {
padding: 24px;
box-shadow: var(--shadow);
}
.card input,
.card textarea {
background: #0f121b;
border: 1px solid var(--line);
color: var(--text);
padding: 10px 12px;
border-radius: 10px;
font-family: inherit;
}
.card input:focus,
.card textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(255, 94, 91, 0.2);
}
.muted { color: var(--muted); }
.pill {
display: inline-flex;
@@ -172,9 +187,6 @@ body {
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.sm\\:px-0 {}
.sm\\:px-6 {}
.sm\\:rounded-lg {}
@media (min-width: 640px) {
.sm\\:px-0 { padding-left: 0; padding-right: 0; }
.sm\\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }

View File

@@ -28,8 +28,21 @@ if (str_contains($uriPath, '..')) {
exit('Bad request');
}
// Root → page/index.php
if ($uriPath === '' || $uriPath === 'index' || $uriPath === 'index.php') {
// Spezialrouten für Module
if (str_starts_with($uriPath, 'modules/setup/')) {
$_GET['module'] = trim(substr($uriPath, strlen('modules/setup/')), '/');
$target = __DIR__ . '/page/modules_setup.php';
} elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
$module = $m[1];
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
$modulePage = app()->modules()->resolvePage($module, $page);
if ($modulePage) {
$target = $modulePage;
} else {
http_response_code(404);
$target = __DIR__ . '/page/404.php';
}
} elseif ($uriPath === '' || $uriPath === 'index' || $uriPath === 'index.php') {
$target = __DIR__ . '/page/index.php';
} else {
$base = __DIR__ . '/page/' . $uriPath;

View File

@@ -1,22 +1,34 @@
<?php
use App\Database;
use App\Repository\KeaHostRepository;
$modules = modules()->all();
?>
<div class="card">
<div class="pill">Core</div>
<h1 style="margin-top:.75rem;">Nexus Basis-System</h1>
<p class="muted">Aktive Module verwalten und neue Module initialisieren.</p>
global $appConfig;
<div style="margin-top:1rem;">
<a class="nav-link" href="/modules">Module verwalten</a>
</div>
$pdo = Database::createPdo($appConfig);
$hosts = [];
$error = null;
if ($pdo) {
try {
$repo = new KeaHostRepository($pdo);
$hosts = $repo->findAll(50);
} catch (\Exception $e) {
$error = "Datenbankfehler: " . $e->getMessage();
}
} else {
$error = "Datenbankverbindung ist nicht konfiguriert oder deaktiviert.";
}
tpl('kea_dashboard', 'landing', compact('hosts', 'error'));
<div style="margin-top:1.5rem;" class="grid">
<?php foreach ($modules as $module): ?>
<div class="card" style="background:var(--panel-2);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<strong><?= e($module['title']) ?></strong>
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
</div>
<?php if (!empty($module['enabled'])): ?>
<span class="pill" style="border-color:var(--accent-2); color:var(--accent-2);">aktiv</span>
<?php else: ?>
<span class="pill">inaktiv</span>
<?php endif; ?>
</div>
<div style="margin-top:.75rem; display:flex; gap:10px; flex-wrap:wrap;">
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
</div>
</div>
<?php endforeach; ?>
</div>
</div>

63
public/page/modules.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
$modules = modules()->all();
$error = null;
$notice = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = (string)($_POST['module'] ?? '');
$action = (string)($_POST['action'] ?? '');
if ($name !== '' && ($action === 'enable' || $action === 'disable')) {
$enabled = $action === 'enable';
modules()->setEnabled($name, $enabled);
$notice = $enabled ? 'Modul aktiviert.' : 'Modul deaktiviert.';
$modules = modules()->all();
} else {
$error = 'Ungültige Aktion.';
}
}
?>
<div class="card">
<div class="pill">Module</div>
<h1 style="margin-top:.75rem;">Module verwalten</h1>
<?php if ($error): ?>
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div style="margin-top:1rem;" class="grid">
<?php foreach ($modules as $module): ?>
<div class="card" style="background:var(--panel-2);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<strong><?= e($module['title']) ?></strong>
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
</div>
<?php if (!empty($module['enabled'])): ?>
<span class="pill" style="border-color:var(--accent-2); color:var(--accent-2);">aktiv</span>
<?php else: ?>
<span class="pill">inaktiv</span>
<?php endif; ?>
</div>
<div style="margin-top:.75rem; display:flex; gap:10px; flex-wrap:wrap;">
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
<form method="post" style="margin:0;">
<input type="hidden" name="module" value="<?= e($module['name']) ?>">
<?php if (!empty($module['enabled'])): ?>
<button class="cta-button" name="action" value="disable" style="background:var(--panel); color:var(--text);">Deaktivieren</button>
<?php else: ?>
<button class="cta-button" name="action" value="enable">Aktivieren</button>
<?php endif; ?>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
</div>

View File

@@ -0,0 +1,108 @@
<?php
$moduleName = (string)($_GET['module'] ?? '');
$module = modules()->get($moduleName);
$error = null;
$notice = null;
if (!$module) {
http_response_code(404);
echo '<div class="card">Modul nicht gefunden.</div>';
return;
}
$fields = (array)($module['setup']['fields'] ?? []);
$current = modules()->settings($moduleName);
$defaults = $module['db_defaults'] ?? [];
if (empty($current['db']) && is_array($defaults)) {
$current['db'] = $defaults;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$payload = [];
$db = $current['db'] ?? [];
foreach ($fields as $field) {
$name = (string)($field['name'] ?? '');
if ($name === '') {
continue;
}
$value = $_POST[$name] ?? null;
if (is_array($value)) {
continue;
}
$value = is_string($value) ? trim($value) : $value;
if ($name === 'kea_auto_init') {
$payload[$name] = $value === '1';
continue;
}
if (str_starts_with($name, 'db.')) {
$key = substr($name, 3);
$db[$key] = $value;
continue;
}
$payload[$name] = $value;
}
if (!empty($db)) {
$payload['db'] = $db;
}
modules()->saveSettings($moduleName, $payload);
$notice = 'Setup gespeichert.';
$current = modules()->settings($moduleName);
}
?>
<div class="card">
<div class="pill">Setup</div>
<h1 style="margin-top:.75rem;"><?= e($module['title']) ?> Einrichtung</h1>
<p class="muted">Trage die benötigten Informationen für das Modul ein.</p>
<?php if ($error): ?>
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<form method="post" style="margin-top:1rem; display:grid; gap:14px; max-width:520px;">
<?php foreach ($fields as $field): ?>
<?php
$name = (string)($field['name'] ?? '');
$label = (string)($field['label'] ?? $name);
$type = (string)($field['type'] ?? 'text');
$required = !empty($field['required']);
$value = '';
if ($name === 'kea_auto_init') {
$value = !empty($current[$name]) ? '1' : '0';
} elseif (str_starts_with($name, 'db.')) {
$key = substr($name, 3);
$value = (string)($current['db'][$key] ?? '');
} else {
$value = (string)($current[$name] ?? '');
}
?>
<label class="muted" style="display:grid; gap:6px;">
<span><?= e($label) ?></span>
<?php if ($type === 'textarea'): ?>
<textarea name="<?= e($name) ?>" rows="3" <?= $required ? 'required' : '' ?>><?= e($value) ?></textarea>
<?php elseif ($type === 'checkbox'): ?>
<input type="checkbox" name="<?= e($name) ?>" value="1" <?= $value === '1' ? 'checked' : '' ?>>
<?php else: ?>
<input type="<?= e($type) ?>" name="<?= e($name) ?>" value="<?= e($value) ?>" <?= $required ? 'required' : '' ?>>
<?php endif; ?>
</label>
<?php endforeach; ?>
<div style="display:flex; gap:10px;">
<button class="cta-button" type="submit">Speichern</button>
<a class="nav-link" href="/modules">Zurück</a>
</div>
</form>
</div>

View File

@@ -13,6 +13,8 @@ final class App
private I18n $i18n;
private Flash $flash;
private ?\PDO $pdo;
private ?\PDO $basePdo;
private ModuleManager $modules;
private function __construct(private Config $config)
{
@@ -22,6 +24,9 @@ final class App
$this->i18n = new I18n($config, 'en');
$this->flash = new Flash($this->session);
$this->pdo = Database::createPdo($config);
$this->basePdo = Database::createBasePdo($config);
$this->modules = new ModuleManager($this->basePdo, __DIR__ . '/../../modules');
$this->modules->bootEnabled();
}
public static function init(Config $config): self
@@ -47,4 +52,6 @@ final class App
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; }
}

144
src/App/BaseSchema.php Normal file
View File

@@ -0,0 +1,144 @@
<?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);
return;
}
if ($driver === 'sqlite') {
self::ensureSqlite($pdo);
return;
}
self::ensureGeneric($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_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()
)"
);
}
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_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'))
)"
);
}
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_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
)"
);
}
}

View File

@@ -9,15 +9,23 @@ class Config
public bool $dbAutoInit;
public ?string $dbInitScript;
public ?string $dbInitCmd;
public string $keaDbVersion;
public array $baseDb;
public bool $baseDbEnabled;
public function __construct(
public array $db,
public bool $dbEnabled = true
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;
}
public function primaryUrl(): string

View File

@@ -11,39 +11,14 @@ final class Database
return null;
}
$db = $config->db;
$driver = (string)($db['driver'] ?? '');
if ($driver === '') {
throw new \RuntimeException('DB enabled but config/db.php 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),
};
try {
$pdo = new \PDO(
$dsn,
// sqlite braucht user/pass nicht, PDO ignoriert es aber; wir geben leer zurück
(string)($db['user'] ?? ''),
(string)($db['password'] ?? ''),
(array)($db['options'] ?? [])
);
// Optional: PostgreSQL schema/search_path setzen
if ($driver === 'pgsql' && !empty($db['schema'])) {
// Minimaler Schutz gegen Injection über schema
$schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db['schema']);
if ($schema !== '') {
$pdo->exec('SET search_path TO ' . $schema);
}
}
self::ensureSchema($pdo, $config);
$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) {
@@ -63,7 +38,55 @@ final class Database
}
}
private static function ensureSchema(\PDO $pdo, Config $config): void
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') {
@@ -71,8 +94,8 @@ final class Database
}
if (!self::tableExists($pdo, 'hosts')) {
if ($config->dbAutoInit) {
self::initKeaSchema($pdo, $config);
if (!empty($options['auto_init'])) {
self::initKeaSchema($pdo, $options);
}
}
@@ -95,15 +118,20 @@ final class Database
return (bool)$stmt->fetchColumn();
}
private static function initKeaSchema(\PDO $pdo, Config $config): void
private static function initKeaSchema(\PDO $pdo, array $options): void
{
if ($config->dbInitCmd) {
self::runInitCommand($config->dbInitCmd);
if (!empty($options['init_cmd'])) {
self::runInitCommand((string)$options['init_cmd']);
return;
}
if ($config->dbInitScript) {
self::execSqlFile($pdo, $config->dbInitScript);
$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;
}

247
src/App/ModuleManager.php Normal file
View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleManager
{
private array $modules = [];
private array $callbacks = [];
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 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 modulePdo(string $name, array $fallback = []): ?\PDO
{
$settings = $this->settings($name);
$db = $settings['db'] ?? $fallback;
if (!is_array($db) || empty($db)) {
return null;
}
if (!isset($db['options'])) {
$db['options'] = [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
];
}
$pdo = Database::createFromArray($db);
if ($name === 'kea' && !empty($settings['kea_auto_init'])) {
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'] ?? '',
]);
}
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)) {
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);
}
private function scanModules(): void
{
$this->modules = [];
if (!is_dir($this->modulesPath)) {
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;
}
$module = [
'name' => $name,
'title' => $data['title'] ?? $name,
'version' => $data['version'] ?? '',
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
'path' => $dir,
'enabled' => false,
];
$module['enabled'] = $this->loadEnabledState($name, $module);
$this->modules[$name] = $module;
}
}
private function loadEnabledState(string $name, array $module): bool
{
if (!$this->basePdo) {
return 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, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
);
$stmt->execute([
'name' => $name,
'title' => (string)$module['title'],
'version' => (string)$module['version'],
]);
return false;
}
}

View File

@@ -13,6 +13,16 @@ function t(string $key, $default = '', array $vars = []): string
return app()->i18n()->get($key, $default, $vars);
}
function modules(): \App\ModuleManager
{
return app()->modules();
}
function module_fn(string $module, string $name, mixed ...$args): mixed
{
return modules()->call($module, $name, ...$args);
}
/**
* Lädt ein Template-Partial.
*
@@ -56,6 +66,24 @@ function tpl(string $name, string $folder = 'landing', array $data = []): void
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} -->";
}
}
/**
* HTML Escaping Helper.
*/

File diff suppressed because it is too large Load Diff