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
+ = htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?>
+ 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
+
+
+
+
+
+
+
+
+Systemübersicht
Realms
+Status und Ressourcen der Portainer-Server, auf denen die FoundryVTT-Instanzen ausgeführt werden.
+= dashboard_escape($realmError) ?>
+Keine Realms konfiguriert.
+
+Docker = dashboard_escape($realm['version']) ?> = (int)$realm['containers_running'] ?> / = (int)$realm['containers_total'] ?> Container aktiv
+CPU = number_format((float)$realm['cpu_percent'], 1, ',', '.') ?> % = (int)$realm['cpus'] ?> CPU-Kerne · Container-Auslastung
RAM = dashboard_bytes((int)$realm['memory_used']) ?> von = dashboard_bytes((int)$realm['memory_total']) ?> Docker-Speicher
Festplatte = dashboard_bytes((int)$realm['disk_used']) ?> belegte Docker-Daten · Kapazität nicht gemeldet
+
+Instanzen verwalten →
+
+Administration
Kundenverwaltung
+Benutzerkonten und deren Zugriffsstatus.
+Benutzername E-Mail Rolle Status Angelegt = dashboard_escape((string)$customer['username']) ?> = dashboard_escape((string)$customer['email']) ?> = dashboard_escape((string)$customer['role']) ?> = dashboard_escape((string)$customer['status']) ?> = dashboard_escape((string)$customer['created_at']) ?>
+
+Übersicht
= $isAdmin ? 'Instanzen verwalten' : 'Meine Instanzen' ?>
+FoundryVTT-Instanzen buchen und ihren Bereitstellungsstatus verfolgen.
+= dashboard_escape($message) ?>
+= dashboard_escape($error) ?>
+Neue Buchung
Instanz anfragen S · M · L
+
+
+Noch keine Instanzen vorhanden.
Name Benutzer Tarif Zahlung Status Adresse Aktion = dashboard_escape((string)$instance['name']) ?> = dashboard_escape((string)$instance['username']) ?> = strtoupper(dashboard_escape((string)$instance['plan'])) ?> = dashboard_escape((string)$instance['payment_status']) ?> = dashboard_escape(dashboard_status((string)$instance['status'])) ?> = dashboard_escape((string)$instance['error_message']) ?> := dashboard_escape((string)$instance['port']) ?>–
–
+
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.
+ = htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
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.
+
+
+ = htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
+
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.
+= setup_h($error) ?>
Die Datenbank muss bereits existieren. Der Assistent benötigt nur den MariaDB-Anwendungsbenutzer, nicht den MariaDB-Root-Zugang.
+
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.
+ = htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?>
+ 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
+ = htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?>
+ = htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?>
+ Vergeben Sie ein neues Passwort für Ihr Konto.
+
+
+ Der Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.
+
+
+
+
+
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"