Files
2026-10-19 18:05:57 +00:00

1038 lines
42 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* install/index.php
*
* Eigenständiger Installations-Assistent (unabhängig von includes/bootstrap.php,
* da config/config.php zu diesem Zeitpunkt ja erst erstellt wird).
*
* Nach erfolgreicher Installation sollte dieses Verzeichnis aus
* Sicherheitsgründen vom Server gelöscht oder zumindest gesperrt werden.
*/
// Sicherheits-Header und Sitzungs-Cookie des Assistenten
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex, nofollow');
$installHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0])) === 'https';
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $installHttps, 'httponly' => true, 'samesite' => 'Lax']);
session_start();
define('ROOT_DIR', dirname(__DIR__));
define('LOCK_FILE', __DIR__ . '/.installed');
require_once ROOT_DIR . '/database/sqlite_schema.php';
/** Gleiche Regeln wie im Programm (validate_password_strength), damit das Admin-Konto nicht schwächer ist als alle anderen. */
function install_password_error(string $p): ?string
{
if (strlen($p) < 10) return 'Das Admin-Passwort muss mindestens 10 Zeichen lang sein.';
if (!preg_match('/[A-Z]/', $p)) return 'Das Admin-Passwort muss mindestens einen Großbuchstaben enthalten.';
if (!preg_match('/[a-z]/', $p)) return 'Das Admin-Passwort muss mindestens einen Kleinbuchstaben enthalten.';
if (!preg_match('/[0-9]/', $p)) return 'Das Admin-Passwort muss mindestens eine Ziffer enthalten.';
if (!preg_match('/[^A-Za-z0-9]/', $p)) return 'Das Admin-Passwort muss mindestens ein Sonderzeichen enthalten.';
return null;
}
/** Verständliche Meldung zu einem Datenbankfehler (mit dem technischen Text dahinter). */
function install_db_error(Throwable $e): string
{
$code = (int) ($e instanceof PDOException ? ($e->errorInfo[1] ?? $e->getCode()) : 0);
$hint = match (true) {
$code === 1045 => 'Benutzername oder Passwort der Datenbank stimmen nicht.',
$code === 1049 => 'Die Datenbank existiert nicht. Legen Sie sie zuerst (leer) an, z. B. im Hosting-Bereich.',
$code === 1044 => 'Der Datenbank-Benutzer hat keine Rechte auf diese Datenbank.',
$code === 2002 || $code === 2006 => 'Der Datenbank-Server ist nicht erreichbar. Prüfen Sie Host (und Port, z. B. localhost:3306).',
$code === 2054 => 'Der Datenbank-Server verlangt eine Anmeldemethode, die diese PHP-Version nicht unterstützt.',
default => 'Prüfen Sie Host, Datenbankname, Benutzer und Passwort.',
};
return 'Datenbankverbindung fehlgeschlagen. ' . $hint . ' (Technisch: ' . $e->getMessage() . ')';
}
/**
* Schreibt die Konfiguration so, dass sie sicher gilt: auch wenn schon eine (alte, z. B. mitgelieferte) config.php liegt,
* die dem Webserver nicht gehört oder nicht beschreibbar ist, und mit Löschen des PHP-Zwischenspeichers (OPcache),
* damit nicht weiter die alte Version geladen wird.
*/
function install_write_config(string $path, string $content): bool
{
$ok = @file_put_contents($path, $content) !== false;
if (!$ok && is_file($path)) {
@chmod($path, 0644);
$ok = @file_put_contents($path, $content) !== false;
if (!$ok && @unlink($path)) $ok = @file_put_contents($path, $content) !== false; // Datei gehört einem anderen Benutzer, der Ordner aber nicht
}
if ($ok) {
clearstatcache(true, $path);
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
}
return $ok;
}
/** Liest die geschriebene Konfiguration frisch ein und prüft, dass genau die eingegebenen Daten darin stehen. Gibt null oder eine Fehlermeldung zurück. */
function install_verify_config(string $path, array $expected): ?string
{
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
$onDisk = (string) @file_get_contents($path);
if (!str_contains($onDisk, var_export((string) $expected['app_key'], true))) return 'Die Konfigurationsdatei enthält nicht die neuen Daten. Prüfen Sie die Schreibrechte auf config/config.php.';
$loaded = @include $path;
$db = is_array($loaded) ? (array) ($loaded['db'] ?? []) : [];
foreach (['driver', 'host', 'name', 'user', 'pass'] as $k) {
if ((string) ($db[$k] ?? '') !== (string) $expected['db'][$k]) {
return 'Die Konfiguration wurde geschrieben, PHP liefert aber noch eine ältere Version (Zwischenspeicher/OPcache des Servers). Laden Sie PHP neu (Hosting-Oberfläche) oder warten Sie einige Minuten und starten Sie die Installation erneut.';
}
}
return null;
}
function h(?string $s): string
{
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
}
// URL der aktuellen Installation automatisch erkennen
$detectedUrl = (function(): string {
$scheme = $GLOBALS['installHttps'] ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost';
// install/ vom Pfad entfernen → Root-URL
$path = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
$path = preg_replace('#/install$#', '', $path);
return rtrim($scheme . '://' . $host . $path, '/');
})();
/**
* Fehlt die Sperrdatei (z. B. weil Dateien neu hochgeladen wurden), darf der Assistent trotzdem keine bestehende
* Installation überschreiben: Gibt es schon eine Konfiguration mit Datenbank und Mitarbeiterkonten, gilt sie als installiert.
*/
$existingInstallation = (function (): bool {
$cfgFile = ROOT_DIR . '/config/config.php';
if (!is_file($cfgFile)) return false;
try {
$cfg = (array) (require $cfgFile);
$db = (array) ($cfg['db'] ?? []);
if (($db['driver'] ?? 'sqlite') === 'mysql') {
$pdo = new PDO('mysql:host=' . ($db['host'] ?? 'localhost') . ';dbname=' . ($db['name'] ?? '') . ';charset=utf8mb4', (string) ($db['user'] ?? ''), (string) ($db['pass'] ?? ''), [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 3]);
} else {
$path = (string) ($db['sqlite_path'] ?? '');
if ($path === '' || !is_file($path)) return false;
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
}
return (int) $pdo->query('SELECT COUNT(*) FROM staff')->fetchColumn() > 0;
} catch (Throwable $e) {
return false; // keine nutzbare Datenbank: Erstinstallation ist in Ordnung
}
})();
$alreadyInstalled = file_exists(LOCK_FILE) || $existingInstallation;
$errors = [];
$success = false;
$manualConfig = null;
// ---------------------------------------------------------------
// Anforderungsprüfung
// ---------------------------------------------------------------
// Ordner anlegen, die für den Betrieb nötig sind (falls beim Hochladen leer weggelassen)
foreach (['uploads', 'backups', 'plugins', 'app_downloads', 'config'] as $__dir) {
if (!is_dir(ROOT_DIR . '/' . $__dir)) @mkdir(ROOT_DIR . '/' . $__dir, 0755, true);
}
$checks = [
['label' => 'PHP-Version 8.2 oder neuer (gefunden: ' . PHP_VERSION . ')', 'ok' => version_compare(PHP_VERSION, '8.2.0', '>=')],
['label' => 'PDO-Erweiterung', 'ok' => extension_loaded('pdo')],
['label' => 'PDO MySQL-Treiber (für Produktivbetrieb)', 'ok' => extension_loaded('pdo_mysql'), 'optional' => true],
['label' => 'PDO SQLite-Treiber (optional, für lokale Tests)', 'ok' => extension_loaded('pdo_sqlite'), 'optional' => true],
['label' => 'Mindestens ein Datenbanktreiber (MySQL oder SQLite)', 'ok' => extension_loaded('pdo_mysql') || extension_loaded('pdo_sqlite')],
['label' => 'mbstring-Erweiterung', 'ok' => extension_loaded('mbstring')],
['label' => 'openssl-Erweiterung (Verschlüsselung der Zugangsdaten, Signaturen)', 'ok' => extension_loaded('openssl')],
['label' => 'json-Erweiterung', 'ok' => extension_loaded('json')],
['label' => 'curl-Erweiterung (optional: Webhooks, Plugin-Katalog, Updates)', 'ok' => extension_loaded('curl'), 'optional' => true],
['label' => 'zip-Erweiterung (optional: Plugins, Themes, Update per ZIP, Backups)', 'ok' => extension_loaded('zip'), 'optional' => true],
['label' => 'IMAP-Erweiterung (optional, nur für E-Mail-zu-Ticket)', 'ok' => extension_loaded('imap'), 'optional' => true],
['label' => 'Verzeichnis config/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/config')],
['label' => 'Verzeichnis uploads/ ist beschreibbar', 'ok' => is_writable(ROOT_DIR . '/uploads')],
['label' => 'Verzeichnis database/ ist beschreibbar (nur für SQLite nötig)', 'ok' => is_writable(ROOT_DIR . '/database'), 'optional' => true],
['label' => 'Verzeichnis backups/ ist beschreibbar (für Backups)', 'ok' => is_writable(ROOT_DIR . '/backups'), 'optional' => true],
['label' => 'HTTPS (empfohlen: Anmeldedaten sonst unverschlüsselt)', 'ok' => $installHttps, 'optional' => true],
];
$requiredOk = true;
foreach ($checks as $c) {
if (empty($c['optional']) && !$c['ok']) {
$requiredOk = false;
}
}
// ---------------------------------------------------------------
// Installation durchführen
// ---------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$alreadyInstalled && $requiredOk) {
if (empty($_SESSION['install_csrf']) || !hash_equals($_SESSION['install_csrf'], $_POST['csrf_token'] ?? '')) {
$errors[] = 'Ungültige Anfrage (Sitzung abgelaufen). Bitte die Seite neu laden und erneut versuchen.';
} else {
$driver = ($_POST['driver'] ?? '') === 'mysql' ? 'mysql' : 'sqlite';
$siteName = trim($_POST['site_name'] ?? '') ?: 'DeskHub Support';
$siteUrl = rtrim(trim($_POST['site_url'] ?? ''), '/');
$adminName = trim($_POST['admin_name'] ?? '');
$adminEmail = trim($_POST['admin_email'] ?? '');
$adminPass = $_POST['admin_password'] ?? '';
$adminPass2 = $_POST['admin_password2'] ?? '';
if ($siteUrl === '') $errors[] = 'Bitte die URL der Installation angeben (z.B. https://support.firma.de).';
if ($adminName === '') $errors[] = 'Bitte einen Namen für das Admin-Konto angeben.';
if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige Admin-E-Mail-Adresse angeben.';
if (($pwError = install_password_error($adminPass)) !== null) $errors[] = $pwError;
if ($adminPass !== $adminPass2) $errors[] = 'Die beiden Passwörter stimmen nicht überein.';
if (!extension_loaded('pdo_' . $driver)) $errors[] = "Die PHP-Erweiterung pdo_{$driver} ist nicht installiert.";
$dbConfig = [
'driver' => $driver,
'host' => 'localhost',
'name' => 'deskhub',
'user' => 'deskhub_user',
'pass' => 'change_me',
'sqlite_path' => ROOT_DIR . '/database/deskhub.sqlite',
];
if ($driver === 'mysql') {
$dbConfig['host'] = trim($_POST['db_host'] ?? 'localhost') ?: 'localhost';
$dbConfig['name'] = trim($_POST['db_name'] ?? '');
$dbConfig['user'] = trim($_POST['db_user'] ?? '');
$dbConfig['pass'] = $_POST['db_pass'] ?? '';
if ($dbConfig['name'] === '' || $dbConfig['user'] === '') {
$errors[] = 'Bitte Datenbankname und Datenbankbenutzer angeben.';
}
}
$pdo = null;
if (!$errors) {
try {
if ($driver === 'mysql') {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $dbConfig['host'], $dbConfig['name']);
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass']);
} else {
// Nur bei gewähltem SQLite wird eine Datenbank-Datei angelegt, und nur für diese Installation:
// Bricht sie ab, wird die halbfertige Datei am Ende des Aufrufs wieder entfernt.
@unlink($dbConfig['sqlite_path']);
$sqlitePath = $dbConfig['sqlite_path'];
register_shutdown_function(function () use (&$success, &$pdo, &$stmt, $sqlitePath) {
if ($success) return;
// Verbindung und Statements schließen, sonst lässt sich die Datei (vor allem unter Windows) nicht löschen
$stmt = null;
$pdo = null;
gc_collect_cycles();
foreach ([$sqlitePath, $sqlitePath . '-wal', $sqlitePath . '-shm'] as $leftover) @unlink($leftover);
});
$pdo = new PDO('sqlite:' . $dbConfig['sqlite_path']);
$pdo->exec('PRAGMA foreign_keys = ON;');
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (Throwable $e) {
$errors[] = install_db_error($e);
}
}
if (!$errors && $pdo) {
try {
$sql = file_get_contents(ROOT_DIR . '/database/schema.sql');
if ($driver === 'sqlite') {
$sql = convert_schema_to_sqlite($sql);
}
$pdo->exec($sql);
// Vordefiniertes Demo-Admin-Konto entfernen und durch das hier
// eingegebene, echte Admin-Konto ersetzen.
$pdo->prepare('DELETE FROM staff WHERE email IN (?, ?)')->execute(['admin@example.com', $adminEmail]);
$stmt = $pdo->prepare(
'INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'
);
$stmt->execute([$adminName, $adminEmail, password_hash($adminPass, PASSWORD_DEFAULT), 'admin', 1, date('Y-m-d H:i:s')]);
// ── Initiale Einstellungen in die DB seeden ───────────────────────
// Alle Kerneinstellungen sofort in der settings-Tabelle anlegen,
// damit sie Updates überleben (config.php wird beim Update nie
// überschrieben, aber so sind die Werte auch bei Neuinstallationen
// sofort über get_setting() abrufbar).
// Sicherer, zufälliger App-Key – direkt hier generieren, damit er
// sowohl in die DB (überlebt Updates) als auch in config.php geschrieben wird.
$appKey = bin2hex(random_bytes(32));
$installSettings = [
'app_key' => $appKey, // ← in DB: überlebt Updates
'display_name' => $siteName,
'site_url' => $siteUrl,
'timezone' => 'Europe/Berlin',
'smtp_from_email' => $adminEmail,
'smtp_from_name' => $siteName,
'mail_enabled' => '0',
'helpdesk_status' => 'online',
'kb_enabled' => '1',
'ticket_ratings_enabled' => '1',
'staff_session_timeout' => '30',
'retention_enabled' => '0',
'retention_months' => '24',
'ip_anonymize' => '0',
'upload_allowed_extensions' => 'png, jpg, jpeg, gif, pdf, txt, log, docx, xlsx, zip',
'upload_max_size_mb' => '10',
];
$upsertSql = $driver === 'sqlite'
? 'INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?, ?)'
: 'INSERT INTO settings (setting_key, setting_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)';
foreach ($installSettings as $k => $v) {
$pdo->prepare($upsertSql)->execute([$k, $v]);
}
// Alle vorhandenen Migrationsdateien sofort als "eingespielt" markieren,
// da das Schema gerade frisch erstellt wurde.
$migrTable = $driver === 'sqlite'
? "CREATE TABLE IF NOT EXISTS schema_migrations (migration VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT (datetime('now')))"
: "CREATE TABLE IF NOT EXISTS schema_migrations (migration VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$pdo->exec($migrTable);
$insertMigr = $driver === 'sqlite'
? 'INSERT OR IGNORE INTO schema_migrations (migration) VALUES (?)'
: 'INSERT IGNORE INTO schema_migrations (migration) VALUES (?)';
foreach (glob(ROOT_DIR . '/database/migrations/*.sql') ?: [] as $mf) {
$pdo->prepare($insertMigr)->execute([basename($mf)]);
}
} catch (Throwable $e) {
$errors[] = 'Fehler beim Einrichten der Datenbank: ' . $e->getMessage();
}
}
if (!$errors) {
$configArray = [
'app_key' => $appKey,
'db' => $dbConfig,
'site' => [
'name' => $siteName,
'url' => $siteUrl,
'admin_email' => $adminEmail,
'timezone' => 'Europe/Berlin',
],
'mail' => [
'enabled' => false,
'from_email' => $adminEmail,
'from_name' => $siteName,
],
'imap' => [
'enabled' => false,
'mailbox' => '{imap.example.com:993/imap/ssl}INBOX',
'user' => 'support@example.com',
'pass' => 'change_me',
'default_department_id' => 1,
'default_priority_id' => 2,
],
'uploads' => [
'max_size_bytes' => 10 * 1024 * 1024,
'allowed_extensions' => ['png','jpg','jpeg','gif','pdf','txt','log','docx','xlsx','zip'],
'path' => ROOT_DIR . '/uploads',
],
];
$php = "<?php\n/**\n * DeskHub – Konfiguration\n"
. " * Automatisch erstellt durch den Installations-Assistenten am " . date('d.m.Y H:i') . "\n"
. " * WICHTIG: app_key niemals ändern – verschlüsselte Werte in der DB werden sonst unlesbar.\n"
. " */\n\nreturn " . var_export($configArray, true) . ";\n";
$cfgPath = ROOT_DIR . '/config/config.php';
if (!install_write_config($cfgPath, $php)) {
$errors[] = 'config/config.php konnte nicht geschrieben werden. Die Datenbank ist eingerichtet. Speichern Sie den unten angezeigten Inhalt als config/config.php auf dem Server (oder geben Sie dem Webserver Schreibrechte auf den Ordner config/) und laden Sie die Seite neu.';
$manualConfig = $php;
} else {
@chmod($cfgPath, 0640); // enthält Schlüssel und Datenbank-Passwort
if (($verifyError = install_verify_config($cfgPath, $configArray)) !== null) {
$errors[] = $verifyError;
$manualConfig = $php;
} else {
// Programm-Migrationen (Schritte mit Code statt SQL) jetzt mit der fertigen Konfiguration einspielen: Fehler erscheinen hier
// und nicht erst beim ersten Seitenaufruf. Scheitert das, wird die Konfiguration wieder entfernt, damit ein neuer Versuch möglich ist.
try {
require_once ROOT_DIR . '/includes/db.php';
require_once ROOT_DIR . '/includes/functions.php';
get_db();
@file_put_contents(LOCK_FILE, date('Y-m-d H:i:s') . ' von ' . ($_SERVER['REMOTE_ADDR'] ?? 'CLI'));
$success = true;
} catch (Throwable $e) {
@unlink($cfgPath);
$errors[] = 'Fehler beim Einspielen der Datenbank-Änderungen: ' . $e->getMessage();
}
}
}
}
}
}
if (empty($_SESSION['install_csrf'])) {
$_SESSION['install_csrf'] = bin2hex(random_bytes(32));
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DeskHub – Installation</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #16202a;
--primary: #0f6f6a;
--primary-dk:#0b5853;
--primary-lt:#e6f4f3;
--accent: #d97706;
--danger: #dc2626;
--danger-lt: #fef2f2;
--success: #16a34a;
--success-lt:#f0fdf4;
--border: #dde3ec;
--muted: #64748b;
--surface: #ffffff;
--bg: #f1f5f9;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--radius: 10px;
--shadow: 0 4px 24px rgba(15,23,42,.10), 0 1px 4px rgba(15,23,42,.06);
}
html, body {
min-height: 100vh;
font-family: var(--font);
font-size: 15px;
line-height: 1.55;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
/* ── Layout ── */
.i-shell {
display: flex;
min-height: 100vh;
}
/* ── Left brand panel ── */
.i-brand {
width: 340px;
flex-shrink: 0;
background: var(--ink);
background-image: radial-gradient(ellipse at 20% 50%, rgba(15,111,106,.35) 0%, transparent 65%),
radial-gradient(ellipse at 80% 10%, rgba(217,119,6,.15) 0%, transparent 55%);
display: flex;
flex-direction: column;
padding: 48px 36px;
position: sticky;
top: 0;
height: 100vh;
}
.i-brand-logo {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
letter-spacing: -.03em;
margin-bottom: 8px;
}
.i-brand-logo span { color: var(--accent); }
.i-brand-tagline {
font-size: .82rem;
color: #94a3b8;
margin-bottom: 48px;
}
.i-brand-features {
list-style: none;
display: flex;
flex-direction: column;
gap: 18px;
flex: 1;
}
.i-brand-features li {
display: flex;
gap: 12px;
align-items: flex-start;
}
.i-brand-feat-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
margin-top: 1px;
}
.i-brand-feat-text strong {
display: block;
font-size: .82rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 2px;
}
.i-brand-feat-text span {
font-size: .75rem;
color: #64748b;
}
.i-brand-footer {
font-size: .72rem;
color: #334155;
margin-top: 32px;
}
/* ── Right content area ── */
.i-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 24px 64px;
overflow-y: auto;
}
.i-box {
width: 100%;
max-width: 560px;
}
/* ── Section title ── */
.i-section-title {
font-size: .7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--primary);
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 2px solid var(--primary-lt);
display: flex;
align-items: center;
gap: 8px;
}
/* ── Cards ── */
.i-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px 28px 24px;
margin-bottom: 20px;
}
.i-card-title {
font-size: 1rem;
font-weight: 700;
color: var(--ink);
margin-bottom: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.i-card-title .num {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: .72rem;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ── Check list ── */
.i-checks { list-style: none; display: flex; flex-direction: column; gap: 0; }
.i-check-row {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 0;
border-bottom: 1px solid var(--border);
font-size: .875rem;
}
.i-check-row:last-child { border-bottom: none; }
.i-check-badge {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: .7rem;
font-weight: 800;
flex-shrink: 0;
}
.i-check-badge.ok { background: var(--success-lt); color: var(--success); }
.i-check-badge.err { background: var(--danger-lt); color: var(--danger); }
.i-check-badge.opt { background: #f1f5f9; color: var(--muted); }
.i-check-row.optional { opacity: .65; }
/* ── Form elements ── */
.i-field { margin-bottom: 16px; }
.i-field label {
display: block;
font-size: .8rem;
font-weight: 600;
color: #374151;
margin-bottom: 5px;
}
.i-field input,
.i-field select {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 7px;
font-size: .9rem;
font-family: var(--font);
color: var(--ink);
background: #fff;
transition: border-color .15s, box-shadow .15s;
outline: none;
}
.i-field input:focus,
.i-field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(15,111,106,.12);
}
.i-field input::placeholder { color: #b0bac6; }
.i-hint, .i-field .i-hint {
font-size: .74rem;
color: var(--muted);
margin-top: 4px;
}
.i-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
/* ── DB driver tabs ── */
.i-driver-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.i-driver-tab {
flex: 1;
border: 2px solid var(--border);
border-radius: 9px;
padding: 12px 14px;
cursor: pointer;
background: #fff;
transition: border-color .15s, background .15s;
text-align: left;
}
.i-driver-tab input[type=radio] { display: none; }
.i-driver-tab.active {
border-color: var(--primary);
background: var(--primary-lt);
}
.i-driver-tab-label {
font-size: .85rem;
font-weight: 700;
color: var(--ink);
margin-bottom: 2px;
}
.i-driver-tab-desc {
font-size: .73rem;
color: var(--muted);
}
.i-driver-tab.active .i-driver-tab-label { color: var(--primary-dk); }
/* ── Submit button ── */
.i-submit-btn {
width: 100%;
padding: 13px;
background: var(--primary);
color: #fff;
font-size: .95rem;
font-weight: 700;
font-family: var(--font);
border: none;
border-radius: 9px;
cursor: pointer;
transition: background .15s, transform .1s;
margin-top: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
letter-spacing: .01em;
}
.i-submit-btn:hover { background: var(--primary-dk); }
.i-submit-btn:active { transform: scale(.99); }
/* ── Alerts ── */
.i-alert {
padding: 12px 16px;
border-radius: 8px;
font-size: .875rem;
margin-bottom: 14px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.i-alert.error { background: var(--danger-lt); border: 1px solid #fca5a5; color: #991b1b; }
.i-alert.success { background: var(--success-lt); border: 1px solid #86efac; color: #166534; }
.i-alert.warning { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; }
.i-alert-icon { font-size: 1rem; line-height: 1.4; flex-shrink: 0; }
/* ── Success screen ── */
.i-success-icon {
width: 64px;
height: 64px;
background: var(--success-lt);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
margin: 0 auto 20px;
}
.i-action-btns { display: flex; gap: 12px; margin-top: 20px; }
.i-btn-primary {
flex: 1;
padding: 11px;
background: var(--primary);
color: #fff;
border-radius: 8px;
font-weight: 700;
font-size: .875rem;
text-align: center;
text-decoration: none;
transition: background .15s;
}
.i-btn-primary:hover { background: var(--primary-dk); color: #fff; text-decoration: none; }
.i-btn-secondary {
flex: 1;
padding: 11px;
background: #fff;
color: var(--ink);
border: 1px solid var(--border);
border-radius: 8px;
font-weight: 600;
font-size: .875rem;
text-align: center;
text-decoration: none;
transition: background .15s;
}
.i-btn-secondary:hover { background: var(--bg); color: var(--ink); text-decoration: none; }
code {
font-family: var(--mono);
font-size: .82em;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border);
}
.i-divider {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}
/* ── Password strength ── */
.i-pw-bar {
height: 4px;
border-radius: 2px;
background: var(--border);
margin-top: 6px;
overflow: hidden;
}
.i-pw-fill {
height: 100%;
border-radius: 2px;
width: 0%;
transition: width .3s, background .3s;
}
@media (max-width: 768px) {
.i-shell { flex-direction: column; }
.i-brand { width: 100%; height: auto; position: static; padding: 28px 24px; }
.i-brand-features { display: none; }
.i-content { padding: 28px 16px 48px; }
.i-row { grid-template-columns: 1fr; gap: 0; }
}
</style>
</head>
<body>
<div class="i-shell">
<!-- ── Brand Sidebar ── -->
<aside class="i-brand">
<div class="i-brand-logo">Desk<span>Hub</span></div>
<div class="i-brand-tagline">Helpdesk & Ticketsystem</div>
<ul class="i-brand-features">
<li>
<div class="i-brand-feat-icon">🎫</div>
<div class="i-brand-feat-text">
<strong>Ticket-Management</strong>
<span>Abteilungen, Prioritäten, SLA, Regeln</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">📧</div>
<div class="i-brand-feat-text">
<strong>E-Mail-Integration</strong>
<span>SMTP-Versand & IMAP-Abruf</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">👥</div>
<div class="i-brand-feat-text">
<strong>Kundenportal</strong>
<span>Self-Service, Wissensdatenbank</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">📊</div>
<div class="i-brand-feat-text">
<strong>Berichte & Statistiken</strong>
<span>SLA-Raten, Agenten-Performance</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">🔔</div>
<div class="i-brand-feat-text">
<strong>Benachrichtigungen</strong>
<span>Push, Discord, Telegram</span>
</div>
</li>
<li>
<div class="i-brand-feat-icon">🔒</div>
<div class="i-brand-feat-text">
<strong>Sicherheit</strong>
<span>2FA, verschlüsselte Zugangsdaten</span>
</div>
</li>
</ul>
<div class="i-brand-footer">DeskHub · Viper Network</div>
</aside>
<!-- ── Main content ── -->
<main class="i-content">
<div class="i-box">
<?php if ($alreadyInstalled): ?>
<!-- Already installed -->
<div class="i-card" style="text-align:center;padding:40px 28px">
<div class="i-success-icon">🔒</div>
<h2 style="margin-bottom:10px">Bereits installiert</h2>
<?php if (is_file(LOCK_FILE)): ?>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">Eingerichtet am <strong><?= h((string) file_get_contents(LOCK_FILE)) ?></strong></p>
<?php else: ?>
<p style="color:var(--muted);font-size:.9rem;margin-bottom:6px">Es gibt bereits eine Konfiguration mit Datenbank und Mitarbeiterkonten. Eine Neuinstallation würde diese Daten überschreiben und ist deshalb gesperrt.</p>
<?php endif; ?>
<div class="i-alert warning" style="margin-top:20px;text-align:left">
<span class="i-alert-icon">⚠️</span>
<span>Bitte das Verzeichnis <code>install/</code> vom Server löschen, um diese Seite zu deaktivieren.</span>
</div>
<div class="i-action-btns">
<a href="/login.php" class="i-btn-primary">Kundenportal</a>
<a href="/staff/login.php" class="i-btn-secondary">Mitarbeiter-Login</a>
</div>
<p style="font-size:.75rem;color:var(--muted);margin-top:16px;line-height:1.6">
Wirklich neu installieren? Erst ein Backup der Daten anlegen, dann <code>config/config.php</code>, <code>install/.installed</code> und die Datenbank (bei SQLite die Datei <code>database/deskhub.sqlite</code>) entfernen. Alle bisherigen Daten gehen dabei verloren.
</p>
</div>
<?php elseif ($success): ?>
<!-- Success screen -->
<div class="i-card" style="text-align:center;padding:40px 28px">
<div class="i-success-icon">✅</div>
<h2 style="margin-bottom:8px;color:var(--success)">Installation abgeschlossen!</h2>
<p style="color:var(--muted);font-size:.9rem">DeskHub wurde erfolgreich eingerichtet.</p>
<div class="i-action-btns">
<a href="/staff/login.php" class="i-btn-primary">→ Jetzt einloggen</a>
<a href="/login.php" class="i-btn-secondary">Kundenportal</a>
</div>
<hr class="i-divider">
<div class="i-alert warning" style="text-align:left">
<span class="i-alert-icon">🗑️</span>
<span><strong>Wichtig:</strong> Löschen Sie jetzt das Verzeichnis <code>install/</code> vom Server, damit niemand die Installation wiederholen kann.</span>
</div>
</div>
<?php else: ?>
<!-- Step 1: Requirements -->
<div class="i-card">
<div class="i-card-title">
<span class="num">1</span> Systemvoraussetzungen
</div>
<ul class="i-checks">
<?php foreach ($checks as $c):
$cls = $c['ok'] ? 'ok' : (empty($c['optional']) ? 'err' : 'opt');
$ico = $c['ok'] ? '✓' : (empty($c['optional']) ? '✗' : '–');
?>
<li class="i-check-row <?= empty($c['optional']) ? '' : 'optional' ?>">
<span class="i-check-badge <?= $cls ?>"><?= $ico ?></span>
<span><?= h($c['label']) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php if (!$requiredOk): ?>
<div class="i-alert error" style="margin-top:16px">
<span class="i-alert-icon">✗</span>
<span>Nicht alle Pflichtvoraussetzungen erfüllt. Bitte die rot markierten Punkte beheben und die Seite neu laden.</span>
</div>
<?php endif; ?>
</div>
<?php if ($requiredOk): ?>
<?php foreach ($errors as $err): ?>
<div class="i-alert error"><span class="i-alert-icon">✗</span><span><?= h($err) ?></span></div>
<?php endforeach; ?>
<?php if ($manualConfig !== null): ?>
<div class="i-card">
<div class="i-card-title">Inhalt für config/config.php</div>
<div class="i-hint" style="margin-bottom:10px">Enthält Schlüssel und Datenbank-Passwort: nur auf dem Server speichern, nirgends weitergeben.</div>
<textarea readonly rows="14" style="width:100%;font-family:monospace;font-size:.78rem" onclick="this.select()"><?= h($manualConfig) ?></textarea>
</div>
<?php endif; ?>
<!-- Step 2: Configuration form -->
<div class="i-card">
<div class="i-card-title">
<span class="num">2</span> Konfiguration
</div>
<form method="post" id="install-form">
<input type="hidden" name="csrf_token" value="<?= h($_SESSION['install_csrf']) ?>">
<!-- DB driver -->
<div class="i-section-title">🗄️ Datenbank</div>
<div class="i-driver-tabs" id="driver-tabs">
<label class="i-driver-tab <?= ($_POST['driver'] ?? 'mysql') === 'mysql' ? 'active' : '' ?>" id="tab-mysql">
<input type="radio" name="driver" value="mysql" <?= ($_POST['driver'] ?? 'mysql') === 'mysql' ? 'checked' : '' ?>>
<div class="i-driver-tab-label">MySQL / MariaDB</div>
<div class="i-driver-tab-desc">Produktivbetrieb empfohlen</div>
</label>
<label class="i-driver-tab <?= ($_POST['driver'] ?? '') === 'sqlite' ? 'active' : '' ?>" id="tab-sqlite">
<input type="radio" name="driver" value="sqlite" <?= ($_POST['driver'] ?? '') === 'sqlite' ? 'checked' : '' ?>>
<div class="i-driver-tab-label">SQLite</div>
<div class="i-driver-tab-desc">Nur für lokale Tests</div>
</label>
</div>
<div id="mysql-fields" style="display:<?= ($_POST['driver'] ?? 'mysql') === 'sqlite' ? 'none' : 'block' ?>">
<div class="i-row">
<div class="i-field">
<label for="db_host">Host</label>
<input type="text" id="db_host" name="db_host" value="<?= h($_POST['db_host'] ?? 'localhost') ?>" placeholder="localhost">
</div>
<div class="i-field">
<label for="db_name">Datenbankname</label>
<input type="text" id="db_name" name="db_name" value="<?= h($_POST['db_name'] ?? '') ?>" placeholder="deskhub">
</div>
</div>
<div class="i-row">
<div class="i-field">
<label for="db_user">Benutzer</label>
<input type="text" id="db_user" name="db_user" value="<?= h($_POST['db_user'] ?? '') ?>" placeholder="deskhub_user">
</div>
<div class="i-field">
<label for="db_pass">Passwort</label>
<input type="password" id="db_pass" name="db_pass" placeholder="••••••••">
</div>
</div>
<div class="i-hint" style="margin-bottom:16px">Die Datenbank muss bereits existieren (leer). Tabellen werden automatisch angelegt.</div>
</div>
<!-- Website -->
<div class="i-section-title" style="margin-top:8px">🌐 Website</div>
<div class="i-field">
<label for="site_name">Name der Installation</label>
<input type="text" id="site_name" name="site_name" value="<?= h($_POST['site_name'] ?? 'DeskHub Support') ?>" required>
</div>
<div class="i-field">
<label for="site_url">URL der Installation</label>
<input type="text" id="site_url" name="site_url" value="<?= h($_POST['site_url'] ?? $detectedUrl) ?>" placeholder="https://support.firma.de" required>
<div class="i-hint">Wird in E-Mail-Links verwendet. Kein abschließendes /</div>
</div>
<!-- Admin account -->
<div class="i-section-title" style="margin-top:8px">👤 Administrator-Konto</div>
<div class="i-row">
<div class="i-field">
<label for="admin_name">Name</label>
<input type="text" id="admin_name" name="admin_name" value="<?= h($_POST['admin_name'] ?? '') ?>" placeholder="Max Mustermann" required>
</div>
<div class="i-field">
<label for="admin_email">E-Mail</label>
<input type="email" id="admin_email" name="admin_email" autocapitalize="none" autocorrect="off" spellcheck="false" value="<?= h($_POST['admin_email'] ?? '') ?>" placeholder="admin@firma.de" required>
</div>
</div>
<div class="i-row">
<div class="i-field">
<label for="admin_password">Passwort</label>
<input type="password" id="admin_password" name="admin_password" placeholder="••••••••" required>
<div class="i-pw-bar"><div class="i-pw-fill" id="pw-fill"></div></div>
<div class="i-hint">Mindestens 10 Zeichen mit Groß- und Kleinbuchstaben, Ziffer und Sonderzeichen.</div>
</div>
<div class="i-field">
<label for="admin_password2">Passwort wiederholen</label>
<input type="password" id="admin_password2" name="admin_password2" placeholder="••••••••" required>
</div>
</div>
<button type="submit" class="i-submit-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
Installation starten
</button>
</form>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</main>
</div>
<script>
// Driver tab switching
document.querySelectorAll('.i-driver-tab input[type=radio]').forEach(function(radio) {
radio.addEventListener('change', function() {
document.querySelectorAll('.i-driver-tab').forEach(function(t) { t.classList.remove('active'); });
this.closest('.i-driver-tab').classList.add('active');
document.getElementById('mysql-fields').style.display = this.value === 'mysql' ? 'block' : 'none';
});
});
// Doppelklick verhindern: sonst startet die Installation zweimal
var installForm = document.getElementById('install-form');
if (installForm) installForm.addEventListener('submit', function() {
var b = installForm.querySelector('button[type=submit]');
if (b) { b.disabled = true; b.textContent = 'Wird installiert …'; }
});
// Password strength bar
var pwInput = document.getElementById('admin_password');
var pwFill = document.getElementById('pw-fill');
if (pwInput && pwFill) {
pwInput.addEventListener('input', function() {
var v = this.value, score = 0;
if (v.length >= 10) score++;
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) score++;
if (/[0-9]/.test(v)) score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
if (v.length >= 14) score++;
var colors = ['#ef4444','#f97316','#eab308','#22c55e','#16a34a'];
var widths = ['20%','40%','60%','80%','100%'];
pwFill.style.width = score > 0 ? widths[score-1] : '0%';
pwFill.style.background = score > 0 ? colors[score-1] : 'transparent';
});
}
</script>
</body>
</html>