Add PnPaaS application and browser installer
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user