Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
/**
* IT-Dokumentationssystem - Front Controller
*
* Zentraler Einstiegspunkt für alle HTTP-Anfragen.
* SICHERHEITSKRITISCH: Alle Anfragen durchlaufen diesen Controller.
*/
// ============================================================
// BOOTSTRAP
// ============================================================
// Fehlerbehandlung - E_DEPRECATED ausschliessen (dompdf/php-font-lib PHP 8.4 Kompatibilitaet)
error_reporting(E_ALL & ~E_DEPRECATED);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Zeitzone setzen
date_default_timezone_set('Europe/Berlin');
// ============================================================
// INSTALLER-ERKENNUNG
// ============================================================
// Prüfen ob Installation erforderlich
$envFile = dirname(__DIR__) . '/.env';
$installedMarker = dirname(__DIR__) . '/storage/.installed';
if (!file_exists($envFile) || !file_exists($installedMarker)) {
// Installer existiert?
if (file_exists(__DIR__ . '/install.php')) {
// Weiterleitung zum Installer
header('Location: /install.php');
exit;
} else {
// Keine .env und kein Installer - Fehler
http_response_code(500);
die('
<h1>Konfiguration fehlt</h1>
<p>Die Anwendung ist nicht konfiguriert. Bitte erstellen Sie eine <code>.env</code> Datei oder laden Sie <code>install.php</code> hoch.</p>
');
}
}
// Composer Autoloader (Drittanbieter-Pakete)
require_once dirname(__DIR__) . '/vendor/autoload.php';
// Eigenständiger PSR-4-Autoloader für OpenNIT\* – unabhängig von vendor/,
// damit Updates ohne composer dump-autoload nicht die App-Klassen verlieren.
require_once dirname(__DIR__) . '/bootstrap.php';
// TinyMCE Installations-Status als Konstante (für Template-Nutzung ohne Controller-Overhead)
define('TINYMCE_INSTALLED', is_file(__DIR__ . '/assets/tinymce/tinymce.min.js'));
use OpenNIT\Security\CredentialCrypto;
// Umgebungsvariablen laden
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();
// Konfiguration laden
$config = [
'app' => require dirname(__DIR__) . '/config/app.php',
'database' => require dirname(__DIR__) . '/config/database.php',
'oauth' => require dirname(__DIR__) . '/config/oauth.php',
'security' => require dirname(__DIR__) . '/config/security.php',
'modules' => [
'orders' => true,
'services' => true,
'vault' => true,
'contracts' => true,
'loans' => true,
'ipam' => true,
'tickets' => true,
'contacts' => true,
'assets' => true,
'ssl' => true,
'kanban' => true,
'monitoring' => true,
'stock' => true,
'vms' => false,
'unifi' => false,
'backup' => false,
'files' => true,
'netdiagram' => true,
'todos' => true,
'maintenance' => true,
'cve' => true,
'budget' => true,
'deadlines' => true, // Fristen-Cockpit (modulübergreifende Termine) + Digest
'dns' => false, // DNS-Zonen-Import (Windows-DNS-Agent) – Opt-in
'xdr' => false, // XDR/EDR-Sicherheitswarnungen (Trend Vision One u. a.) – Opt-in
// Opt-in-Module (benoetigen externe Einrichtung bzw. sind nischig) –
// explizit gelistet, damit alle Module im Modul-Toggle abgebildet sind.
'ai_chat' => false,
'licenses' => false,
'configlog' => false,
'phonebook' => false,
'isms' => false, // ISMS / ISO 27001 (Risikoregister, Annex-A-Controls)
'statuspage' => false, // Oeffentliche Statusseite (Dienste, Stoerungen, Wartungen)
'api_auto' => false, // KI-gestuetzte API-Integrationen (Opt-in: bindet fremde Systeme an)
'workflows' => false, // Antraege & Genehmigungen (Approval-Baukasten)
'infrastructure' => false, // Physische Netz-/Standortdoku (Racks & Belegung)
'catalog' => false, // Ausstattung & Preise / Standard-Ausstattung mit Richtpreisen
'software' => false, // Software-Inventar (Agent/Intune lesen Programme mit) – Opt-in
'teamboard' => false, // Notizbrett fuer kurze Zurufe im Team
],
];
// display_errors nur im Debug-Modus aktivieren (Dotenv ist ab Zeile 57 geladen)
if (filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
ini_set('display_errors', '1');
}
// Runtime-Konfiguration aus Settings laden (überschreibt Defaults)
// Prüfe zuerst runtime.php, dann alte config.php für Migration
$runtimeConfigFile = dirname(__DIR__) . '/config/runtime.php';
$oldConfigFile = dirname(__DIR__) . '/config/config.php';
$runtimeConfig = null;
if (file_exists($runtimeConfigFile)) {
$runtimeConfig = include $runtimeConfigFile;
} elseif (file_exists($oldConfigFile)) {
// Alte config.php verwenden (wird beim nächsten Settings-Speichern migriert)
$runtimeConfig = include $oldConfigFile;
}
if (is_array($runtimeConfig)) {
// Azure-Einstellungen überschreiben
if (!empty($runtimeConfig['azure']['tenant_id'])) {
$config['oauth']['azure']['tenant_id'] = $runtimeConfig['azure']['tenant_id'];
}
if (!empty($runtimeConfig['azure']['client_id'])) {
$config['oauth']['azure']['client_id'] = $runtimeConfig['azure']['client_id'];
}
if (!empty($runtimeConfig['azure']['client_secret'])) {
// Entschlüsseln, da das Feld verschlüsselt in runtime.php gespeichert wird
$config['oauth']['azure']['client_secret'] = CredentialCrypto::decrypt(
$runtimeConfig['azure']['client_secret'],
$_ENV['APP_KEY'] ?? ''
);
}
if (!empty($runtimeConfig['azure']['redirect_uri'])) {
$config['oauth']['azure']['redirect_uri'] = $runtimeConfig['azure']['redirect_uri'];
}
// App-Name überschreiben
if (!empty($runtimeConfig['app']['name'])) {
$config['app']['name'] = $runtimeConfig['app']['name'];
}
// Basis-URL der Installation (im Backend gepflegt) hat Vorrang vor APP_URL.
// Ohne diese Übernahme läsen alle Stellen, die $config['app']['url'] direkt
// auswerten, weiterhin nur den Wert aus der Umgebung.
if (!empty($runtimeConfig['app']['url'])) {
$config['app']['url'] = rtrim((string)$runtimeConfig['app']['url'], '/');
}
// Im Backend pflegbare app.*-Einstellungen.
//
// Ein Schlüssel, der in diesen Listen fehlt, wird zwar in runtime.php
// gespeichert und im Formular wieder angezeigt (das liest die Datei direkt),
// erreicht aber $config['app'] nie und bleibt damit wirkungslos. Deshalb
// stehen alle einfachen Werte hier beisammen statt einzeln verstreut.
// Werte mit Nebenwirkung oder eigener Aufbereitung (url, timezone) folgen
// weiter unten gesondert.
$backendAppStrings = [
'logo', 'logo_on_dark', 'footer_text', 'favicon', 'login_bg',
'icon_style', 'design_style',
'label_logo', 'label_print_method', 'label_size',
];
foreach ($backendAppStrings as $appKey) {
if (isset($runtimeConfig['app'][$appKey])) {
$config['app'][$appKey] = $runtimeConfig['app'][$appKey];
}
}
$backendAppFlags = [
'nav_icons', 'allow_local_login', 'password_reset_enabled', 'disable_flash_messages',
];
foreach ($backendAppFlags as $appKey) {
if (isset($runtimeConfig['app'][$appKey])) {
$config['app'][$appKey] = (bool)$runtimeConfig['app'][$appKey];
}
}
if (isset($runtimeConfig['app']['default_review_interval_months'])) {
$config['app']['default_review_interval_months'] = (int)$runtimeConfig['app']['default_review_interval_months'];
}
// Zeitzone
if (!empty($runtimeConfig['app']['timezone'])) {
$config['app']['timezone'] = $runtimeConfig['app']['timezone'];
date_default_timezone_set($runtimeConfig['app']['timezone']);
// Datenbank-Session-Zeitzone mit App-Zeitzone synchronisieren,
// damit MySQL CURRENT_TIMESTAMP die korrekte lokale Zeit liefert
$tzObj = new DateTimeZone($runtimeConfig['app']['timezone']);
$offsetSec = $tzObj->getOffset(new DateTime('now', $tzObj));
$sign = $offsetSec >= 0 ? '+' : '-';
$h = intdiv(abs($offsetSec), 3600);
$m = intdiv(abs($offsetSec) % 3600, 60);
$config['database']['timezone'] = sprintf('%s%02d:%02d', $sign, $h, $m);
}
// Modul-Aktivierung (false = komplett deaktiviert)
if (isset($runtimeConfig['modules']) && is_array($runtimeConfig['modules'])) {
foreach (['orders', 'services', 'vault', 'contracts', 'loans', 'ipam', 'tickets', 'contacts', 'assets', 'ssl', 'kanban', 'monitoring', 'stock', 'vms', 'unifi', 'backup', 'files', 'netdiagram', 'todos', 'maintenance', 'deadlines', 'configlog', 'licenses', 'phonebook', 'ai_chat', 'cve', 'budget', 'xdr', 'dns', 'isms', 'statuspage', 'api_auto', 'workflows', 'infrastructure', 'catalog', 'software', 'teamboard'] as $mod) {
if (array_key_exists($mod, $runtimeConfig['modules'])) {
$config['modules'][$mod] = (bool)$runtimeConfig['modules'][$mod];
}
}
}
// UniFi-Verbindungseinstellungen
if (isset($runtimeConfig['unifi']) && is_array($runtimeConfig['unifi'])) {
$config['unifi'] = $runtimeConfig['unifi'];
}
// Backup-Verbindungseinstellungen (Veeam)
if (isset($runtimeConfig['backup']) && is_array($runtimeConfig['backup'])) {
$config['backup'] = $runtimeConfig['backup'];
}
// Vertragsmanagement-Einstellungen (Preisanzeige, Fristen-Vorlauf, iCal-Alarm)
if (isset($runtimeConfig['contracts']) && is_array($runtimeConfig['contracts'])) {
$config['contracts'] = array_merge($config['contracts'] ?? [], $runtimeConfig['contracts']);
}
// Sicherheits-/Session-Einstellungen aus dem Backend (Session-/CSRF-Lebensdauer,
// Rate-Limit, Upload-Größe, Audit-Aufbewahrung). Nur die im Backend pflegbaren
// Teilschlüssel werden überschrieben; alle übrigen Werte bleiben aus config/security.php.
if (isset($runtimeConfig['security']) && is_array($runtimeConfig['security'])) {
$config['security'] = array_replace_recursive($config['security'], $runtimeConfig['security']);
}
}
// ============================================================
// ANWENDUNG STARTEN
// ============================================================
use OpenNIT\Core\Application;
try {
$app = new Application($config);
$app->run();
} catch (Throwable $e) {
// Verweigerte Berechtigung ist kein Serverfehler: Sie wird als 403 beantwortet,
// damit Client und Protokoll den Unterschied zwischen „darf nicht" und
// „ist kaputt" sehen – und kein Stacktrace an einen Unbefugten geht.
if ($e instanceof RuntimeException && $e->getCode() === 403) {
http_response_code(403);
$istAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest')
|| str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
if ($istAjax) {
header('Content-Type: application/json');
echo json_encode(['error' => 'Keine Berechtigung']);
} else {
// Eigenständige Seite ohne Layout: An dieser Stelle ist die Anwendung
// bereits verlassen, ein Template mit Layout-Aufruf ließe sich hier
// nicht mehr rendern.
echo '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">'
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
. '<title>Zugriff verweigert</title></head><body '
. 'style="font-family:system-ui,sans-serif;max-width:34rem;margin:15vh auto;padding:0 1.5rem;color:#1f2937;">'
. '<h1 style="font-size:1.25rem;">Zugriff verweigert</h1>'
. '<p>Für diese Aktion fehlt die Berechtigung. Wende dich an eine '
. 'Administratorin oder einen Administrator, wenn du sie benötigst.</p>'
. '<p><a href="/" style="color:#2563eb;">Zur Startseite</a></p></body></html>';
}
exit;
}
// Eine zu lange Eingabe ist kein Ausfall, sondern eine Frage der Eingabe.
// Sie bekommt deshalb einen eigenen Status und eine Meldung, mit der sich
// etwas anfangen lässt, statt der allgemeinen Fehlerseite.
$eingabeZuLang = class_exists(\OpenNIT\Core\Database::class)
&& \OpenNIT\Core\Database::isValueTooLongError($e);
http_response_code($eingabeZuLang ? 422 : 500);
// Unbehandelte Exceptions immer ins Server-Log schreiben (auch in Produktion) –
// sonst ist die generische Fehlerseite nicht diagnostizierbar.
error_log(sprintf(
'OpenNIT uncaught %s: %s in %s:%d | URI: %s',
get_class($e), $e->getMessage(), $e->getFile(), $e->getLine(),
$_SERVER['REQUEST_URI'] ?? '-'
));
$appDebug = filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN);
$isAjax = (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest')
|| str_contains($_SERVER['HTTP_ACCEPT'] ?? '', 'application/json');
$meldung = $eingabeZuLang
? 'Eine Eingabe ist länger als das dafür vorgesehene Feld. Bitte kürzen Sie den Text und speichern Sie erneut.'
: 'Ein interner Fehler ist aufgetreten. Bitte wenden Sie sich an den Administrator.';
if ($isAjax) {
header('Content-Type: application/json');
echo json_encode(['error' => $appDebug ? $e->getMessage() : ($eingabeZuLang ? $meldung : 'Interner Serverfehler')]);
} else {
echo $eingabeZuLang ? '<h1>Eingabe zu lang</h1>' : '<h1>Fehler</h1>';
if ($appDebug) {
echo '<pre style="background:#fee;padding:20px;border:1px solid #c00;">';
echo '<strong>' . htmlspecialchars($e->getMessage()) . '</strong>' . "\n\n";
echo 'File: ' . htmlspecialchars($e->getFile()) . ':' . $e->getLine() . "\n\n";
echo htmlspecialchars($e->getTraceAsString());
echo '</pre>';
} else {
echo '<p style="font-family:sans-serif;color:#555;">' . $meldung . '</p>';
}
if ($eingabeZuLang) {
echo '<p style="font-family:sans-serif;"><a href="javascript:history.back()" style="color:#2563eb;">Zurück zum Formular</a></p>';
}
}
exit;
}