OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

index.php

14 KB
<?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&uuml;r diese Aktion fehlt die Berechtigung. Wende dich an eine '
                . 'Administratorin oder einen Administrator, wenn du sie ben&ouml;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;
}