@@ -34,12 +34,13 @@ $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 ],
's' => [ 'label' => 'S' , 'memory' => 512 , 'cpu' => 1.00 , 'storage' => 5120 , 'monthly_cents' => 299 , 'yearly_cents' => 2999 ],
'm' => [ 'label' => 'M' , 'memory' => 2048 , 'cpu' => 2.00 , 'storage' => 10240 , 'monthly_cents' => 399 , 'yearly_cents' => 3999 ],
'l' => [ 'label' => 'L' , 'memory' => 4096 , 'cpu' => 4.00 , 'storage' => 25600 , 'monthly_cents' => 499 , 'yearly_cents' => 4999 ],
];
function dashboard_escape ( string $value ) : string { return htmlspecialchars ( $value , ENT_QUOTES , 'UTF-8' ); }
function dashboard_money ( int $cents ) : string { return $cents > 0 ? number_format ( $cents / 100 , 2 , ',' , '.' ) . ' €' : '– ' ; }
function dashboard_status ( string $status ) : string {
return match ( $status ) {
'pending_payment' => 'Zahlung ausstehend' ,
@@ -50,6 +51,12 @@ function dashboard_status(string $status): string {
default => ucfirst ( $status ),
};
}
function dashboard_debug_detail ( Throwable $exception ) : string {
$detail = trim ( $exception -> getMessage ());
$detail = preg_replace ( '/(?:X-API-Key|Authorization|Bearer)\s*:\s*[^\s,;]+/i' , '[credential redacted]' , $detail ) ? ? $detail ;
$detail = preg_replace ( '/("?(?:token|password|api[_-]?key|secret)"?\s*[:=]\s*)[^,;\s}]+/i' , '$1[redacted]' , $detail ) ? ? $detail ;
return substr ( $detail !== '' ? $detail : 'Keine technische Fehlerbeschreibung vorhanden.' , 0 , 800 );
}
if ( $_SERVER [ 'REQUEST_METHOD' ] === 'POST' ) {
if ( ! pnpaas_valid_csrf (( string )( $_POST [ 'csrf_token' ] ? ? '' ))) {
@@ -61,11 +68,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ( $action === 'request_instance' ) {
$name = trim (( string )( $_POST [ 'name' ] ? ? '' ));
$planKey = strtolower ( trim (( string )( $_POST [ 'plan' ] ? ? '' )));
$billingPeriod = ( string )( $_POST [ 'billing_period' ] ? ? '' );
$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 )) {
if ( ! isset ( $plans [ $planKey ]) || ! in_array ( $billingPeriod , [ 'monthly' , 'yearly' ], true ) || ! 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' );
@@ -73,12 +81,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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' ]]);
$statement = $db -> prepare ( 'INSERT INTO instances (owner_user_id, name, plan, billing_period, price_gross_cents, payment_status, payment_method, container_name, port, volume_size_mb, memory_limit_mb, cpu_limit, status) VALUES (:owner, :name, :plan, :billing_period, :price_gross_cents, "pending", :method, :container, :port, :storage, :memory, :cpu, "pending_payment")' );
$statement -> execute ([ 'owner' => $userId , 'name' => $name , 'plan' => $planKey , 'billing_period' => $billingPeriod , 'price_gross_cents' => $p [ $billingPeriod . '_cents' ], ' 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 = $db -> prepare ( 'SELECT * FROM instances WHERE id = :id AND (( status = "pending_payment") OR (status = "error" AND payment_status = "paid")) LIMIT 1' );
$query -> execute ([ 'id' => $instanceId ]);
$instance = $query -> fetch ();
if ( ! $instance ) throw new RuntimeException ( 'Diese Instanzanfrage ist nicht mehr freigabefähig.' );
@@ -144,7 +152,9 @@ if ($_SERVER['REQUEST_METHOD'] === '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 ;
$apiPath = $operation === 'delete'
? '/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '?force=true'
: '/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '/' . $operation ;
pnpaas_portainer_request ( $apiPath , $operation === 'delete' ? 'DELETE' : 'POST' , []);
$newStatus = match ( $operation ) {
'start' , 'restart' => 'running' ,
@@ -164,8 +174,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
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 () );
$debugDetail = dashboard_debug_detail ( $exception );
error_log ( 'PnPaaS instance action error: ' . $debugDetail );
$error = 'Die angeforderte Aktion konnte nicht ausgeführt werden. Bitte versuchen Sie es erneut.' ;
if ( $isAdmin ) $error .= ' Technischer Hinweis: ' . $debugDetail ;
}
}
}
@@ -180,7 +192,14 @@ $realmError = null;
if ( $isAdmin && ! $showInstances ) {
try {
$e ndpoints = pnpaas_portainer_request ( '/api/endpoints' );
$configuredE ndpointId = pnpaas_portainer_endpoint_id ();
// Die Endpoint-Metadaten sind für eingeschränkte Portainer-Benutzer nicht immer lesbar.
// Die konfigurierte ID reicht aus; der eigentliche Zugriff erfolgt über den Docker-Proxy.
$endpoints = [[
'Id' => $configuredEndpointId ,
'Name' => 'Portainer-Umgebung ' . $configuredEndpointId ,
'Status' => 1 ,
]];
foreach ( $endpoints as $endpoint ) {
$endpointId = ( int )( $endpoint [ 'Id' ] ? ? 0 );
if ( $endpointId < 1 ) continue ;
@@ -236,8 +255,8 @@ if ($isAdmin && !$showInstances) {
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 ; })();
? pnpaas_db () -> query ( 'SELECT i.*, u.username FROM instances i JOIN users u ON u.id = i.owner_user_id WHERE i.status <> "deleted" 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 AND i.status <> "deleted" ORDER BY i.created_at DESC' ); $s -> execute ([ 'owner' => $userId ]); return $s ; })();
$instances = $statement -> fetchAll ();
}
@@ -284,7 +303,7 @@ function dashboard_bytes(int $bytes): string {
<?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>
<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="billing_period">Abrechnung</label><select id="billing_period" name="billing_period"><option value="monthly">Monatlich</option><option value="yearly">Jährlich (Rabatt)</option></select><label for="plan">Tarif</label><select id="plan" name="plan"><option value="s">S – 2,99 € monatlich / 29,99 € jährlich · 512 MB RAM · 1 CPU · 5 GB</option><option value="m">M – 3,99 € monatlich / 39,99 € jährlich · 2 GB RAM · 2 CPU · 10 GB</option><option value="l">L – 4,99 € monatlich / 49,99 € jährlich · 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 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>Preis</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="Preis"><?= dashboard_escape(dashboard_money((int)($instance['price_gross_cents'] ?? 0))) ?> (<?= (string)($instance['billing_period'] ?? 'monthly') === 'yearly' ? 'jährlich' : 'monatlich' ?>)</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 (in_array((string) $instance['status'], [ 'pending_payment', 'error'], true) && $isAdmin && ($instance['status'] === 'pending_payment' || $instance['payment_status'] === 'paid') ): ?><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"><?= $instance['status'] === 'error' ? 'Erneut provisionieren' : ' 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>