Add PnPaaS application and browser installer

This commit is contained in:
Hermes Agent
2026-09-20 13:41:15 +00:00
parent 498f3308d4
commit 2e20e5472f
18 changed files with 1890 additions and 0 deletions
+40
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
Options -Indexes
<FilesMatch "^(?:\.env(?:\..*)?|.*\.(?:sql|log|ini|conf|sh|bak|old|orig)|runtime-config\.php|\.install-complete)$">
Require all denied
</FilesMatch>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^(?:includes|database)(?:/|$) - [F,L]
</IfModule>
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
// PnPaaS admin scaffold
//
// Required environment variables for the initial admin login:
// PNPAAS_ADMIN_USER
// PNPAAS_ADMIN_PASSWORD_HASH
//
// Generate a password hash with:
// php -r 'echo password_hash("YOUR_PASSWORD", PASSWORD_DEFAULT), PHP_EOL;'
//
// Portainer connection variables will be added server-side after the Docker
// server information has been provided. API keys must remain backend-only.
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
pnpaas_session_start();
$token = trim((string)($_GET['token'] ?? $_POST['token'] ?? ''));
$csrfToken = pnpaas_csrf_token();
$error = null;
$success = null;
$validToken = false;
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
$error = 'Dieser Aktivierungslink ist ungültig oder abgelaufen.';
} else {
try {
$check = pnpaas_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'
);
$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.';
}
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PnPaaS Konto aktivieren</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="auth-page">
<main class="auth-card" aria-labelledby="activation-title">
<div class="brand-mark" aria-hidden="true">✦</div>
<p class="eyebrow">PnPaaS</p>
<h1 id="activation-title">Konto aktivieren</h1>
<?php if ($error !== null): ?><div class="alert alert-error" role="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($success !== null): ?><div class="alert alert-success" role="status"><?= htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?></div><p class="form-footer"><a href="index.php">Zur Anmeldung</a></p><?php elseif ($validToken): ?>
<p class="intro">Bestätigen Sie die Aktivierung Ihres PnPaaS-Kontos.</p>
<form method="post" action="activate-account.php?token=<?= htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ?>" class="login-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="token" value="<?= htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ?>">
<button type="submit">Konto aktivieren</button>
</form>
<?php else: ?><p class="form-footer"><a href="register.php">Neu registrieren</a> · <a href="index.php">Zur Anmeldung</a></p><?php endif; ?>
</main>
</body>
</html>
+281
View File
@@ -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; }
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
// Example only. Configure these values in Apache/PHP-FPM environment variables.
// Never commit real credentials or API keys.
//
// PNPAAS_ADMIN_USER=admin
// PNPAAS_ADMIN_PASSWORD_HASH=<output of: php -r 'echo password_hash("...", PASSWORD_DEFAULT);'>
// PORTAINER_URL=https://portainer.example:9443
// PORTAINER_API_KEY=<secret, server-side only>
// 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
+290
View File
@@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
pnpaas_session_start();
if (empty($_SESSION['pnpaas_user_id'])) {
header('Location: index.php');
exit;
}
$userId = (int)$_SESSION['pnpaas_user_id'];
try {
$currentUserQuery = pnpaas_db()->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];
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>PnPaaS Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar"><div class="topbar-brand"><strong>PnPaaS</strong><span class="topbar-subtitle"><?= $isAdmin ? 'Administration' : 'Kundenbereich' ?></span></div><nav class="main-nav" aria-label="Hauptnavigation"><a class="<?= !$showInstances && !$showCustomers ? 'active' : '' ?>" href="dashboard.php">Dashboard</a><a class="<?= $showInstances ? 'active' : '' ?>" href="dashboard.php?view=instances">Instanzverwaltung</a><?php if ($isAdmin): ?><a class="<?= $showCustomers ? 'active' : '' ?>" href="dashboard.php?view=customers">Kundenverwaltung</a><?php endif; ?></nav><div class="topbar-actions"><span><?= dashboard_escape($username) ?></span><a href="logout.php">Abmelden</a></div></header>
<main class="dashboard">
<?php if ($isAdmin && !$showInstances && !$showCustomers): ?>
<p class="eyebrow">Systemübersicht</p><h1>Realms</h1>
<p class="intro">Status und Ressourcen der Portainer-Server, auf denen die FoundryVTT-Instanzen ausgeführt werden.</p>
<?php if ($realmError !== null): ?><div class="alert alert-error" role="alert"><?= dashboard_escape($realmError) ?></div><?php endif; ?>
<?php if ($realms === [] && $realmError === null): ?><div class="empty-state">Keine Realms konfiguriert.</div><?php else: ?><section class="realm-grid" aria-label="Realm-Status">
<?php foreach ($realms as $realm): ?><article class="realm-card"><div class="realm-card-header"><div><p class="eyebrow">Portainer-Server</p><h2><?= dashboard_escape($realm['name']) ?></h2></div><span class="status-badge <?= $realm['online'] ? 'status-online' : 'status-offline' ?>"><?= $realm['online'] ? 'Server Up' : 'Server Down' ?></span></div>
<div class="realm-meta"><span>Docker <?= dashboard_escape($realm['version']) ?></span><span><?= (int)$realm['containers_running'] ?> / <?= (int)$realm['containers_total'] ?> Container aktiv</span></div>
<div class="realm-metrics"><div class="realm-metric"><span class="metric-label">CPU</span><strong><?= number_format((float)$realm['cpu_percent'], 1, ',', '.') ?> %</strong><small><?= (int)$realm['cpus'] ?> CPU-Kerne · Container-Auslastung</small></div><div class="realm-metric"><span class="metric-label">RAM</span><strong><?= dashboard_bytes((int)$realm['memory_used']) ?></strong><small>von <?= dashboard_bytes((int)$realm['memory_total']) ?> Docker-Speicher</small></div><div class="realm-metric"><span class="metric-label">Festplatte</span><strong><?= dashboard_bytes((int)$realm['disk_used']) ?></strong><small>belegte Docker-Daten · Kapazität nicht gemeldet</small></div></div>
<div class="realm-footer"><span>Docker Root: <code><?= dashboard_escape($realm['docker_root']) ?></code></span><span>Stand: <?= date('H:i:s') ?></span></div></article><?php endforeach; ?></section><?php endif; ?>
<p class="dashboard-link"><a href="dashboard.php?view=instances">Instanzen verwalten →</a></p>
<?php elseif ($showCustomers): ?>
<p class="eyebrow">Administration</p><h1>Kundenverwaltung</h1>
<p class="intro">Benutzerkonten und deren Zugriffsstatus.</p>
<section class="data-section"><div class="table-wrap"><table class="data-table"><thead><tr><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th>Angelegt</th></tr></thead><tbody><?php foreach ($customers as $customer): ?><tr><td data-label="Benutzername"><strong><?= dashboard_escape((string)$customer['username']) ?></strong></td><td data-label="E-Mail"><?= dashboard_escape((string)$customer['email']) ?></td><td data-label="Rolle"><?= dashboard_escape((string)$customer['role']) ?></td><td data-label="Status"><?= dashboard_escape((string)$customer['status']) ?></td><td data-label="Angelegt"><?= dashboard_escape((string)$customer['created_at']) ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php else: ?>
<p class="eyebrow">Übersicht</p><h1><?= $isAdmin ? 'Instanzen verwalten' : 'Meine Instanzen' ?></h1>
<p class="intro">FoundryVTT-Instanzen buchen und ihren Bereitstellungsstatus verfolgen.</p>
<?php if ($message !== null): ?><div class="alert alert-success" role="status"><?= dashboard_escape($message) ?></div><?php endif; ?>
<?php if ($error !== null): ?><div class="alert alert-error" role="alert"><?= dashboard_escape($error) ?></div><?php endif; ?>
<section class="data-section"><div class="section-heading"><div><p class="eyebrow">Neue Buchung</p><h2>Instanz anfragen</h2></div><span class="status-badge">S · M · L</span></div>
<form method="post" class="login-form"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="request_instance"><label for="name">Instanzname</label><input id="name" name="name" required maxlength="120" placeholder="Meine Spielrunde"><label for="plan">Tarif</label><select id="plan" name="plan"><option value="s">S 512 MB RAM · 1 CPU · 1 GB</option><option value="m">M 2 GB RAM · 2 CPU · 10 GB</option><option value="l">L 4 GB RAM · 4 CPU · 25 GB</option></select><label for="payment_method">Zahlungsart</label><select id="payment_method" name="payment_method"><option value="paypal">PayPal</option><option value="bank_transfer">Überweisung</option></select><button type="submit">Instanz anfragen</button></form></section>
<section class="data-section"><div class="section-heading"><div><p class="eyebrow">Provisionierung</p><h2>Instanzen</h2></div><?php if ($isAdmin): ?><a href="dashboard.php">← Realms</a><?php endif; ?></div>
<?php if ($instances === []): ?><div class="empty-state">Noch keine Instanzen vorhanden.</div><?php else: ?><div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th><?php if ($isAdmin): ?><th>Benutzer</th><?php endif; ?><th>Tarif</th><th>Zahlung</th><th>Status</th><th>Adresse</th><th>Aktion</th></tr></thead><tbody><?php foreach ($instances as $instance): ?><tr><td data-label="Name"><strong><?= dashboard_escape((string)$instance['name']) ?></strong></td><?php if ($isAdmin): ?><td data-label="Benutzer"><?= dashboard_escape((string)$instance['username']) ?></td><?php endif; ?><td data-label="Tarif"><?= strtoupper(dashboard_escape((string)$instance['plan'])) ?></td><td data-label="Zahlung"><?= dashboard_escape((string)$instance['payment_status']) ?></td><td data-label="Status"><span class="container-state <?= dashboard_escape((string)$instance['status']) ?>"><?= dashboard_escape(dashboard_status((string)$instance['status'])) ?></span><?php if (!empty($instance['error_message'])): ?><br><small><?= dashboard_escape((string)$instance['error_message']) ?></small><?php endif; ?></td><td data-label="Adresse"><?php if ($instance['status'] === 'running'): ?>:<?= dashboard_escape((string)$instance['port']) ?><?php else: ?><?php endif; ?></td><td data-label="Aktion"><?php if ($instance['status'] === 'pending_payment' && $isAdmin): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="approve_instance"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Zahlung freigeben</button></form><?php elseif (in_array($instance['status'], ['running', 'stopped'], true)): ?><div class="instance-actions"><form method="post" onsubmit="return confirm('Instanz wirklich neu bereitstellen? Die Daten in /data bleiben erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="redeploy"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Redeploy</button></form><?php if ($instance['status'] === 'running'): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="stop"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Stoppen</button></form><?php else: ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="start"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Starten</button></form><?php endif; ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="restart"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Neustarten</button></form><?php if ($isAdmin): ?><form method="post" onsubmit="return confirm('Instanz wirklich löschen? Das Datenverzeichnis bleibt erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="delete"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Löschen</button></form><?php endif; ?></div><?php else: ?><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?></section>
<?php endif; ?></main></body></html>
@@ -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;
@@ -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;
+122
View File
@@ -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;
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
pnpaas_session_start();
$csrfToken = pnpaas_csrf_token();
$message = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim((string)($_POST['email'] ?? ''));
if (!pnpaas_valid_csrf((string)($_POST['csrf_token'] ?? ''))) {
$error = 'Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
} else {
try {
$db = pnpaas_db();
$db->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.';
}
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PnPaaS Passwort zurücksetzen</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="auth-page">
<main class="auth-card" aria-labelledby="reset-title">
<div class="brand-mark" aria-hidden="true"></div>
<p class="eyebrow">PnPaaS Administration</p>
<h1 id="reset-title">Passwort vergessen?</h1>
<p class="intro">Geben Sie Ihre E-Mail-Adresse ein. Falls ein aktives Konto dazugehört, senden wir Ihnen einen zeitlich begrenzten Link.</p>
<?php if ($error !== null): ?><div class="alert alert-error" role="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($message !== null): ?><div class="alert alert-success" role="status"><?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<form method="post" action="forgot-password.php" class="login-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<label for="email">E-Mail-Adresse</label>
<input id="email" name="email" type="email" autocomplete="email" required autofocus>
<button type="submit">Link anfordern</button>
</form>
<p class="form-footer"><a href="index.php">Zurück zur Anmeldung</a></p>
</main>
</body>
</html>
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
function load_pnpaas_env(string $path): array
{
if (!is_readable($path)) {
throw new RuntimeException('PnPaaS-Konfiguration ist nicht lesbar.');
}
$values = [];
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value);
if ($key !== '') {
$values[$key] = trim($value, " \t\r\n\"");
}
}
return $values;
}
function pnpaas_config(): array
{
static $config;
if ($config === null) {
$runtimeConfigPath = __DIR__ . '/runtime-config.php';
if (is_file($runtimeConfigPath)) {
$runtimeConfig = require $runtimeConfigPath;
$config = is_array($runtimeConfig) ? $runtimeConfig : [];
} else {
$config = load_pnpaas_env(dirname(__DIR__) . '/.env');
}
}
return $config;
}
function pnpaas_db(): PDO
{
static $pdo;
if (!$pdo instanceof PDO) {
$config = pnpaas_config();
foreach (['PNPAAS_DB_DSN', 'PNPAAS_DB_USER', 'PNPAAS_DB_PASSWORD'] as $key) {
if (!array_key_exists($key, $config)) {
throw new RuntimeException('Unvollständige PnPaaS-Datenbankkonfiguration.');
}
}
$pdo = new PDO($config['PNPAAS_DB_DSN'], $config['PNPAAS_DB_USER'], $config['PNPAAS_DB_PASSWORD'], [
PDO::ATTR_ERRMODE => 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;
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
pnpaas_session_start();
if (!empty($_SESSION['pnpaas_admin'])) {
header('Location: dashboard.php');
exit;
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = (string)($_POST['csrf_token'] ?? '');
$login = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (!hash_equals((string)$_SESSION['csrf_token'], $csrf)) {
$error = 'Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.';
} elseif ($login === '' || $password === '') {
$error = 'Bitte Benutzername oder E-Mail-Adresse und Passwort eingeben.';
} else {
try {
$statement = pnpaas_db()->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.';
}
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PnPaaS Administration</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="auth-page">
<main class="auth-card" aria-labelledby="login-title">
<div class="brand-mark" aria-hidden="true"></div>
<p class="eyebrow">PnPaaS Administration</p>
<h1 id="login-title">Anmelden</h1>
<p class="intro">Verwalten Sie FoundryVTT-Instanzen und Benutzerzugänge zentral.</p>
<?php if ($error !== null): ?>
<div class="alert alert-error" role="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<form method="post" action="index.php" class="login-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars((string)$_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>">
<label for="username">Benutzername oder E-Mail-Adresse</label>
<input id="username" name="username" type="text" autocomplete="username" required autofocus value="<?= htmlspecialchars((string)($_POST['username'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Anmelden</button>
</form>
<p class="form-footer"><a href="forgot-password.php">Passwort vergessen?</a> · <a href="register.php">Konto registrieren</a></p>
</main>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
$root = __DIR__;
session_name('PNP_INSTALL');
session_start([
'use_strict_mode' => 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 = "<?php\ndeclare(strict_types=1);\nreturn " . var_export($runtimeConfig, true) . ";\n";
if (file_put_contents($configPath, $configCode, LOCK_EX) === false) throw new RuntimeException('Die Anwendungskonfiguration konnte nicht geschrieben werden.');
chmod($configPath, 0640);
file_put_contents($completeFile, date(DATE_ATOM), LOCK_EX);
chmod($completeFile, 0640);
@unlink(__FILE__);
$success = true;
} catch (PDOException $exception) {
error_log('PnPaaS web installer database error: ' . $exception->getMessage());
$error = 'Die Datenbankverbindung oder der Datenbankimport ist fehlgeschlagen.';
} catch (Throwable $exception) {
error_log('PnPaaS web installer error: ' . $exception->getMessage());
$error = $exception->getMessage();
}
}
?>
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>PnPaaS installieren</title>
<style>body{font:16px system-ui,sans-serif;background:#eef2f5;color:#17202a;margin:0}.card{max-width:760px;margin:32px auto;background:#fff;padding:28px;border-radius:12px;box-shadow:0 8px 30px #0002}h1{margin-top:0}fieldset{border:1px solid #ccd5dc;margin:18px 0;padding:16px}legend{font-weight:700}label{display:block;margin:10px 0 4px;font-weight:600}input{box-sizing:border-box;width:100%;padding:10px;border:1px solid #9aa8b3;border-radius:5px}button{padding:11px 18px;background:#1261a0;color:white;border:0;border-radius:5px;font-weight:700}.alert{padding:12px;margin:12px 0;border-radius:5px}.error{background:#ffe1e1;color:#8b1111}.success{background:#dcf7e5;color:#135d2d}.hint{color:#52616b;font-size:.92em}</style></head>
<body><main class="card"><h1>PnPaaS installieren</h1>
<?php if ($success): ?><div class="alert success">Installation abgeschlossen. Der Webinstaller ist deaktiviert. Bitte härten Sie jetzt die Dateirechte und konfigurieren Sie HTTPS.</div>
<?php else: ?><?php if ($error): ?><div class="alert error"><?= setup_h($error) ?></div><?php endif; ?><p class="hint">Die Datenbank muss bereits existieren. Der Assistent benötigt nur den MariaDB-Anwendungsbenutzer, nicht den MariaDB-Root-Zugang.</p>
<form method="post"><input type="hidden" name="csrf" value="<?= setup_h((string)$_SESSION['csrf']) ?>">
<fieldset><legend>MariaDB</legend><label>Host<input name="db_host" value="<?= setup_h($values['db_host']) ?>" required></label><label>Datenbankname<input name="db_name" value="<?= setup_h($values['db_name']) ?>" required></label><label>Datenbankbenutzer<input name="db_user" value="<?= setup_h($values['db_user']) ?>" required></label><label>Datenbankpasswort<input type="password" name="db_password" autocomplete="new-password" required></label></fieldset>
<fieldset><legend>Administrator</legend><label>Benutzername<input name="admin_user" value="<?= setup_h($values['admin_user']) ?>" required></label><label>E-Mail<input type="email" name="admin_email" value="<?= setup_h($values['admin_email']) ?>" required></label><label>Passwort<input type="password" name="admin_password" minlength="12" autocomplete="new-password" required></label></fieldset>
<fieldset><legend>Anwendung und Portainer</legend><label>Öffentliche HTTPS-App-URL<input name="app_url" placeholder="https://example.org/pnpaas" value="<?= setup_h($values['app_url']) ?>" required></label><label>Portainer-URL<input name="portainer_url" value="<?= setup_h($values['portainer_url']) ?>"></label><label>Portainer-API-Key<input type="password" name="portainer_api_key" autocomplete="off"></label><label>Portainer-Umgebungs-ID<input name="portainer_endpoint_id" value="<?= setup_h($values['portainer_endpoint_id']) ?>"></label></fieldset>
<fieldset><legend>Rechtstexte</legend><label>AGB-URL<input name="terms_url" value="<?= setup_h($values['terms_url']) ?>" required></label><label>AGB-Version<input name="terms_version" value="<?= setup_h($values['terms_version']) ?>" required></label><label>Datenschutz-URL<input name="privacy_url" value="<?= setup_h($values['privacy_url']) ?>" required></label><label>Datenschutz-Version<input name="privacy_version" value="<?= setup_h($values['privacy_version']) ?>" required></label><label>Widerrufs-URL<input name="withdrawal_url" value="<?= setup_h($values['withdrawal_url']) ?>" required></label><label>Widerrufs-Version<input name="withdrawal_version" value="<?= setup_h($values['withdrawal_version']) ?>" required></label></fieldset>
<button type="submit">Installation durchführen</button></form><?php endif; ?></main></body></html>
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
session_start([
'cookie_httponly' => 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;
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
pnpaas_session_start();
if (!empty($_SESSION['pnpaas_admin'])) {
header('Location: dashboard.php');
exit;
}
$csrfToken = pnpaas_csrf_token();
$documents = pnpaas_registration_documents();
$registrationEnabled = true;
foreach ($documents as $document) {
if (($document['url'] ?? '') === '' || filter_var($document['url'], FILTER_VALIDATE_URL) === false || !str_starts_with(strtolower($document['url']), 'https://')) {
$registrationEnabled = false;
break;
}
}
$error = null;
$success = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string)($_POST['username'] ?? ''));
$email = strtolower(trim((string)($_POST['email'] ?? '')));
$firstName = trim((string)($_POST['first_name'] ?? ''));
$lastName = trim((string)($_POST['last_name'] ?? ''));
$streetAddress = trim((string)($_POST['street_address'] ?? ''));
$postalCode = trim((string)($_POST['postal_code'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$countryCode = strtoupper(trim((string)($_POST['country_code'] ?? 'AT')));
$password = (string)($_POST['password'] ?? '');
$passwordConfirmation = (string)($_POST['password_confirmation'] ?? '');
if (!$registrationEnabled) {
$error = 'Die Registrierung ist derzeit noch nicht freigeschaltet.';
} elseif (!pnpaas_valid_csrf((string)($_POST['csrf_token'] ?? ''))) {
$error = 'Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.';
} elseif (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]{2,79}$/', $username)) {
$error = 'Der Benutzername muss 3 bis 80 Zeichen enthalten und darf nur Buchstaben, Zahlen, Punkt, Bindestrich und Unterstrich enthalten.';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = 'Bitte geben Sie eine gültige E-Mail-Adresse ein.';
} elseif ($firstName === '' || strlen($firstName) > 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.';
}
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PnPaaS Registrieren</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="auth-page">
<main class="auth-card" aria-labelledby="register-title">
<div class="brand-mark" aria-hidden="true"></div>
<p class="eyebrow">PnPaaS</p>
<h1 id="register-title">Konto erstellen</h1>
<p class="intro">Registrieren Sie sich. Anschließend erhalten Sie eine E-Mail zur Aktivierung Ihres Kontos.</p>
<?php if ($error !== null): ?><div class="alert alert-error" role="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($success !== null): ?><div class="alert alert-success" role="status"><?= htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if (!$registrationEnabled): ?><div class="alert alert-error" role="status">Die Registrierung wird freigeschaltet, sobald die drei externen Rechtstexte hinterlegt sind.</div><?php endif; ?>
<?php if ($success === null): ?>
<form method="post" action="register.php" class="login-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<fieldset <?= !$registrationEnabled ? 'disabled' : '' ?> style="border:0; padding:0; margin:0; display:grid; gap:.65rem;">
<label for="first_name">Vorname</label>
<input id="first_name" name="first_name" type="text" autocomplete="given-name" maxlength="100" required value="<?= htmlspecialchars((string)($_POST['first_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="last_name">Nachname</label>
<input id="last_name" name="last_name" type="text" autocomplete="family-name" maxlength="100" required value="<?= htmlspecialchars((string)($_POST['last_name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="street_address">Straße und Hausnummer</label>
<input id="street_address" name="street_address" type="text" autocomplete="street-address" maxlength="180" required value="<?= htmlspecialchars((string)($_POST['street_address'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="postal_code">Postleitzahl</label>
<input id="postal_code" name="postal_code" type="text" autocomplete="postal-code" maxlength="20" required value="<?= htmlspecialchars((string)($_POST['postal_code'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="city">Ort</label>
<input id="city" name="city" type="text" autocomplete="address-level2" maxlength="100" required value="<?= htmlspecialchars((string)($_POST['city'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="country_code">Land</label>
<select id="country_code" name="country_code" autocomplete="country" required><option value="AT" selected>Österreich</option></select>
<label for="username">Benutzername</label>
<input id="username" name="username" type="text" autocomplete="username" minlength="3" maxlength="80" required value="<?= htmlspecialchars((string)($_POST['username'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="email">E-Mail-Adresse</label>
<input id="email" name="email" type="email" autocomplete="email" maxlength="254" required value="<?= htmlspecialchars((string)($_POST['email'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="new-password" minlength="12" required>
<label for="password_confirmation">Passwort wiederholen</label>
<input id="password_confirmation" name="password_confirmation" type="password" autocomplete="new-password" minlength="12" required>
<?php foreach ($documents as $consentType => $document): ?><label class="consent-line"><input type="checkbox" name="consent_<?= htmlspecialchars($consentType, ENT_QUOTES, 'UTF-8') ?>" value="1" required> <?= $consentType === 'terms' ? 'Ich akzeptiere die' : 'Ich habe die' ?> <a href="<?= htmlspecialchars($document['url'] !== '' ? $document['url'] : '#', ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener noreferrer"><?= htmlspecialchars($document['title'], ENT_QUOTES, 'UTF-8') ?></a> <?= $consentType === 'terms' ? '.' : 'gelesen.' ?></label><?php endforeach; ?>
<button type="submit">Registrieren</button>
</fieldset>
</form>
<?php endif; ?>
<p class="form-footer"><a href="index.php">Zurück zur Anmeldung</a></p>
</main>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
pnpaas_session_start();
$token = trim((string)($_GET['token'] ?? $_POST['token'] ?? ''));
$csrfToken = pnpaas_csrf_token();
$error = null;
$success = null;
$validToken = false;
if (preg_match('/^[a-f0-9]{64}$/', $token)) {
try {
$check = pnpaas_db()->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.';
}
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PnPaaS Neues Passwort</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body class="auth-page">
<main class="auth-card" aria-labelledby="reset-title">
<div class="brand-mark" aria-hidden="true"></div>
<p class="eyebrow">PnPaaS Administration</p>
<h1 id="reset-title">Neues Passwort</h1>
<?php if ($error !== null): ?><div class="alert alert-error" role="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<?php if ($success !== null): ?><div class="alert alert-success" role="status"><?= htmlspecialchars($success, ENT_QUOTES, 'UTF-8') ?></div><p class="form-footer"><a href="index.php">Zur Anmeldung</a></p><?php elseif ($validToken): ?>
<p class="intro">Vergeben Sie ein neues Passwort für Ihr Konto.</p>
<form method="post" action="reset-password.php" class="login-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="token" value="<?= htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ?>">
<label for="password">Neues Passwort</label>
<input id="password" name="password" type="password" autocomplete="new-password" minlength="12" required>
<label for="password_confirmation">Passwort wiederholen</label>
<input id="password_confirmation" name="password_confirmation" type="password" autocomplete="new-password" minlength="12" required>
<button type="submit">Passwort speichern</button>
</form>
<?php elseif ($error === null): ?>
<div class="alert alert-error" role="alert">Der Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Link an.</div>
<p class="form-footer"><a href="forgot-password.php">Neuen Link anfordern</a></p>
<?php endif; ?>
</main>
</body>
</html>
Executable
+154
View File
@@ -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" <<EOF
PNPAAS_ADMIN_USER=$ADMIN_USER
PNPAAS_ADMIN_PASSWORD_HASH=$ADMIN_HASH
PNPAAS_DB_DSN=mysql:host=$DB_HOST;dbname=$DB_NAME;charset=utf8mb4
PNPAAS_DB_USER=$DB_USER
PNPAAS_DB_PASSWORD=$DB_PASSWORD
PNPAAS_APP_URL=$APP_URL
PNPAAS_FORCE_SECURE_COOKIES=1
PORTAINER_URL=$PORTAINER_URL
EOF
chown root:www-data "$TARGET/.env"
chmod 640 "$TARGET/.env"
chown -R root:root "$TARGET"
chown root:www-data "$TARGET/.env"
chmod 640 "$TARGET/.env"
APACHE_CONF="/etc/apache2/conf-available/pnpaas.conf"
cat > "$APACHE_CONF" <<EOF
<Directory $TARGET>
Options -Indexes
AllowOverride None
Require all granted
<FilesMatch "^(?:\\.pnpaas-setup-token\\.sha256|\\.env(?:\\..*)?|.*\\.(?:sql|log|ini|conf|sh|bak|old|orig)|config(?:\\..*)?)$">
Require all denied
</FilesMatch>
</Directory>
<DirectoryMatch "$TARGET/(?:includes|database)">
Require all denied
</DirectoryMatch>
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"