From 2e20e5472f7efeb76eab8d620257111e0532d619 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 20 Sep 2026 13:41:15 +0000 Subject: [PATCH] Add PnPaaS application and browser installer --- README-INSTALL.txt | 40 +++ app/.htaccess | 10 + app/README.php | 14 + app/activate-account.php | 99 ++++++ app/assets/style.css | 281 +++++++++++++++++ app/config.example.php | 20 ++ app/dashboard.php | 290 ++++++++++++++++++ .../migrations/001_account_activation.sql | 20 ++ .../002_customer_registration_data.sql | 23 ++ app/database/schema.sql | 122 ++++++++ app/forgot-password.php | 74 +++++ app/includes/bootstrap.php | 221 +++++++++++++ app/index.php | 93 ++++++ app/installer.php | 126 ++++++++ app/logout.php | 17 + app/register.php | 175 +++++++++++ app/reset-password.php | 111 +++++++ install.sh | 154 ++++++++++ 18 files changed, 1890 insertions(+) create mode 100644 README-INSTALL.txt create mode 100644 app/.htaccess create mode 100644 app/README.php create mode 100644 app/activate-account.php create mode 100644 app/assets/style.css create mode 100644 app/config.example.php create mode 100644 app/dashboard.php create mode 100644 app/database/migrations/001_account_activation.sql create mode 100644 app/database/migrations/002_customer_registration_data.sql create mode 100644 app/database/schema.sql create mode 100644 app/forgot-password.php create mode 100644 app/includes/bootstrap.php create mode 100644 app/index.php create mode 100644 app/installer.php create mode 100644 app/logout.php create mode 100644 app/register.php create mode 100644 app/reset-password.php create mode 100755 install.sh diff --git a/README-INSTALL.txt b/README-INSTALL.txt new file mode 100644 index 0000000..1bd1001 --- /dev/null +++ b/README-INSTALL.txt @@ -0,0 +1,40 @@ +PnPaaS – Installation ohne Shellzugriff +======================================== + +Voraussetzungen +--------------- +- Webhosting mit PHP 8.1 oder neuer +- PHP-Erweiterungen pdo_mysql, curl und mbstring +- Eine bereits angelegte MariaDB/MySQL-Datenbank +- Ein bereits angelegter Datenbankbenutzer mit Rechten auf diese Datenbank +- HTTPS für die öffentliche Domain +- Schreibrechte des Webserver-Benutzers auf `app/includes/` während der Installation + +Installation per Browser +------------------------ +1. Archiv lokal entpacken. +2. Den gesamten Inhalt des Verzeichnisses `app/` per FTP, SFTP oder Hosting-Dateimanager direkt in das gewünschte Webverzeichnis hochladen – entweder in ein Unterverzeichnis wie `public_html/pnpaas/` oder direkt in den Webroot `public_html/`. +3. Im Browser `https://DEINE-DOMAIN/installer.php` öffnen. Liegt die Anwendung in einem Unterverzeichnis, lautet die URL entsprechend `https://DEINE-DOMAIN/pnpaas/installer.php`. +4. MariaDB-Zugangsdaten, Administrator, öffentliche HTTPS-URL sowie Portainer- und Rechtstext-Daten eingeben. +5. Der Browser-Assistent importiert Schema und Migrationen, legt den Administrator an und schreibt die geschützte Konfiguration nach `includes/runtime-config.php`. +6. Nach erfolgreicher Installation wird `installer.php` automatisch gelöscht. Falls das Hosting das Löschen von PHP-Dateien blockiert, bleibt die Datei durch `.install-complete` gesperrt und muss über den Dateimanager gelöscht werden. + +Wichtig +------- +- Es werden keine MariaDB-Root-Zugangsdaten benötigt oder abgefragt. +- Die Datenbank und der eingeschränkte Datenbankbenutzer müssen vor dem Browser-Aufruf über das Hosting-Panel existieren. +- `includes/`, `database/`, `.env`, SQL-Dateien und die Laufzeitkonfiguration sind durch `.htaccess` geschützt. Die Regeln müssen vom Hosting unterstützt werden. +- `installer.php` darf nach der Einrichtung nicht dauerhaft im Webverzeichnis verbleiben. +- Die Anwendung setzt sichere Cookies voraus und muss produktiv über HTTPS laufen. + +Nach der Installation prüfen +---------------------------- +- Anmeldung mit dem Administrator-Konto +- HTTP-403 für `/includes/` und `/database/` +- Keine Auslieferung von `runtime-config.php` oder `.install-complete` +- Portainer-Zugriff +- Registrierung erst nach Konfiguration der Rechtstext-URLs + +Alternative für Server mit Shellzugriff +-------------------------------------- +Das Paket enthält weiterhin `install.sh` für eine vollständig serverseitige Installation. Für den hier beschriebenen Ablauf ist jedoch weder Shellzugriff noch der Aufruf dieses Skripts erforderlich. diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000..f8ebe4d --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,10 @@ +Options -Indexes + + + Require all denied + + + + RewriteEngine On + RewriteRule ^(?:includes|database)(?:/|$) - [F,L] + diff --git a/app/README.php b/app/README.php new file mode 100644 index 0000000..3d2fbf8 --- /dev/null +++ b/app/README.php @@ -0,0 +1,14 @@ +prepare( + 'SELECT t.id, t.user_id + FROM account_activation_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token_hash = :token_hash AND t.used_at IS NULL + AND t.expires_at > NOW() AND u.status = "pending" + LIMIT 1' + ); + $check->execute(['token_hash' => hash('sha256', $token)]); + $validToken = is_array($check->fetch()); + if (!$validToken) $error = 'Dieser Aktivierungslink ist ungültig oder abgelaufen.'; + } catch (Throwable $exception) { + error_log('PnPaaS activation validation error: ' . $exception->getMessage()); + $error = 'Der Aktivierungslink ist derzeit nicht verfügbar.'; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $error === null) { + if (!pnpaas_valid_csrf((string)($_POST['csrf_token'] ?? ''))) { + $error = 'Die Sitzung ist abgelaufen. Bitte öffnen Sie den Aktivierungslink erneut.'; + } else { + try { + $db = pnpaas_db(); + $db->beginTransaction(); + $find = $db->prepare( + 'SELECT t.id, t.user_id + FROM account_activation_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token_hash = :token_hash AND t.used_at IS NULL + AND t.expires_at > NOW() AND u.status = "pending" + LIMIT 1 FOR UPDATE' + ); + $find->execute(['token_hash' => hash('sha256', $token)]); + $activation = $find->fetch(); + if (!is_array($activation)) { + $db->rollBack(); + $validToken = false; + $error = 'Dieser Aktivierungslink ist ungültig oder abgelaufen.'; + } else { + $updateUser = $db->prepare('UPDATE users SET status = "active" WHERE id = :user_id AND status = "pending"'); + $updateUser->execute(['user_id' => (int)$activation['user_id']]); + $used = $db->prepare('UPDATE account_activation_tokens SET used_at = NOW() WHERE id = :id'); + $used->execute(['id' => (int)$activation['id']]); + $db->commit(); + $validToken = false; + $success = 'Ihr Konto wurde aktiviert. Sie können sich jetzt anmelden.'; + } + } catch (Throwable $exception) { + if (isset($db) && $db->inTransaction()) $db->rollBack(); + error_log('PnPaaS account activation error: ' . $exception->getMessage()); + $error = 'Das Konto konnte nicht aktiviert werden. Bitte versuchen Sie es erneut.'; + } + } +} +?> + + + + + + PnPaaS – Konto aktivieren + + + + + + +
+ +

PnPaaS

+

Konto aktivieren

+ +
+

Bestätigen Sie die Aktivierung Ihres PnPaaS-Kontos.

+ + +
+ + diff --git a/app/assets/style.css b/app/assets/style.css new file mode 100644 index 0000000..3c33730 --- /dev/null +++ b/app/assets/style.css @@ -0,0 +1,281 @@ +:root { + --ink: #030303; + --text: #464646; + --muted: #a1a1a1; + --accent: #00a9ff; + --accent-dark: #008bd1; + --line: #e8e8e8; + --surface: #ffffff; + --surface-soft: #f6f6f6; + --shadow: 0 1px 5px rgba(0, 0, 0, .10); + font-family: "Open Sans", Arial, sans-serif; + color: var(--text); + background: var(--surface); +} + +* { box-sizing: border-box; } + +html { min-height: 100%; } + +body { + min-height: 100vh; + margin: 0; + background: var(--surface); + color: var(--text); + font-size: 15px; + line-height: 1.7; +} + +body::selection { color: #fff; background: var(--accent); } + +h1, h2, h3, h4, p { margin-top: 0; } + +h1, h2, h3 { + color: var(--ink); + font-family: Montserrat, "Open Sans", Arial, sans-serif; + font-weight: 600; + line-height: 1.25; +} + +h1 { font-size: clamp(2rem, 4vw, 3rem); letter-spacing: -.035em; } +h2 { font-size: 1.35rem; } + +button, input { font: inherit; } + +button { + border: 0; + cursor: pointer; +} + +a { + color: var(--accent); + font-weight: 600; + text-decoration: none; + transition: color .2s ease, background-color .2s ease, transform .2s ease; +} + +a:hover { color: var(--accent-dark); } + +.auth-page { + display: grid; + min-height: 100vh; + place-items: center; + padding: 2rem 1rem; + background: linear-gradient(180deg, #fff 0%, #fafafa 100%); +} + +.auth-card { + width: min(100%, 31rem); + padding: clamp(2rem, 6vw, 3.5rem); + background: var(--surface); + border: 1px solid var(--line); + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, .07); +} + +.brand-mark { + display: grid; + width: 3.5rem; + height: 3.5rem; + place-items: center; + margin-bottom: 2rem; + color: #fff; + background: var(--accent); + border-radius: 50%; + font-family: Montserrat, sans-serif; + font-size: 1.25rem; + font-weight: 600; + box-shadow: 0 0 0 .45rem rgba(0, 169, 255, .10); +} + +.eyebrow { + margin-bottom: .6rem; + color: var(--accent); + font-family: Montserrat, "Open Sans", sans-serif; + font-size: .72rem; + font-weight: 600; + letter-spacing: .16em; + text-transform: uppercase; +} + +.auth-card h1 { margin-bottom: .75rem; } +.intro { color: var(--muted); line-height: 1.75; } + +.login-form { + display: grid; + gap: .65rem; + margin-top: 2rem; +} + +label { + margin-top: .45rem; + color: var(--text); + font-family: Montserrat, "Open Sans", sans-serif; + font-size: .75rem; + font-weight: 600; + letter-spacing: .02em; +} + +input, select { + width: 100%; + padding: .85rem 1rem; + color: var(--text); + background: #fff; + border: 1px solid var(--line); + border-radius: 0; + outline: 0; + transition: border-color .2s ease, box-shadow .2s ease; +} + +input:focus, select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(0, 169, 255, .12); +} + +.consent-line { display: flex; align-items: flex-start; gap: .6rem; margin-top: .8rem; font-family: "Open Sans", Arial, sans-serif; font-size: .82rem; font-weight: 400; letter-spacing: 0; line-height: 1.5; } +.consent-line input { width: auto; flex: 0 0 auto; margin-top: .25rem; } +fieldset:disabled { opacity: .6; } + +button { + margin-top: .9rem; + padding: .9rem 1.2rem; + color: #fff; + background: #333; + border-radius: 0; + font-family: Montserrat, "Open Sans", sans-serif; + font-size: .75rem; + font-weight: 600; + letter-spacing: .1em; + text-transform: uppercase; +} + +button:hover { background: var(--accent); transform: translateY(-1px); } + +.alert { + margin-top: 1.25rem; + padding: .8rem 1rem; + border: 1px solid var(--line); + line-height: 1.5; + font-size: .9rem; +} + +.alert-error { color: #a33b3b; background: #fff7f7; border-color: #efcaca; } +.alert-success { color: #24724b; background: #f4fbf7; border-color: #bfe4cd; } +.form-footer { margin: 1.25rem 0 0; color: var(--muted); text-align: center; font-size: .85rem; } + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + min-height: 4.25rem; + padding: 0 clamp(1rem, 5vw, 4rem); + background: #fff; + border-bottom: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.topbar-brand { display: flex; align-items: center; flex-shrink: 0; } +.topbar-brand strong { color: var(--ink); font-family: Montserrat, "Open Sans", sans-serif; font-size: 1.05rem; font-weight: 600; letter-spacing: .02em; } +.topbar-subtitle { margin-left: .85rem; color: var(--muted); font-size: .85rem; } +.main-nav { display: flex; align-items: center; justify-content: center; gap: .25rem; flex: 1; } +.main-nav a { padding: .65rem .8rem; color: var(--muted); font-family: Montserrat, "Open Sans", sans-serif; font-size: .72rem; font-weight: 600; letter-spacing: .04em; } +.main-nav a:hover, .main-nav a.active { color: var(--ink); background: var(--surface-soft); } +.main-nav a.active { box-shadow: inset 0 -2px 0 var(--accent); } +.topbar-actions { display: flex; align-items: center; flex-shrink: 0; gap: 1.25rem; color: var(--muted); font-size: .85rem; } +.topbar-actions a { font-family: Montserrat, "Open Sans", sans-serif; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; } + +.dashboard { + width: min(100% - 2rem, 72rem); + margin: 0 auto; + padding: clamp(3rem, 8vw, 6rem) 0; +} + +.dashboard h1 { max-width: 42rem; margin-bottom: .9rem; } +.dashboard > .intro { max-width: 42rem; } + +.card-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-top: 3.5rem; +} + +.dashboard-card { + min-height: 15rem; + padding: 1.75rem; + background: #fff; + border: 1px solid var(--line); + transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; +} + +.dashboard-card:hover { + border-color: rgba(0, 169, 255, .55); + box-shadow: 0 1rem 2rem rgba(0, 0, 0, .07); + transform: translateY(-2px); +} + +.card-icon { + color: var(--accent); + font-family: Montserrat, "Open Sans", sans-serif; + font-size: .72rem; + font-weight: 600; + letter-spacing: .12em; +} + +.dashboard-card h2 { margin: 1.8rem 0 .65rem; } +.dashboard-card p { min-height: 4.2rem; color: var(--muted); line-height: 1.65; } + +.status-badge { + display: inline-block; + padding: .3rem .65rem; + color: var(--text); + background: var(--surface-soft); + border: 1px solid var(--line); + font-family: Montserrat, "Open Sans", sans-serif; + font-size: .68rem; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; +} + +.status-online { color: #24724b; background: #f4fbf7; border-color: #bfe4cd; } +.data-section { margin-top: 4rem; } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; } +.section-heading h2 { margin: 0; } +.table-wrap { overflow-x: auto; border: 1px solid var(--line); } +.data-table { width: 100%; border-collapse: collapse; background: #fff; font-size: .9rem; } +.data-table th { color: var(--muted); background: var(--surface-soft); font-family: Montserrat, "Open Sans", sans-serif; font-size: .7rem; font-weight: 600; letter-spacing: .08em; text-align: left; text-transform: uppercase; } +.data-table th, .data-table td { padding: 1rem 1.1rem; border-bottom: 1px solid var(--line); vertical-align: middle; } +.data-table tbody tr:last-child td { border-bottom: 0; } +.data-table td { color: var(--text); } +.data-table code { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .82rem; } +.container-state { display: inline-block; color: var(--muted); font-size: .82rem; } +.container-state.running { color: #24724b; font-weight: 600; } +.container-state.exited, .container-state.dead { color: #a33b3b; } +.empty-state { padding: 2rem; color: var(--muted); background: var(--surface-soft); border: 1px solid var(--line); } + +.realm-grid { display: grid; gap: 1.5rem; margin-top: 3rem; } +.realm-card { padding: 1.75rem; background: #fff; border: 1px solid var(--line); box-shadow: var(--shadow); } +.realm-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.realm-card-header h2 { margin: 0; } +.status-offline { color: #a33b3b; background: #fff7f7; border-color: #efcaca; } +.realm-meta, .realm-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .75rem 1.5rem; color: var(--muted); font-size: .82rem; } +.realm-meta { margin-top: 1.25rem; padding-bottom: 1rem; border-bottom: 1px solid var(--line); } +.realm-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 1.5rem; } +.realm-metric { padding: 1rem; background: var(--surface-soft); border: 1px solid var(--line); } +.realm-metric strong { display: block; margin: .35rem 0 .1rem; color: var(--ink); font-family: Montserrat, sans-serif; font-size: 1.35rem; } +.realm-metric small { display: block; color: var(--muted); line-height: 1.45; } +.metric-label { color: var(--accent); font-family: Montserrat, sans-serif; font-size: .7rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; } +.realm-footer { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--line); font-size: .75rem; } +.realm-footer code { color: var(--text); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.dashboard-link { margin-top: 1.5rem; text-align: right; } + +@media (max-width: 700px) { + .card-grid, .realm-metrics { grid-template-columns: 1fr; } + .topbar { align-items: stretch; flex-direction: column; padding-top: 1rem; padding-bottom: 1rem; } + .topbar-brand { justify-content: space-between; } + .main-nav { align-items: stretch; flex-wrap: wrap; justify-content: flex-start; margin: .75rem 0; } + .main-nav a { flex: 1 1 auto; text-align: center; } + .topbar-actions { width: 100%; justify-content: space-between; } + .auth-card { padding: 2.25rem 1.75rem; } +} diff --git a/app/config.example.php b/app/config.example.php new file mode 100644 index 0000000..803ec4f --- /dev/null +++ b/app/config.example.php @@ -0,0 +1,20 @@ + +// PORTAINER_URL=https://portainer.example:9443 +// PORTAINER_API_KEY= +// PNPAAS_APP_URL=https://pnpaas.example/pnpaas +// PNPAAS_FORCE_SECURE_COOKIES=1 +// PNPAAS_MAIL_FROM=no-reply@pnpaas.example +// PNPAAS_TERMS_URL=https://example.com/agb +// PNPAAS_TERMS_VERSION=2026-01-01 +// PNPAAS_PRIVACY_URL=https://example.com/datenschutz +// PNPAAS_PRIVACY_VERSION=2026-01-01 +// PNPAAS_WITHDRAWAL_URL=https://example.com/widerruf +// PNPAAS_WITHDRAWAL_VERSION=2026-01-01 +// PNPAAS_PORTAINER_ENDPOINT_ID=3 diff --git a/app/dashboard.php b/app/dashboard.php new file mode 100644 index 0000000..cc3f957 --- /dev/null +++ b/app/dashboard.php @@ -0,0 +1,290 @@ +prepare('SELECT username, role, status FROM users WHERE id = :id LIMIT 1'); + $currentUserQuery->execute(['id' => $userId]); + $currentUser = $currentUserQuery->fetch(); +} catch (Throwable $exception) { + error_log('PnPaaS session user lookup error: ' . $exception->getMessage()); + $currentUser = false; +} +if (!is_array($currentUser) || (string)$currentUser['status'] !== 'active') { + $_SESSION = []; + session_destroy(); + header('Location: index.php'); + exit; +} +$_SESSION['pnpaas_admin'] = (string)$currentUser['username']; +$_SESSION['pnpaas_role'] = (string)$currentUser['role']; +$role = (string)$currentUser['role']; +$username = (string)$currentUser['username']; +$isAdmin = $role === 'admin'; +$csrf = pnpaas_csrf_token(); +$message = null; +$error = null; + +$plans = [ + 's' => ['label' => 'S', 'memory' => 512, 'cpu' => 1.00, 'storage' => 1024], + 'm' => ['label' => 'M', 'memory' => 2048, 'cpu' => 2.00, 'storage' => 10240], + 'l' => ['label' => 'L', 'memory' => 4096, 'cpu' => 4.00, 'storage' => 25600], +]; + +function dashboard_escape(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); } +function dashboard_status(string $status): string { + return match ($status) { + 'pending_payment' => 'Zahlung ausstehend', + 'provisioning' => 'Wird eingerichtet', + 'running' => 'Läuft', + 'stopped' => 'Gestoppt', + 'error' => 'Fehler', + default => ucfirst($status), + }; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!pnpaas_valid_csrf((string)($_POST['csrf_token'] ?? ''))) { + $error = 'Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.'; + } else { + $action = (string)($_POST['action'] ?? ''); + try { + $db = pnpaas_db(); + if ($action === 'request_instance') { + $name = trim((string)($_POST['name'] ?? '')); + $planKey = strtolower(trim((string)($_POST['plan'] ?? ''))); + $paymentMethod = (string)($_POST['payment_method'] ?? 'bank_transfer'); + if ($name === '' || !preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{1,119}$/', $name)) { + throw new RuntimeException('Bitte einen gültigen Instanznamen eingeben.'); + } + if (!isset($plans[$planKey]) || !in_array($paymentMethod, ['paypal', 'bank_transfer'], true)) { + throw new RuntimeException('Tarif oder Zahlungsart ist ungültig.'); + } + $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $name) ?? 'foundry'); + $containerName = 'pnpaas-' . trim($slug, '-') . '-' . bin2hex(random_bytes(3)); + $port = (int)$db->query('SELECT COALESCE(MAX(port), 29999) + 1 FROM instances')->fetchColumn(); + if ($port > 60000) throw new RuntimeException('Es sind derzeit keine Ports verfügbar.'); + $p = $plans[$planKey]; + $statement = $db->prepare('INSERT INTO instances (owner_user_id, name, plan, payment_status, payment_method, container_name, port, volume_size_mb, memory_limit_mb, cpu_limit, status) VALUES (:owner, :name, :plan, "pending", :method, :container, :port, :storage, :memory, :cpu, "pending_payment")'); + $statement->execute(['owner' => $userId, 'name' => $name, 'plan' => $planKey, 'method' => $paymentMethod, 'container' => $containerName, 'port' => $port, 'storage' => $p['storage'], 'memory' => $p['memory'], 'cpu' => $p['cpu']]); + $message = 'Die Instanzanfrage wurde erfasst und wartet auf die Zahlungsfreigabe.'; + } elseif ($action === 'approve_instance' && $isAdmin) { + $instanceId = (int)($_POST['instance_id'] ?? 0); + $query = $db->prepare('SELECT * FROM instances WHERE id = :id AND status = "pending_payment" LIMIT 1'); + $query->execute(['id' => $instanceId]); + $instance = $query->fetch(); + if (!$instance) throw new RuntimeException('Diese Instanzanfrage ist nicht mehr freigabefähig.'); + $db->prepare('UPDATE instances SET payment_status = "paid", paid_at = CURRENT_TIMESTAMP, status = "provisioning", error_message = NULL WHERE id = :id')->execute(['id' => $instanceId]); + $endpoint = pnpaas_portainer_endpoint_id(); + $dataPath = '/opt/foundry/instances/' . $instance['container_name']; + $payload = [ + 'Image' => 'pnpaas/foundryvtt:14.368', + 'Env' => ['FOUNDRY_VERSION=14.368'], + 'ExposedPorts' => ['30000/tcp' => new stdClass()], + 'HostConfig' => [ + 'Binds' => [$dataPath . ':/data'], + 'PortBindings' => ['30000/tcp' => [['HostPort' => (string)$instance['port']]]], + 'Memory' => (int)$instance['memory_limit_mb'] * 1024 * 1024, + 'NanoCpus' => (int)((float)$instance['cpu_limit'] * 1000000000), + 'RestartPolicy' => ['Name' => 'unless-stopped'], + ], + ]; + $created = pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/create?name=' . rawurlencode($instance['container_name']), 'POST', $payload); + $containerId = (string)($created['Id'] ?? ''); + if ($containerId === '') throw new RuntimeException('Portainer hat keine Container-ID geliefert.'); + pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/' . rawurlencode($containerId) . '/start', 'POST', []); + $db->prepare('UPDATE instances SET portainer_container_id = :cid, status = "running" WHERE id = :id')->execute(['cid' => $containerId, 'id' => $instanceId]); + $message = 'Die Instanz wurde bezahlt markiert, erstellt und gestartet.'; + } elseif ($action === 'instance_action') { + $instanceId = (int)($_POST['instance_id'] ?? 0); + $operation = (string)($_POST['operation'] ?? ''); + $allowedOperations = ['start', 'stop', 'restart', 'redeploy', 'delete']; + if (!in_array($operation, $allowedOperations, true)) { + throw new RuntimeException('Nicht unterstützte Instanzaktion.'); + } + $query = $db->prepare('SELECT * FROM instances WHERE id = :id AND status <> "deleted" AND (owner_user_id = :owner OR :is_admin = 1) LIMIT 1'); + $query->execute(['id' => $instanceId, 'owner' => $userId, 'is_admin' => $isAdmin ? 1 : 0]); + $instance = $query->fetch(); + if (!$instance || empty($instance['portainer_container_id'])) { + throw new RuntimeException('Die Instanz ist nicht verfügbar.'); + } + if ($operation === 'delete' && !$isAdmin) { + throw new RuntimeException('Nur Administratoren dürfen Instanzen löschen.'); + } + $endpoint = pnpaas_portainer_endpoint_id(); + $containerId = rawurlencode((string)$instance['portainer_container_id']); + if ($operation === 'redeploy') { + $db->prepare('UPDATE instances SET status = "provisioning", error_message = NULL WHERE id = :id')->execute(['id' => $instanceId]); + pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '?force=true', 'DELETE', null); + $dataPath = '/opt/foundry/instances/' . $instance['container_name']; + $payload = [ + 'Image' => 'pnpaas/foundryvtt:14.368', + 'Env' => ['FOUNDRY_VERSION=14.368'], + 'ExposedPorts' => ['30000/tcp' => new stdClass()], + 'HostConfig' => [ + 'Binds' => [$dataPath . ':/data'], + 'PortBindings' => ['30000/tcp' => [['HostPort' => (string)$instance['port']]]], + 'Memory' => (int)$instance['memory_limit_mb'] * 1024 * 1024, + 'NanoCpus' => (int)((float)$instance['cpu_limit'] * 1000000000), + 'RestartPolicy' => ['Name' => 'unless-stopped'], + ], + ]; + $created = pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/create?name=' . rawurlencode($instance['container_name']), 'POST', $payload); + $newContainerId = (string)($created['Id'] ?? ''); + if ($newContainerId === '') throw new RuntimeException('Portainer hat keine neue Container-ID geliefert.'); + pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/' . rawurlencode($newContainerId) . '/start', 'POST', []); + $db->prepare('UPDATE instances SET portainer_container_id = :cid, status = "running", error_message = NULL WHERE id = :id')->execute(['cid' => $newContainerId, 'id' => $instanceId]); + $message = 'Die Instanz wurde neu bereitgestellt. Die Daten in /data wurden beibehalten.'; + } else { + $apiPath = '/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '/' . $operation; + pnpaas_portainer_request($apiPath, $operation === 'delete' ? 'DELETE' : 'POST', []); + $newStatus = match ($operation) { + 'start', 'restart' => 'running', + 'stop' => 'stopped', + 'delete' => 'deleted', + }; + $db->prepare('UPDATE instances SET status = :status, error_message = NULL WHERE id = :id')->execute(['status' => $newStatus, 'id' => $instanceId]); + $message = match ($operation) { + 'start' => 'Die Instanz wurde gestartet.', + 'stop' => 'Die Instanz wurde gestoppt.', + 'restart' => 'Die Instanz wurde neu gestartet.', + 'delete' => 'Die Instanz wurde gelöscht. Das Datenverzeichnis bleibt zur Sicherheit erhalten.', + }; + } + } + } catch (Throwable $exception) { + if (in_array(($action ?? ''), ['approve_instance', 'instance_action'], true) && isset($instanceId)) { + try { pnpaas_db()->prepare('UPDATE instances SET status = "error", error_message = :error WHERE id = :id')->execute(['error' => 'Die Bereitstellung oder Aktion konnte nicht abgeschlossen werden.', 'id' => $instanceId]); } catch (Throwable) {} + } + error_log('PnPaaS instance action error: ' . $exception->getMessage()); + $error = 'Die angeforderte Aktion konnte nicht ausgeführt werden. Bitte versuchen Sie es erneut.'; + } + } +} + +$view = (string)($_GET['view'] ?? ''); +$showCustomers = $isAdmin && $view === 'customers'; +$showInstances = !$isAdmin || $view === 'instances' || ($isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST'); +$instances = []; +$customers = []; +$realms = []; +$realmError = null; + +if ($isAdmin && !$showInstances) { + try { + $endpoints = pnpaas_portainer_request('/api/endpoints'); + foreach ($endpoints as $endpoint) { + $endpointId = (int)($endpoint['Id'] ?? 0); + if ($endpointId < 1) continue; + $realm = [ + 'name' => (string)($endpoint['Name'] ?? ('Realm ' . $endpointId)), + 'online' => ((int)($endpoint['Status'] ?? 0) === 1), + 'version' => 'nicht verfügbar', 'cpus' => 0, 'cpu_percent' => 0.0, + 'memory_total' => 0, 'memory_used' => 0, 'disk_used' => 0, + 'docker_root' => 'nicht verfügbar', 'containers_running' => 0, 'containers_total' => 0, + ]; + try { + $info = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/info'); + $disk = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/system/df'); + $containers = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/containers/json?all=1'); + $memoryUsed = 0; + $cpuPercent = 0.0; + foreach ($containers as $container) { + if (($container['State'] ?? '') !== 'running' || empty($container['Id'])) continue; + try { + $stats = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/containers/' . rawurlencode((string)$container['Id']) . '/stats?stream=false'); + $memoryUsed += (int)($stats['memory_stats']['usage'] ?? 0); + $cpuDelta = (int)($stats['cpu_stats']['cpu_usage']['total_usage'] ?? 0) - (int)($stats['precpu_stats']['cpu_usage']['total_usage'] ?? 0); + $systemDelta = (int)($stats['cpu_stats']['system_cpu_usage'] ?? 0) - (int)($stats['precpu_stats']['system_cpu_usage'] ?? 0); + $onlineCpus = max(1, (int)($stats['cpu_stats']['online_cpus'] ?? $info['NCPU'] ?? 1)); + if ($systemDelta > 0) $cpuPercent += ($cpuDelta / $systemDelta) * $onlineCpus * 100; + } catch (Throwable) { + // Ein einzelner Container darf die Realm-Anzeige nicht ausfallen lassen. + } + } + $diskUsed = (int)($disk['LayersSize'] ?? 0); + foreach (($disk['Containers'] ?? []) as $item) $diskUsed += (int)($item['SizeRw'] ?? 0); + foreach (($disk['BuildCache'] ?? []) as $item) $diskUsed += (int)($item['Size'] ?? 0); + $realm['online'] = true; + $realm['version'] = (string)($info['ServerVersion'] ?? 'unbekannt'); + $realm['cpus'] = (int)($info['NCPU'] ?? 0); + $realm['cpu_percent'] = $cpuPercent; + $realm['memory_total'] = (int)($info['MemTotal'] ?? 0); + $realm['memory_used'] = $memoryUsed; + $realm['disk_used'] = $diskUsed; + $realm['docker_root'] = (string)($info['DockerRootDir'] ?? 'unbekannt'); + $realm['containers_running'] = (int)($info['ContainersRunning'] ?? 0); + $realm['containers_total'] = (int)($info['Containers'] ?? 0); + } catch (Throwable $exception) { + error_log('PnPaaS realm ' . $endpointId . ' error: ' . $exception->getMessage()); + } + $realms[] = $realm; + } + } catch (Throwable $exception) { + error_log('PnPaaS realm dashboard error: ' . $exception->getMessage()); + $realmError = 'Die Realm-Daten konnten derzeit nicht vollständig geladen werden.'; + } +} + +if ($showInstances) { + $statement = $isAdmin + ? pnpaas_db()->query('SELECT i.*, u.username FROM instances i JOIN users u ON u.id = i.owner_user_id ORDER BY i.created_at DESC') + : (function () use ($userId) { $s = pnpaas_db()->prepare('SELECT i.*, u.username FROM instances i JOIN users u ON u.id = i.owner_user_id WHERE i.owner_user_id = :owner ORDER BY i.created_at DESC'); $s->execute(['owner' => $userId]); return $s; })(); + $instances = $statement->fetchAll(); +} + +if ($showCustomers) { + $customers = pnpaas_db()->query('SELECT id, username, email, role, status, created_at FROM users ORDER BY created_at DESC')->fetchAll(); +} + +function dashboard_bytes(int $bytes): string { + if ($bytes <= 0) return 'nicht verfügbar'; + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $index = min((int)floor(log($bytes, 1024)), count($units) - 1); + return number_format($bytes / (1024 ** $index), 1, ',', '.') . ' ' . $units[$index]; +} +?> + + + + +PnPaaS – Dashboard + + + + + +
PnPaaS
+
+ +

Systemübersicht

Realms

+

Status und Ressourcen der Portainer-Server, auf denen die FoundryVTT-Instanzen ausgeführt werden.

+ +
Keine Realms konfiguriert.
+

Portainer-Server

+
Docker / Container aktiv
+
CPU % CPU-Kerne · Container-Auslastung
RAMvon Docker-Speicher
Festplattebelegte Docker-Daten · Kapazität nicht gemeldet
+
+ + +

Administration

Kundenverwaltung

+

Benutzerkonten und deren Zugriffsstatus.

+
BenutzernameE-MailRolleStatusAngelegt
+ +

Übersicht

+

FoundryVTT-Instanzen buchen und ihren Bereitstellungsstatus verfolgen.

+
+ +

Neue Buchung

Instanz anfragen

S · M · L
+
+

Provisionierung

Instanzen

← Realms
+
Noch keine Instanzen vorhanden.
NameBenutzerTarifZahlungStatusAdresseAktion

:
+
diff --git a/app/database/migrations/001_account_activation.sql b/app/database/migrations/001_account_activation.sql new file mode 100644 index 0000000..61ea7f3 --- /dev/null +++ b/app/database/migrations/001_account_activation.sql @@ -0,0 +1,20 @@ +-- Run once against an existing PnPaaS database. +-- Existing active/suspended accounts remain unchanged. +ALTER TABLE users + MODIFY status ENUM('pending', 'active', 'suspended') NOT NULL DEFAULT 'pending'; + +CREATE TABLE IF NOT EXISTS account_activation_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + requested_ip VARCHAR(45) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_account_activation_token_hash (token_hash), + KEY idx_account_activation_user (user_id), + KEY idx_account_activation_expiry (expires_at), + CONSTRAINT fk_account_activation_user FOREIGN KEY (user_id) REFERENCES users (id) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/app/database/migrations/002_customer_registration_data.sql b/app/database/migrations/002_customer_registration_data.sql new file mode 100644 index 0000000..08559ff --- /dev/null +++ b/app/database/migrations/002_customer_registration_data.sql @@ -0,0 +1,23 @@ +-- Add customer contract data fields to existing users. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS first_name VARCHAR(100) NOT NULL DEFAULT '' AFTER email, + ADD COLUMN IF NOT EXISTS last_name VARCHAR(100) NOT NULL DEFAULT '' AFTER first_name, + ADD COLUMN IF NOT EXISTS street_address VARCHAR(180) NOT NULL DEFAULT '' AFTER last_name, + ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20) NOT NULL DEFAULT '' AFTER street_address, + ADD COLUMN IF NOT EXISTS city VARCHAR(100) NOT NULL DEFAULT '' AFTER postal_code, + ADD COLUMN IF NOT EXISTS country_code CHAR(2) NOT NULL DEFAULT 'AT' AFTER city; + +CREATE TABLE IF NOT EXISTS user_consents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + consent_type ENUM('terms', 'privacy', 'withdrawal') NOT NULL, + document_url VARCHAR(2048) NOT NULL, + document_version VARCHAR(100) NULL, + consented_at DATETIME NOT NULL, + consent_ip VARCHAR(45) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_user_consent_type (user_id, consent_type), + KEY idx_user_consents_user (user_id), + CONSTRAINT fk_user_consents_user FOREIGN KEY (user_id) REFERENCES users (id) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/app/database/schema.sql b/app/database/schema.sql new file mode 100644 index 0000000..221b9d7 --- /dev/null +++ b/app/database/schema.sql @@ -0,0 +1,122 @@ +-- PnPaaS database schema +-- Safe to run repeatedly after the database has been created. + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(80) NOT NULL, + email VARCHAR(254) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + street_address VARCHAR(180) NOT NULL, + postal_code VARCHAR(20) NOT NULL, + city VARCHAR(100) NOT NULL, + country_code CHAR(2) NOT NULL DEFAULT 'AT', + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin', 'user') NOT NULL DEFAULT 'user', + status ENUM('pending', 'active', 'suspended') NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_users_username (username), + UNIQUE KEY uq_users_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS account_activation_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + requested_ip VARCHAR(45) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_account_activation_token_hash (token_hash), + KEY idx_account_activation_user (user_id), + KEY idx_account_activation_expiry (expires_at), + CONSTRAINT fk_account_activation_user FOREIGN KEY (user_id) REFERENCES users (id) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS user_consents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + consent_type ENUM('terms', 'privacy', 'withdrawal') NOT NULL, + document_url VARCHAR(2048) NOT NULL, + document_version VARCHAR(100) NULL, + consented_at DATETIME NOT NULL, + consent_ip VARCHAR(45) NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_user_consent_type (user_id, consent_type), + KEY idx_user_consents_user (user_id), + CONSTRAINT fk_user_consents_user FOREIGN KEY (user_id) REFERENCES users (id) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + requested_ip VARCHAR(45) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_password_reset_token_hash (token_hash), + KEY idx_password_reset_user (user_id), + KEY idx_password_reset_expiry (expires_at), + CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users (id) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS instances ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + owner_user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + plan ENUM('s', 'm', 'l') NOT NULL DEFAULT 's', + payment_status ENUM('pending', 'paid', 'rejected') NOT NULL DEFAULT 'pending', + payment_method ENUM('paypal', 'bank_transfer', 'manual') NULL, + requested_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + paid_at TIMESTAMP NULL, + container_name VARCHAR(128) NOT NULL, + port SMALLINT UNSIGNED NOT NULL, + volume_size_mb INT UNSIGNED NOT NULL DEFAULT 1024, + memory_limit_mb INT UNSIGNED NOT NULL DEFAULT 512, + cpu_limit DECIMAL(4,2) NOT NULL DEFAULT 1.00, + portainer_stack_id BIGINT UNSIGNED NULL, + portainer_container_id VARCHAR(128) NULL, + status ENUM('pending_payment', 'planned', 'provisioning', 'running', 'stopped', 'error', 'deleted') NOT NULL DEFAULT 'pending_payment', + error_message VARCHAR(500) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_instances_container_name (container_name), + UNIQUE KEY uq_instances_port (port), + CONSTRAINT fk_instances_owner FOREIGN KEY (owner_user_id) REFERENCES users (id) + ON UPDATE CASCADE ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS portainer_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + label VARCHAR(120) NOT NULL, + token_ciphertext TEXT NOT NULL, + token_nonce VARBINARY(24) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_portainer_tokens_label (label) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NULL, + action VARCHAR(120) NOT NULL, + instance_id BIGINT UNSIGNED NULL, + details JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_audit_user (user_id), + KEY idx_audit_instance (instance_id), + CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users (id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_audit_instance FOREIGN KEY (instance_id) REFERENCES instances (id) + ON UPDATE CASCADE ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/app/forgot-password.php b/app/forgot-password.php new file mode 100644 index 0000000..924966a --- /dev/null +++ b/app/forgot-password.php @@ -0,0 +1,74 @@ +exec('DELETE FROM password_reset_tokens WHERE expires_at < NOW() OR used_at IS NOT NULL'); + $statement = $db->prepare('SELECT id, email FROM users WHERE email = :email AND status = :status LIMIT 1'); + $statement->execute(['email' => $email, 'status' => 'active']); + $user = $statement->fetch(); + if (is_array($user)) { + $token = bin2hex(random_bytes(32)); + $insert = $db->prepare( + 'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, requested_ip) + VALUES (:user_id, :token_hash, DATE_ADD(NOW(), INTERVAL 1 HOUR), :requested_ip)' + ); + $insert->execute([ + 'user_id' => (int)$user['id'], + 'token_hash' => hash('sha256', $token), + 'requested_ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); + if (!pnpaas_send_password_reset((string)$user['email'], $token)) { + error_log('PnPaaS password reset mail could not be sent.'); + } + } + $message = 'Wenn ein aktives Konto zu dieser E-Mail-Adresse existiert, wurde ein Link zum Zurücksetzen versendet.'; + } catch (Throwable $exception) { + error_log('PnPaaS password reset request error: ' . $exception->getMessage()); + $error = 'Die Anfrage ist derzeit nicht möglich. Bitte versuchen Sie es später erneut.'; + } + } +} +?> + + + + + + PnPaaS – Passwort zurücksetzen + + + + + + +
+ +

PnPaaS Administration

+

Passwort vergessen?

+

Geben Sie Ihre E-Mail-Adresse ein. Falls ein aktives Konto dazugehört, senden wir Ihnen einen zeitlich begrenzten Link.

+ +
+ + +
+ + diff --git a/app/includes/bootstrap.php b/app/includes/bootstrap.php new file mode 100644 index 0000000..919aa1d --- /dev/null +++ b/app/includes/bootstrap.php @@ -0,0 +1,221 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return $pdo; +} + +function pnpaas_security_headers(): void +{ + if (headers_sent()) return; + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: DENY'); + header('Referrer-Policy: no-referrer'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); + header('Cross-Origin-Opener-Policy: same-origin'); + header("Content-Security-Policy: default-src 'self'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; script-src 'none'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'"); + if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + } +} + +function pnpaas_session_start(): void +{ + pnpaas_security_headers(); + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + session_start([ + 'use_strict_mode' => true, + 'use_only_cookies' => true, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Lax', + 'cookie_secure' => !empty(pnpaas_config()['PNPAAS_FORCE_SECURE_COOKIES']) || (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), + ]); +} + +function pnpaas_csrf_token(): string +{ + pnpaas_session_start(); + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return (string)$_SESSION['csrf_token']; +} + +function pnpaas_valid_csrf(string $token): bool +{ + pnpaas_session_start(); + return $token !== '' && hash_equals((string)($_SESSION['csrf_token'] ?? ''), $token); +} + +function pnpaas_registration_documents(): array +{ + $config = pnpaas_config(); + return [ + 'terms' => ['title' => 'AGB', 'url' => trim((string)($config['PNPAAS_TERMS_URL'] ?? '')), 'version' => trim((string)($config['PNPAAS_TERMS_VERSION'] ?? ''))], + 'privacy' => ['title' => 'Datenschutzerklärung', 'url' => trim((string)($config['PNPAAS_PRIVACY_URL'] ?? '')), 'version' => trim((string)($config['PNPAAS_PRIVACY_VERSION'] ?? ''))], + 'withdrawal' => ['title' => 'Widerrufsbelehrung', 'url' => trim((string)($config['PNPAAS_WITHDRAWAL_URL'] ?? '')), 'version' => trim((string)($config['PNPAAS_WITHDRAWAL_VERSION'] ?? ''))], + ]; +} + +function pnpaas_app_url(): string +{ + $config = pnpaas_config(); + if (!empty($config['PNPAAS_APP_URL'])) { + return rtrim($config['PNPAAS_APP_URL'], '/'); + } + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + return $scheme . '://' . $host . '/pnpaas'; +} + +function pnpaas_send_account_activation(string $recipient, string $token): bool +{ + $config = pnpaas_config(); + $from = $config['PNPAAS_MAIL_FROM'] ?? ('no-reply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost')); + $link = pnpaas_app_url() . '/activate-account.php?token=' . rawurlencode($token); + $subject = 'PnPaaS – Konto aktivieren'; + $body = "Hallo,\n\n" . + "vielen Dank für Ihre Registrierung bei PnPaaS.\n\n" . + "Aktivieren Sie Ihr Konto innerhalb der nächsten 24 Stunden über diesen Link:\n" . $link . "\n\n" . + "Wenn Sie diese Registrierung nicht angefordert haben, können Sie diese E-Mail ignorieren.\n\n" . + "Viele Grüße\nPnPaaS"; + $headers = [ + 'From: ' . $from, + 'Content-Type: text/plain; charset=UTF-8', + 'X-Mailer: PnPaaS', + ]; + return mail($recipient, $subject, $body, implode("\r\n", $headers)); +} + +function pnpaas_send_password_reset(string $recipient, string $token): bool +{ + $config = pnpaas_config(); + $from = $config['PNPAAS_MAIL_FROM'] ?? ('no-reply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost')); + $link = pnpaas_app_url() . '/reset-password.php?token=' . rawurlencode($token); + $subject = 'PnPaaS – Passwort zurücksetzen'; + $body = "Hallo,\n\n" . + "für Ihr PnPaaS-Konto wurde eine Passwort-Zurücksetzung angefordert.\n\n" . + "Öffnen Sie innerhalb der nächsten Stunde diesen Link:\n" . $link . "\n\n" . + "Wenn Sie die Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.\n\n" . + "Viele Grüße\nPnPaaS"; + $headers = [ + 'From: ' . $from, + 'Content-Type: text/plain; charset=UTF-8', + 'X-Mailer: PnPaaS', + ]; + return mail($recipient, $subject, $body, implode("\r\n", $headers)); +} + +function pnpaas_portainer_request(string $path, string $method = 'GET', ?array $payload = null): array +{ + $config = pnpaas_config(); + foreach (['PORTAINER_URL', 'PORTAINER_API_KEY'] as $key) { + if (empty($config[$key])) { + throw new RuntimeException('Unvollständige Portainer-Konfiguration.'); + } + } + + $url = rtrim($config['PORTAINER_URL'], '/') . '/' . ltrim($path, '/'); + $handle = curl_init($url); + if ($handle === false) { + throw new RuntimeException('Portainer-Anfrage konnte nicht initialisiert werden.'); + } + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'Content-Type: application/json', + 'X-API-Key: ' . $config['PORTAINER_API_KEY'], + ], + CURLOPT_POSTFIELDS => $payload === null ? null : json_encode($payload, JSON_THROW_ON_ERROR), + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + $body = curl_exec($handle); + $error = curl_error($handle); + $status = (int)curl_getinfo($handle, CURLINFO_HTTP_CODE); + curl_close($handle); + + if ($body === false || $error !== '') { + throw new RuntimeException('Portainer ist nicht erreichbar.'); + } + if ($status < 200 || ($status >= 300 && $status !== 304)) { + throw new RuntimeException('Portainer hat die Anfrage abgelehnt.'); + } + if ($body === '' || $body === null) { + return []; + } + $data = json_decode($body, true); + if (!is_array($data)) { + throw new RuntimeException('Portainer hat eine ungültige Antwort geliefert.'); + } + return $data; +} + +function pnpaas_portainer_endpoint_id(): int +{ + $config = pnpaas_config(); + $id = (int)($config['PNPAAS_PORTAINER_ENDPOINT_ID'] ?? 3); + if ($id < 1) { + throw new RuntimeException('Ungültige Portainer-Umgebungs-ID.'); + } + return $id; +} diff --git a/app/index.php b/app/index.php new file mode 100644 index 0000000..e968651 --- /dev/null +++ b/app/index.php @@ -0,0 +1,93 @@ +prepare( + 'SELECT id, username, password_hash, role, status + FROM users + WHERE (username = :login_username OR email = :login_email) AND status = :status + LIMIT 1' + ); + $statement->execute([ + 'login_username' => $login, + 'login_email' => $login, + 'status' => 'active', + ]); + $user = $statement->fetch(); + + if (is_array($user) && password_verify($password, (string)$user['password_hash'])) { + session_regenerate_id(true); + $_SESSION['pnpaas_admin'] = (string)$user['username']; + $_SESSION['pnpaas_user_id'] = (int)$user['id']; + $_SESSION['pnpaas_role'] = (string)$user['role']; + header('Location: dashboard.php'); + exit; + } + $error = 'Benutzername, E-Mail-Adresse oder Passwort ist nicht korrekt.'; + } catch (Throwable $exception) { + error_log('PnPaaS login error: ' . $exception->getMessage()); + $error = 'Die Anmeldung ist derzeit nicht möglich. Bitte prüfen Sie die Serverkonfiguration.'; + } + } +} +?> + + + + + + PnPaaS – Administration + + + + + + +
+ +

PnPaaS Administration

+

Anmelden

+

Verwalten Sie FoundryVTT-Instanzen und Benutzerzugänge zentral.

+ + + + + + + +
+ + diff --git a/app/installer.php b/app/installer.php new file mode 100644 index 0000000..4256e3b --- /dev/null +++ b/app/installer.php @@ -0,0 +1,126 @@ + true, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Strict', + 'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', +]); +header('X-Content-Type-Options: nosniff'); +header('X-Frame-Options: DENY'); +header('Referrer-Policy: no-referrer'); +header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"); + +$completeFile = __DIR__ . '/.install-complete'; +if (is_file($completeFile)) { + http_response_code(404); + exit('Not found'); +} +if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32)); + +$error = null; +$success = false; +$values = [ + 'db_host' => 'localhost', 'db_name' => 'pnpaas', 'db_user' => 'pnpaas', + 'admin_user' => 'admin', 'admin_email' => '', 'app_url' => '', + 'portainer_url' => '', 'terms_url' => '', 'terms_version' => '', + 'privacy_url' => '', 'privacy_version' => '', 'withdrawal_url' => '', + 'withdrawal_version' => '', 'portainer_endpoint_id' => '3', +]; + +function setup_h(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); } +function setup_https_url(string $value, string $label, bool $required = false): string { + $value = trim($value); + if (!$required && $value === '') return ''; + if (filter_var($value, FILTER_VALIDATE_URL) === false || !str_starts_with(strtolower($value), 'https://')) { + throw new RuntimeException("$label muss eine gültige HTTPS-URL sein."); + } + return $value; +} + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + foreach ($values as $key => $_) $values[$key] = trim((string)($_POST[$key] ?? '')); + try { + if (!hash_equals((string)$_SESSION['csrf'], (string)($_POST['csrf'] ?? ''))) throw new RuntimeException('Die Sitzung ist abgelaufen.'); + if (!preg_match('/^[A-Za-z0-9_.:-]{1,253}$/', $values['db_host'])) throw new RuntimeException('Der Datenbank-Host ist ungültig.'); + if (!preg_match('/^[A-Za-z0-9_]{1,64}$/', $values['db_name']) || !preg_match('/^[A-Za-z0-9_]{1,32}$/', $values['db_user'])) throw new RuntimeException('Datenbankname oder Benutzername ist ungültig.'); + if ((string)($_POST['db_password'] ?? '') === '') throw new RuntimeException('Das Datenbankpasswort ist erforderlich.'); + if (!preg_match('/^[A-Za-z0-9_.-]{3,80}$/', $values['admin_user'])) throw new RuntimeException('Der Admin-Benutzername ist ungültig.'); + if (!filter_var($values['admin_email'], FILTER_VALIDATE_EMAIL)) throw new RuntimeException('Die Admin-E-Mail-Adresse ist ungültig.'); + if (strlen((string)($_POST['admin_password'] ?? '')) < 12) throw new RuntimeException('Das Adminpasswort muss mindestens 12 Zeichen enthalten.'); + $appUrl = setup_https_url($values['app_url'], 'Die App-URL', true); + $portainerUrl = setup_https_url($values['portainer_url'], 'Die Portainer-URL'); + $termsUrl = setup_https_url($values['terms_url'], 'Die AGB-URL', true); + $privacyUrl = setup_https_url($values['privacy_url'], 'Die Datenschutz-URL', true); + $withdrawalUrl = setup_https_url($values['withdrawal_url'], 'Die Widerrufs-URL', true); + if (!ctype_digit($values['portainer_endpoint_id']) || (int)$values['portainer_endpoint_id'] < 1) throw new RuntimeException('Die Portainer-Umgebungs-ID ist ungültig.'); + + $dsn = 'mysql:host=' . $values['db_host'] . ';dbname=' . $values['db_name'] . ';charset=utf8mb4'; + $db = new PDO($dsn, $values['db_user'], (string)($_POST['db_password'] ?? ''), [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + $schema = file_get_contents($root . '/database/schema.sql'); + if ($schema === false || $schema === '') throw new RuntimeException('Das Datenbankschema fehlt.'); + $db->exec($schema); + foreach (glob($root . '/database/migrations/*.sql') ?: [] as $migration) { + $sql = file_get_contents($migration); + if ($sql === false) throw new RuntimeException('Eine Datenbankmigration konnte nicht gelesen werden.'); + $db->exec($sql); + } + $adminHash = password_hash((string)$_POST['admin_password'], PASSWORD_DEFAULT); + $admin = $db->prepare("INSERT INTO users (username, email, password_hash, role, status) VALUES (:username, :email, :password_hash, 'admin', 'active') ON DUPLICATE KEY UPDATE email = VALUES(email), password_hash = VALUES(password_hash), role = 'admin', status = 'active'"); + $admin->execute(['username' => $values['admin_user'], 'email' => $values['admin_email'], 'password_hash' => $adminHash]); + + $runtimeConfig = [ + 'PNPAAS_ADMIN_USER' => $values['admin_user'], + 'PNPAAS_ADMIN_PASSWORD_HASH' => $adminHash, + 'PNPAAS_DB_DSN' => $dsn, + 'PNPAAS_DB_USER' => $values['db_user'], + 'PNPAAS_DB_PASSWORD' => (string)$_POST['db_password'], + 'PNPAAS_APP_URL' => $appUrl, + 'PNPAAS_FORCE_SECURE_COOKIES' => '1', + 'PORTAINER_URL' => $portainerUrl, + 'PORTAINER_API_KEY' => (string)($_POST['portainer_api_key'] ?? ''), + 'PNPAAS_PORTAINER_ENDPOINT_ID' => $values['portainer_endpoint_id'], + 'PNPAAS_TERMS_URL' => $termsUrl, + 'PNPAAS_TERMS_VERSION' => $values['terms_version'], + 'PNPAAS_PRIVACY_URL' => $privacyUrl, + 'PNPAAS_PRIVACY_VERSION' => $values['privacy_version'], + 'PNPAAS_WITHDRAWAL_URL' => $withdrawalUrl, + 'PNPAAS_WITHDRAWAL_VERSION' => $values['withdrawal_version'], + ]; + $configPath = $root . '/includes/runtime-config.php'; + if (!is_writable(dirname($configPath))) throw new RuntimeException('Das Include-Verzeichnis ist für den Webserver nicht beschreibbar.'); + $configCode = "getMessage()); + $error = 'Die Datenbankverbindung oder der Datenbankimport ist fehlgeschlagen.'; + } catch (Throwable $exception) { + error_log('PnPaaS web installer error: ' . $exception->getMessage()); + $error = $exception->getMessage(); + } +} +?> + +PnPaaS installieren + +

PnPaaS installieren

+
Installation abgeschlossen. Der Webinstaller ist deaktiviert. Bitte härten Sie jetzt die Dateirechte und konfigurieren Sie HTTPS.
+

Die Datenbank muss bereits existieren. Der Assistent benötigt nur den MariaDB-Anwendungsbenutzer, nicht den MariaDB-Root-Zugang.

+
+
MariaDB
+
Administrator
+
Anwendung und Portainer
+
Rechtstexte
+
diff --git a/app/logout.php b/app/logout.php new file mode 100644 index 0000000..0deeccb --- /dev/null +++ b/app/logout.php @@ -0,0 +1,17 @@ + true, + 'cookie_samesite' => 'Lax', + 'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', +]); + +$_SESSION = []; +if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool)$params['secure'], (bool)$params['httponly']); +} +session_destroy(); +header('Location: index.php'); +exit; diff --git a/app/register.php b/app/register.php new file mode 100644 index 0000000..4d9f2b5 --- /dev/null +++ b/app/register.php @@ -0,0 +1,175 @@ + 100 || $lastName === '' || strlen($lastName) > 100) { + $error = 'Bitte geben Sie Vor- und Nachnamen ein.'; + } elseif ($streetAddress === '' || strlen($streetAddress) > 180 || $postalCode === '' || strlen($postalCode) > 20 || $city === '' || strlen($city) > 100 || $countryCode !== 'AT') { + $error = 'Bitte geben Sie eine vollständige österreichische Adresse ein.'; + } elseif ((string)($_POST['consent_terms'] ?? '') !== '1' || (string)($_POST['consent_privacy'] ?? '') !== '1' || (string)($_POST['consent_withdrawal'] ?? '') !== '1') { + $error = 'Bitte bestätigen Sie die drei verlinkten Rechtstexte.'; + } elseif (strlen($password) < 12) { + $error = 'Das Passwort muss mindestens 12 Zeichen lang sein.'; + } elseif (!hash_equals($password, $passwordConfirmation)) { + $error = 'Die Passwörter stimmen nicht überein.'; + } else { + try { + $db = pnpaas_db(); + $db->beginTransaction(); + $db->exec('DELETE FROM account_activation_tokens WHERE expires_at < NOW() OR used_at IS NOT NULL'); + + $insertUser = $db->prepare( + 'INSERT INTO users (username, email, first_name, last_name, street_address, postal_code, city, country_code, password_hash, role, status) + VALUES (:username, :email, :first_name, :last_name, :street_address, :postal_code, :city, :country_code, :password_hash, "user", "pending")' + ); + $insertUser->execute([ + 'username' => $username, + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'street_address' => $streetAddress, + 'postal_code' => $postalCode, + 'city' => $city, + 'country_code' => $countryCode, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + ]); + $userId = (int)$db->lastInsertId(); + $insertConsent = $db->prepare( + 'INSERT INTO user_consents (user_id, consent_type, document_url, document_version, consented_at, consent_ip) + VALUES (:user_id, :consent_type, :document_url, :document_version, NOW(), :consent_ip)' + ); + foreach ($documents as $consentType => $document) { + $insertConsent->execute([ + 'user_id' => $userId, + 'consent_type' => $consentType, + 'document_url' => $document['url'], + 'document_version' => $document['version'] !== '' ? $document['version'] : null, + 'consent_ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); + } + $token = bin2hex(random_bytes(32)); + $insertToken = $db->prepare( + 'INSERT INTO account_activation_tokens (user_id, token_hash, expires_at, requested_ip) + VALUES (:user_id, :token_hash, DATE_ADD(NOW(), INTERVAL 24 HOUR), :requested_ip)' + ); + $insertToken->execute([ + 'user_id' => $userId, + 'token_hash' => hash('sha256', $token), + 'requested_ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]); + $db->commit(); + + if (!pnpaas_send_account_activation($email, $token)) { + error_log('PnPaaS account activation mail could not be sent.'); + $error = 'Das Konto wurde angelegt, aber die Aktivierungs-E-Mail konnte nicht versendet werden. Bitte wenden Sie sich an den Administrator.'; + } else { + $success = 'Die Registrierung war erfolgreich. Bitte prüfen Sie Ihr E-Mail-Postfach und aktivieren Sie Ihr Konto über den zugesendeten Link.'; + } + } catch (PDOException $exception) { + if (isset($db) && $db->inTransaction()) $db->rollBack(); + if ($exception->getCode() === '23000') { + $error = 'Benutzername oder E-Mail-Adresse wird bereits verwendet.'; + } else { + error_log('PnPaaS registration database error: ' . $exception->getMessage()); + $error = 'Die Registrierung ist derzeit nicht möglich. Bitte versuchen Sie es später erneut.'; + } + } catch (Throwable $exception) { + if (isset($db) && $db->inTransaction()) $db->rollBack(); + error_log('PnPaaS registration error: ' . $exception->getMessage()); + $error = 'Die Registrierung ist derzeit nicht möglich. Bitte versuchen Sie es später erneut.'; + } + } +} +?> + + + + + + PnPaaS – Registrieren + + + + + + +
+ +

PnPaaS

+

Konto erstellen

+

Registrieren Sie sich. Anschließend erhalten Sie eine E-Mail zur Aktivierung Ihres Kontos.

+ +
+
Die Registrierung wird freigeschaltet, sobald die drei externen Rechtstexte hinterlegt sind.
+ + + + +
+ + diff --git a/app/reset-password.php b/app/reset-password.php new file mode 100644 index 0000000..10523be --- /dev/null +++ b/app/reset-password.php @@ -0,0 +1,111 @@ +prepare( + 'SELECT id, user_id FROM password_reset_tokens + WHERE token_hash = :token_hash AND used_at IS NULL AND expires_at > NOW() + LIMIT 1' + ); + $check->execute(['token_hash' => hash('sha256', $token)]); + $validToken = is_array($check->fetch()); + } catch (Throwable $exception) { + error_log('PnPaaS password reset validation error: ' . $exception->getMessage()); + $error = 'Der Zurücksetzungslink ist derzeit nicht verfügbar.'; + } +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $error === null) { + $password = (string)($_POST['password'] ?? ''); + $passwordConfirmation = (string)($_POST['password_confirmation'] ?? ''); + if (!pnpaas_valid_csrf((string)($_POST['csrf_token'] ?? ''))) { + $error = 'Die Sitzung ist abgelaufen. Bitte öffnen Sie den Link erneut.'; + } elseif (!$validToken) { + $error = 'Der Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.'; + } elseif (strlen($password) < 12) { + $error = 'Das Passwort muss mindestens 12 Zeichen lang sein.'; + } elseif ($password !== $passwordConfirmation) { + $error = 'Die Passwörter stimmen nicht überein.'; + } else { + try { + $db = pnpaas_db(); + $db->beginTransaction(); + $find = $db->prepare( + 'SELECT id, user_id FROM password_reset_tokens + WHERE token_hash = :token_hash AND used_at IS NULL AND expires_at > NOW() + FOR UPDATE' + ); + $find->execute(['token_hash' => hash('sha256', $token)]); + $reset = $find->fetch(); + if (!is_array($reset)) { + $db->rollBack(); + $validToken = false; + $error = 'Der Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.'; + } else { + $update = $db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :user_id AND status = :status'); + $update->execute([ + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'user_id' => (int)$reset['user_id'], + 'status' => 'active', + ]); + $used = $db->prepare('UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = :user_id'); + $used->execute(['user_id' => (int)$reset['user_id']]); + $db->commit(); + $success = 'Ihr Passwort wurde geändert. Sie können sich jetzt anmelden.'; + $validToken = false; + } + } catch (Throwable $exception) { + if (isset($db) && $db instanceof PDO && $db->inTransaction()) { + $db->rollBack(); + } + error_log('PnPaaS password reset update error: ' . $exception->getMessage()); + $error = 'Das Passwort konnte nicht geändert werden. Bitte versuchen Sie es erneut.'; + } + } +} +?> + + + + + + PnPaaS – Neues Passwort + + + + + + +
+ +

PnPaaS Administration

+

Neues Passwort

+ +
+

Vergeben Sie ein neues Passwort für Ihr Konto.

+ + + + + +
+ + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ecb8112 --- /dev/null +++ b/install.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +APP_SOURCE="$SCRIPT_DIR/app" +TARGET="/var/www/html/pnpaas" +DB_NAME="pnpaas" +DB_USER="pnpaas" +DB_HOST="localhost" +ADMIN_USER="admin" +ADMIN_EMAIL="" +APP_URL="" +PORTAINER_URL="" +DRY_RUN=0 + +usage() { + printf '%s\n' \ + 'Usage: sudo ./install.sh [options]' \ + ' --target PATH Application target (default: /var/www/html/pnpaas)' \ + ' --db-name NAME MariaDB database (default: pnpaas)' \ + ' --db-user NAME MariaDB application user (default: pnpaas)' \ + ' --db-host HOST MariaDB host (default: localhost)' \ + ' --admin-user NAME Initial administrator username (default: admin)' \ + ' --admin-email EMAIL Initial administrator email' \ + ' --app-url URL Public HTTPS URL, e.g. https://example/pnpaas' \ + ' --portainer-url URL Backend Portainer URL' \ + ' --check Validate prerequisites without changes' \ + ' --help Show this help' +} + +while (($#)); do + case "$1" in + --target) TARGET="$2"; shift 2;; + --db-name) DB_NAME="$2"; shift 2;; + --db-user) DB_USER="$2"; shift 2;; + --db-host) DB_HOST="$2"; shift 2;; + --admin-user) ADMIN_USER="$2"; shift 2;; + --admin-email) ADMIN_EMAIL="$2"; shift 2;; + --app-url) APP_URL="$2"; shift 2;; + --portainer-url) PORTAINER_URL="$2"; shift 2;; + --check) DRY_RUN=1; shift;; + --help) usage; exit 0;; + *) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2;; + esac +done + +fail() { printf 'ERROR: %s\n' "$1" >&2; exit 1; } +require_command() { command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"; } +sql_escape() { local value="$1"; printf "%s" "${value//\'/\'\'}"; } + +[[ "$(id -u)" -eq 0 ]] || fail 'Run this installer as root, for example: sudo ./install.sh' +[[ -d "$APP_SOURCE" ]] || fail "Application source not found: $APP_SOURCE" +require_command php +require_command mariadb +require_command openssl +require_command apache2ctl +php -m | grep -qi '^pdo_mysql$' || fail 'PHP extension pdo_mysql is required.' +php -m | grep -qi '^curl$' || fail 'PHP extension curl is required.' +php -m | grep -qi '^mbstring$' || fail 'PHP extension mbstring is required.' +[[ "$DB_NAME" =~ ^[A-Za-z0-9_]{1,64}$ ]] || fail 'Database name must contain only letters, numbers, and underscores.' +[[ "$DB_USER" =~ ^[A-Za-z0-9_]{1,32}$ ]] || fail 'Database user must contain only letters, numbers, and underscores.' +[[ "$DB_HOST" =~ ^[A-Za-z0-9_.:-]{1,253}$ ]] || fail 'Database host contains invalid characters.' +[[ "$ADMIN_USER" =~ ^[A-Za-z0-9_.-]{3,80}$ ]] || fail 'Admin username must contain 3-80 ASCII letters, numbers, dot, dash, or underscore.' +[[ -z "$APP_URL" || "$APP_URL" == https://* ]] || fail '--app-url must use HTTPS.' +[[ -z "$PORTAINER_URL" || "$PORTAINER_URL" == https://* ]] || fail '--portainer-url must use HTTPS.' + +if [[ -n "$ADMIN_EMAIL" ]]; then + php -r 'exit(filter_var($argv[1], FILTER_VALIDATE_EMAIL) ? 0 : 1);' "$ADMIN_EMAIL" || fail 'Invalid administrator email address.' +fi + +if ((DRY_RUN)); then + printf 'Prerequisite check passed. No files, services, or database objects were changed.\n' + exit 0 +fi + +[[ -n "$ADMIN_EMAIL" ]] || read -r -p 'Administrator email: ' ADMIN_EMAIL +php -r 'exit(filter_var($argv[1], FILTER_VALIDATE_EMAIL) ? 0 : 1);' "$ADMIN_EMAIL" || fail 'Invalid administrator email address.' +read -r -s -p 'Initial administrator password (empty = generate one): ' ADMIN_PASSWORD; printf '\n' +if [[ -z "$ADMIN_PASSWORD" ]]; then + ADMIN_PASSWORD="$(openssl rand -hex 24)" + GENERATED_ADMIN_PASSWORD=1 +else + GENERATED_ADMIN_PASSWORD=0 +fi +[[ ${#ADMIN_PASSWORD} -ge 12 ]] || fail 'Administrator password must be at least 12 characters.' +DB_PASSWORD="$(openssl rand -hex 32)" +ADMIN_HASH="$(php -r 'echo password_hash($argv[1], PASSWORD_DEFAULT);' "$ADMIN_PASSWORD")" + +install -d -m 0755 "$TARGET" +cp -a "$APP_SOURCE/." "$TARGET/" +rm -f "$TARGET/.env" + +SQL_TMP="$(mktemp)" +cleanup() { rm -f "$SQL_TMP"; } +trap cleanup EXIT +{ + printf 'CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n' "$(sql_escape "$DB_NAME")" + printf "CREATE USER IF NOT EXISTS '%s'@'%s' IDENTIFIED BY '%s';\n" "$(sql_escape "$DB_USER")" "$(sql_escape "$DB_HOST")" "$(sql_escape "$DB_PASSWORD")" + printf "ALTER USER '%s'@'%s' IDENTIFIED BY '%s';\n" "$(sql_escape "$DB_USER")" "$(sql_escape "$DB_HOST")" "$(sql_escape "$DB_PASSWORD")" + printf 'GRANT ALL PRIVILEGES ON `%s`.* TO '\''%s'\''@'\''%s'\'';\n' "$(sql_escape "$DB_NAME")" "$(sql_escape "$DB_USER")" "$(sql_escape "$DB_HOST")" + printf 'FLUSH PRIVILEGES;\n' +} > "$SQL_TMP" +mariadb < "$SQL_TMP" +mariadb "$DB_NAME" < "$TARGET/database/schema.sql" +for migration in "$TARGET"/database/migrations/*.sql; do + mariadb "$DB_NAME" < "$migration" +done + +ADMIN_SQL_TMP="$(mktemp)" +trap 'rm -f "$SQL_TMP" "$ADMIN_SQL_TMP"' EXIT +printf "INSERT INTO users (username, email, password_hash, role, status) VALUES ('%s','%s','%s','admin','active') ON DUPLICATE KEY UPDATE email=VALUES(email), password_hash=VALUES(password_hash), role='admin', status='active';\n" \ + "$(sql_escape "$ADMIN_USER")" "$(sql_escape "$ADMIN_EMAIL")" "$(sql_escape "$ADMIN_HASH")" > "$ADMIN_SQL_TMP" +mariadb "$DB_NAME" < "$ADMIN_SQL_TMP" + +umask 027 +cat > "$TARGET/.env" < "$APACHE_CONF" < + Options -Indexes + AllowOverride None + Require all granted + + Require all denied + + + + Require all denied + +EOF +if command -v a2enconf >/dev/null 2>&1; then a2enconf pnpaas >/dev/null; fi +apache2ctl configtest >/dev/null +systemctl reload apache2 + +printf '\nPnPaaS installation completed.\nTarget: %s\nDatabase: %s\nAdministrator: %s\n' "$TARGET" "$DB_NAME" "$ADMIN_USER" +if ((GENERATED_ADMIN_PASSWORD)); then + printf 'Initial administrator password (displayed once): %s\n' "$ADMIN_PASSWORD" +fi +printf 'Next steps: configure Portainer and legal-document URLs in %s/.env, then configure HTTPS.\n' "$TARGET"