weitere Bearibeitung
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
||||
use App\Core\Auth;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
use App\Core\Session;
|
||||
use App\Core\TenantContext;
|
||||
@@ -22,7 +23,7 @@ final class PlatformController
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
private readonly array $config,
|
||||
private readonly Database $database,
|
||||
private readonly DatabaseInterface $database,
|
||||
private readonly View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
||||
use App\Core\Auth;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Core\Response;
|
||||
use App\Core\Session;
|
||||
use App\Core\TenantContext;
|
||||
@@ -19,7 +20,7 @@ use RuntimeException;
|
||||
final class TenantController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $database,
|
||||
private readonly DatabaseInterface $database,
|
||||
private readonly View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf,
|
||||
|
||||
+2
-2
@@ -68,11 +68,11 @@ final class Auth
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->rehashPasswordIfNeeded((int) $membership['id'], (string) $membership['password_hash'], $password);
|
||||
$this->rehashPasswordIfNeeded((int) $membership['user_id'], (string) $membership['password_hash'], $password);
|
||||
|
||||
$this->session->regenerate();
|
||||
$this->session->put('auth', [
|
||||
'user_id' => (int) $membership['id'],
|
||||
'user_id' => (int) $membership['user_id'],
|
||||
'tenant_id' => (int) $membership['tenant_id'],
|
||||
'tenant_slug' => $membership['tenant_slug'],
|
||||
'tenant_role' => $membership['role'],
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace App\Core;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
final class Database
|
||||
final class Database implements DatabaseInterface
|
||||
{
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Gemeinsames Interface für Database (MySQL) und DatabaseSqlite.
|
||||
* Ermöglicht den Betrieb beider Backends ohne Type-Errors.
|
||||
*/
|
||||
interface DatabaseInterface
|
||||
{
|
||||
public function pdo(): PDO;
|
||||
public function isConfigured(): bool;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* SQLite-basierte Datenbank für Test-/Entwicklungsumgebungen.
|
||||
* Ersetzt die MySQL-Database-Klasse und emuliert MySQL-spezifische Features.
|
||||
*/
|
||||
final class DatabaseSqlite implements DatabaseInterface
|
||||
{
|
||||
private ?PDO $pdo = null;
|
||||
private readonly string $dbPath;
|
||||
|
||||
public function __construct(private readonly array $config = [])
|
||||
{
|
||||
$this->dbPath = $this->config['path']
|
||||
?? dirname(__DIR__, 2) . '/storage/database.sqlite';
|
||||
}
|
||||
|
||||
public function pdo(): PDO
|
||||
{
|
||||
if ($this->pdo instanceof PDO) {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
$dbDir = dirname($this->dbPath);
|
||||
|
||||
if (!is_dir($dbDir) && !mkdir($dbDir, 0775, true) && !is_dir($dbDir)) {
|
||||
throw new RuntimeException('Das Datenbank-Verzeichnis konnte nicht erstellt werden: ' . $dbDir);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pdo = new PDO(
|
||||
'sqlite:' . $this->dbPath,
|
||||
null,
|
||||
null,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$this->pdo->exec('PRAGMA foreign_keys=ON');
|
||||
$this->pdo->exec('PRAGMA busy_timeout=5000');
|
||||
} catch (PDOException $exception) {
|
||||
throw new PDOException(
|
||||
'SQLite connection failed: ' . $exception->getMessage(),
|
||||
(int) $exception->getCode(),
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->dbPath !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt schema.sqlite.sql aus, um die Datenbank zu initialisieren.
|
||||
*/
|
||||
public function initializeSchema(): void
|
||||
{
|
||||
$schemaFile = dirname(__DIR__, 2) . '/database/schema.sqlite.sql';
|
||||
|
||||
if (!is_file($schemaFile)) {
|
||||
throw new RuntimeException('SQLite-Schema nicht gefunden: ' . $schemaFile);
|
||||
}
|
||||
|
||||
$schema = file_get_contents($schemaFile);
|
||||
|
||||
if (!is_string($schema) || $schema === '') {
|
||||
throw new RuntimeException('SQLite-Schema konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$this->pdo()->exec($schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite-kompatibler UPSERT: INSERT OR REPLACE + SELECT als Alternative zu ON DUPLICATE KEY.
|
||||
*/
|
||||
public function upsertRateLimit(string $actionKey, string $bucketKey, string $now): void
|
||||
{
|
||||
$pdo = $this->pdo();
|
||||
|
||||
$existing = $pdo->prepare(
|
||||
'SELECT id FROM rate_limits WHERE action_key = :action_key AND bucket_key = :bucket_key LIMIT 1'
|
||||
);
|
||||
$existing->execute([
|
||||
'action_key' => $actionKey,
|
||||
'bucket_key' => $bucketKey,
|
||||
]);
|
||||
|
||||
if (!$existing->fetchColumn()) {
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO rate_limits (action_key, bucket_key, attempts, window_start, last_attempt_at, created_at)
|
||||
VALUES (:action_key, :bucket_key, 0, :window_start, :last_attempt_at, :created_at)'
|
||||
);
|
||||
$insert->execute([
|
||||
'action_key' => $actionKey,
|
||||
'bucket_key' => $bucketKey,
|
||||
'window_start' => $now,
|
||||
'last_attempt_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function getDbPath(): string
|
||||
{
|
||||
return $this->dbPath;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,15 @@ final class Response
|
||||
{
|
||||
public static function redirect(string $url): never
|
||||
{
|
||||
if (str_starts_with($url, '/') && function_exists('url')) {
|
||||
$url = \url($url);
|
||||
} else {
|
||||
$prefix = $_SERVER['KAFFEEKASSE_PROXY_PREFIX'] ?? '';
|
||||
if ($prefix !== '' && str_starts_with($url, '/') && !str_starts_with($url, $prefix)) {
|
||||
$url = $prefix . $url;
|
||||
}
|
||||
}
|
||||
|
||||
header('Location: ' . $url);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class RateLimiterService
|
||||
{
|
||||
@@ -43,45 +44,69 @@ final class RateLimiterService
|
||||
$nowTs = time();
|
||||
$now = $this->formatTimestamp($nowTs);
|
||||
|
||||
$isSqlite = $this->isSqlite();
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$bootstrapStatement = $this->pdo->prepare(
|
||||
'INSERT INTO rate_limits (
|
||||
action_key,
|
||||
bucket_key,
|
||||
attempts,
|
||||
window_start,
|
||||
last_attempt_at,
|
||||
blocked_until,
|
||||
created_at
|
||||
) VALUES (
|
||||
:action_key,
|
||||
:bucket_key,
|
||||
0,
|
||||
:window_start,
|
||||
:last_attempt_at,
|
||||
NULL,
|
||||
:created_at
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
action_key = action_key'
|
||||
);
|
||||
$bootstrapStatement->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
'window_start' => $now,
|
||||
'last_attempt_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
if ($isSqlite) {
|
||||
$existingStmt = $this->pdo->prepare(
|
||||
'SELECT id FROM rate_limits WHERE action_key = :action_key AND bucket_key = :bucket_key LIMIT 1'
|
||||
);
|
||||
$existingStmt->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
]);
|
||||
|
||||
if (!$existingStmt->fetchColumn()) {
|
||||
$this->pdo->prepare(
|
||||
'INSERT INTO rate_limits (action_key, bucket_key, attempts, window_start, last_attempt_at, created_at)
|
||||
VALUES (:action_key, :bucket_key, 0, :window_start, :last_attempt_at, :created_at)'
|
||||
)->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
'window_start' => $now,
|
||||
'last_attempt_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$bootstrapStatement = $this->pdo->prepare(
|
||||
'INSERT INTO rate_limits (
|
||||
action_key,
|
||||
bucket_key,
|
||||
attempts,
|
||||
window_start,
|
||||
last_attempt_at,
|
||||
blocked_until,
|
||||
created_at
|
||||
) VALUES (
|
||||
:action_key,
|
||||
:bucket_key,
|
||||
0,
|
||||
:window_start,
|
||||
:last_attempt_at,
|
||||
NULL,
|
||||
:created_at
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
action_key = action_key'
|
||||
);
|
||||
$bootstrapStatement->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
'window_start' => $now,
|
||||
'last_attempt_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$forUpdate = $isSqlite ? '' : ' FOR UPDATE';
|
||||
$selectStatement = $this->pdo->prepare(
|
||||
'SELECT attempts, window_start, last_attempt_at, blocked_until
|
||||
FROM rate_limits
|
||||
WHERE action_key = :action_key
|
||||
AND bucket_key = :bucket_key
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
LIMIT 1' . $forUpdate
|
||||
);
|
||||
$selectStatement->execute([
|
||||
'action_key' => $policy['action'],
|
||||
@@ -136,7 +161,7 @@ final class RateLimiterService
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
} catch (Throwable $throwable) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
@@ -239,6 +264,15 @@ final class RateLimiterService
|
||||
];
|
||||
}
|
||||
|
||||
private function isSqlite(): bool
|
||||
{
|
||||
try {
|
||||
return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function bucketKey(string $scope): string
|
||||
{
|
||||
$scope = trim($scope);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* SQLite-kompatible Version des RateLimiterService.
|
||||
* Ersetzt MySQL-spezifisches ON DUPLICATE KEY UPDATE durch SELECT+INSERT.
|
||||
*/
|
||||
final class RateLimiterSqliteService
|
||||
{
|
||||
private readonly DateTimeZone $utc;
|
||||
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly array $config = []
|
||||
) {
|
||||
$this->utc = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
public function inspect(string $action, string $scope): array
|
||||
{
|
||||
$policy = $this->resolvePolicy($action);
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT attempts, window_start, last_attempt_at, blocked_until
|
||||
FROM rate_limits
|
||||
WHERE action_key = :action_key
|
||||
AND bucket_key = :bucket_key
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $this->bucketKey($scope),
|
||||
]);
|
||||
|
||||
return $this->buildState($policy, $statement->fetch() ?: null, time());
|
||||
}
|
||||
|
||||
public function hit(string $action, string $scope): array
|
||||
{
|
||||
$policy = $this->resolvePolicy($action);
|
||||
$bucketKey = $this->bucketKey($scope);
|
||||
$nowTs = time();
|
||||
$now = $this->formatTimestamp($nowTs);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
// SQLite-kompatibler Upsert: SELECT + INSERT falls nicht vorhanden
|
||||
$existingStmt = $this->pdo->prepare(
|
||||
'SELECT id FROM rate_limits WHERE action_key = :action_key AND bucket_key = :bucket_key LIMIT 1'
|
||||
);
|
||||
$existingStmt->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
]);
|
||||
|
||||
if (!$existingStmt->fetchColumn()) {
|
||||
$insertStmt = $this->pdo->prepare(
|
||||
'INSERT INTO rate_limits (action_key, bucket_key, attempts, window_start, last_attempt_at, created_at)
|
||||
VALUES (:action_key, :bucket_key, 0, :window_start, :last_attempt_at, :created_at)'
|
||||
);
|
||||
$insertStmt->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
'window_start' => $now,
|
||||
'last_attempt_at' => $now,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$selectStatement = $this->pdo->prepare(
|
||||
'SELECT attempts, window_start, last_attempt_at, blocked_until
|
||||
FROM rate_limits
|
||||
WHERE action_key = :action_key
|
||||
AND bucket_key = :bucket_key
|
||||
LIMIT 1'
|
||||
);
|
||||
$selectStatement->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
]);
|
||||
$row = $selectStatement->fetch();
|
||||
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
|
||||
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
|
||||
$isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs;
|
||||
$resetWindow = $windowStartTs === null
|
||||
|| ($windowStartTs + $policy['window_seconds']) <= $nowTs
|
||||
|| ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs);
|
||||
|
||||
if ($isBlocked) {
|
||||
$updatedRow = $row;
|
||||
} else {
|
||||
$attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1);
|
||||
$windowStart = $resetWindow ? $now : (string) $row['window_start'];
|
||||
$blockedUntil = $attempts >= $policy['max_attempts']
|
||||
? $this->formatTimestamp($nowTs + $policy['block_seconds'])
|
||||
: null;
|
||||
|
||||
$updateStatement = $this->pdo->prepare(
|
||||
'UPDATE rate_limits
|
||||
SET attempts = :attempts,
|
||||
window_start = :window_start,
|
||||
last_attempt_at = :last_attempt_at,
|
||||
blocked_until = :blocked_until
|
||||
WHERE action_key = :action_key
|
||||
AND bucket_key = :bucket_key'
|
||||
);
|
||||
$updateStatement->execute([
|
||||
'attempts' => $attempts,
|
||||
'window_start' => $windowStart,
|
||||
'last_attempt_at' => $now,
|
||||
'blocked_until' => $blockedUntil,
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $bucketKey,
|
||||
]);
|
||||
|
||||
$updatedRow = [
|
||||
'attempts' => $attempts,
|
||||
'window_start' => $windowStart,
|
||||
'last_attempt_at' => $now,
|
||||
'blocked_until' => $blockedUntil,
|
||||
];
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
return $this->buildState($policy, $updatedRow, $nowTs);
|
||||
}
|
||||
|
||||
public function clear(string $action, string $scope): void
|
||||
{
|
||||
$policy = $this->resolvePolicy($action);
|
||||
$statement = $this->pdo->prepare(
|
||||
'DELETE FROM rate_limits
|
||||
WHERE action_key = :action_key
|
||||
AND bucket_key = :bucket_key'
|
||||
);
|
||||
$statement->execute([
|
||||
'action_key' => $policy['action'],
|
||||
'bucket_key' => $this->bucketKey($scope),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tooManyAttempts(string $action, string $scope): bool
|
||||
{
|
||||
return !$this->inspect($action, $scope)['allowed'];
|
||||
}
|
||||
|
||||
public function prune(int $staleAfterSeconds = 604800): int
|
||||
{
|
||||
$cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds));
|
||||
$statement = $this->pdo->prepare(
|
||||
'DELETE FROM rate_limits
|
||||
WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff)
|
||||
OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)'
|
||||
);
|
||||
$statement->execute(['cutoff' => $cutoff]);
|
||||
|
||||
return $statement->rowCount();
|
||||
}
|
||||
|
||||
private function buildState(array $policy, ?array $row, int $nowTs): array
|
||||
{
|
||||
$attempts = 0;
|
||||
$blockedUntil = null;
|
||||
$retryAfter = 0;
|
||||
$allowed = true;
|
||||
$resetAtTs = $nowTs + $policy['window_seconds'];
|
||||
|
||||
if ($row) {
|
||||
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
|
||||
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
|
||||
|
||||
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
|
||||
$attempts = (int) $row['attempts'];
|
||||
$blockedUntil = (string) $row['blocked_until'];
|
||||
$retryAfter = max(1, $blockedUntilTs - $nowTs);
|
||||
$allowed = false;
|
||||
$resetAtTs = $blockedUntilTs;
|
||||
} elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) {
|
||||
$attempts = (int) $row['attempts'];
|
||||
$resetAtTs = $windowStartTs + $policy['window_seconds'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => $policy['action'],
|
||||
'allowed' => $allowed,
|
||||
'attempts' => $attempts,
|
||||
'max_attempts' => $policy['max_attempts'],
|
||||
'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts),
|
||||
'window_seconds' => $policy['window_seconds'],
|
||||
'block_seconds' => $policy['block_seconds'],
|
||||
'retry_after_seconds' => $retryAfter,
|
||||
'reset_at' => $this->formatTimestamp($resetAtTs),
|
||||
'blocked_until' => $blockedUntil,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePolicy(string $action): array
|
||||
{
|
||||
$normalizedAction = mb_strtolower(trim($action));
|
||||
|
||||
if ($normalizedAction === '') {
|
||||
throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.');
|
||||
}
|
||||
|
||||
$policy = $this->config[$normalizedAction] ?? null;
|
||||
|
||||
if (!is_array($policy)) {
|
||||
throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction));
|
||||
}
|
||||
|
||||
return [
|
||||
'action' => $normalizedAction,
|
||||
'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)),
|
||||
'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)),
|
||||
'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)),
|
||||
];
|
||||
}
|
||||
|
||||
private function bucketKey(string $scope): string
|
||||
{
|
||||
$scope = trim($scope);
|
||||
|
||||
if ($scope === '') {
|
||||
throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.');
|
||||
}
|
||||
|
||||
return hash('sha256', $scope);
|
||||
}
|
||||
|
||||
private function formatTimestamp(int $timestamp): string
|
||||
{
|
||||
return gmdate('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
private function timestampFromDatabase(?string $value): ?int
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
|
||||
|
||||
if (!$date instanceof DateTimeImmutable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->getTimestamp();
|
||||
}
|
||||
}
|
||||
+112
-37
@@ -45,37 +45,16 @@ final class SetupService
|
||||
|
||||
$this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url']));
|
||||
|
||||
$config = [
|
||||
'host' => trim((string) $input['db_host']),
|
||||
'port' => (int) $input['db_port'],
|
||||
'name' => trim((string) $input['db_name']),
|
||||
'user' => trim((string) $input['db_user']),
|
||||
'pass' => (string) ($input['db_pass'] ?? ''),
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['name'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
|
||||
|
||||
if (!is_string($schema) || $schema === '') {
|
||||
throw new RuntimeException('Das Datenbankschema konnte nicht geladen werden.');
|
||||
// Prüfe verfügbare PDO-Treiber und verwende entsprechende Datenbank
|
||||
$availableDrivers = PDO::getAvailableDrivers();
|
||||
$useMySQL = in_array('mysql', $availableDrivers) && trim((string) $input['db_host']) !== 'sqlite';
|
||||
|
||||
if ($useMySQL) {
|
||||
$pdo = $this->setupMySQLDatabase($input);
|
||||
} else {
|
||||
$pdo = $this->setupSQLiteDatabase();
|
||||
}
|
||||
|
||||
$pdo->exec($schema);
|
||||
|
||||
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
|
||||
$existing = $statement->fetchColumn();
|
||||
@@ -92,7 +71,7 @@ final class SetupService
|
||||
]);
|
||||
}
|
||||
|
||||
$envContent = $this->buildEnvFile($input);
|
||||
$envContent = $this->buildEnvFile($input, $useMySQL);
|
||||
|
||||
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
|
||||
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
|
||||
@@ -101,12 +80,90 @@ final class SetupService
|
||||
$lockContent = json_encode([
|
||||
'installed_at' => gmdate(DATE_ATOM),
|
||||
'app_url' => trim((string) $input['app_url']),
|
||||
'database_type' => $useMySQL ? 'mysql' : 'sqlite',
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent);
|
||||
}
|
||||
|
||||
private function buildEnvFile(array $input): string
|
||||
private function setupMySQLDatabase(array $input): PDO
|
||||
{
|
||||
$config = [
|
||||
'host' => trim((string) $input['db_host']),
|
||||
'port' => (int) $input['db_port'],
|
||||
'name' => trim((string) $input['db_name']),
|
||||
'user' => trim((string) $input['db_user']),
|
||||
'pass' => (string) ($input['db_pass'] ?? ''),
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['name'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
|
||||
|
||||
if (!is_string($schema) || $schema === '') {
|
||||
throw new RuntimeException('Das MySQL-Datenbankschema konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$pdo->exec($schema);
|
||||
return $pdo;
|
||||
} catch (\PDOException $e) {
|
||||
throw new RuntimeException('MySQL-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage() . '. Bitte prüfen Sie Ihre Datenbankeinstellungen oder verwenden Sie SQLite als Alternative.');
|
||||
}
|
||||
}
|
||||
|
||||
private function setupSQLiteDatabase(): PDO
|
||||
{
|
||||
$dbPath = $this->rootPath . '/storage/database.sqlite';
|
||||
$dbDir = dirname($dbPath);
|
||||
|
||||
if (!is_dir($dbDir) && !mkdir($dbDir, 0775, true) && !is_dir($dbDir)) {
|
||||
throw new RuntimeException('Das Datenbank-Verzeichnis konnte nicht erstellt werden: ' . $dbDir);
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
'sqlite:' . $dbPath,
|
||||
null,
|
||||
null,
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
|
||||
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||
|
||||
$schema = file_get_contents($this->rootPath . '/database/schema.sqlite.sql');
|
||||
|
||||
if (!is_string($schema) || $schema === '') {
|
||||
throw new RuntimeException('Das SQLite-Datenbankschema konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$pdo->exec($schema);
|
||||
return $pdo;
|
||||
} catch (\PDOException $e) {
|
||||
throw new RuntimeException('SQLite-Datenbankverbindung fehlgeschlagen: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function buildEnvFile(array $input, bool $useMySQL = true): string
|
||||
{
|
||||
$appUrl = $this->normalizeAppUrl((string) $input['app_url']);
|
||||
$mailFrom = $this->resolveMailFrom($input, $appUrl);
|
||||
@@ -123,11 +180,29 @@ final class SetupService
|
||||
'ALLOW_PUBLIC_REGISTRATION=1',
|
||||
'RFID_INGEST_ENABLED=0',
|
||||
'',
|
||||
'DB_HOST=' . trim((string) $input['db_host']),
|
||||
'DB_PORT=' . (int) $input['db_port'],
|
||||
'DB_NAME=' . trim((string) $input['db_name']),
|
||||
'DB_USER=' . trim((string) $input['db_user']),
|
||||
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
||||
];
|
||||
|
||||
// Datenbankeinstellungen je nach verwendetem System
|
||||
if ($useMySQL) {
|
||||
$lines = array_merge($lines, [
|
||||
'DB_HOST=' . trim((string) $input['db_host']),
|
||||
'DB_PORT=' . (int) $input['db_port'],
|
||||
'DB_NAME=' . trim((string) $input['db_name']),
|
||||
'DB_USER=' . trim((string) $input['db_user']),
|
||||
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
||||
]);
|
||||
} else {
|
||||
// SQLite-Konfiguration
|
||||
$lines = array_merge($lines, [
|
||||
'DB_HOST=sqlite',
|
||||
'DB_PORT=0',
|
||||
'DB_NAME=' . $this->rootPath . '/storage/database.sqlite',
|
||||
'DB_USER=',
|
||||
'DB_PASS=',
|
||||
]);
|
||||
}
|
||||
|
||||
$lines = array_merge($lines, [
|
||||
'',
|
||||
'MAIL_FROM=' . $mailFrom,
|
||||
'MAIL_REPLY_TO=' . $mailFrom,
|
||||
@@ -148,7 +223,7 @@ final class SetupService
|
||||
'RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600',
|
||||
'',
|
||||
'RFID_SHARED_SECRET=' . $rfidSecret,
|
||||
];
|
||||
]);
|
||||
|
||||
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||
}
|
||||
|
||||
+141
-2
@@ -3,6 +3,32 @@
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Session;
|
||||
|
||||
function base_path(): string
|
||||
{
|
||||
foreach ([
|
||||
$_SERVER['KAFFEEKASSE_PROXY_PREFIX'] ?? null,
|
||||
$_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null,
|
||||
$_ENV['APP_BASE_PATH'] ?? null,
|
||||
] as $prefix) {
|
||||
$prefix = trim((string) $prefix);
|
||||
|
||||
if ($prefix !== '') {
|
||||
return normalize_base_path($prefix);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalize_base_path(string $prefix): string
|
||||
{
|
||||
$path = parse_url($prefix, PHP_URL_PATH);
|
||||
$path = is_string($path) ? $path : $prefix;
|
||||
$path = '/' . trim($path, '/');
|
||||
|
||||
return $path === '/' ? '' : $path;
|
||||
}
|
||||
|
||||
function e(?string $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
@@ -12,12 +38,120 @@ function current_path(): string
|
||||
{
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
|
||||
return is_string($uri) ? $uri : '/';
|
||||
$path = is_string($uri) && $uri !== '' ? $uri : '/';
|
||||
$basePath = base_path();
|
||||
|
||||
if ($basePath !== '' && ($path === $basePath || str_starts_with($path, $basePath . '/'))) {
|
||||
$path = substr($path, strlen($basePath)) ?: '/';
|
||||
}
|
||||
|
||||
$path = '/' . ltrim($path, '/');
|
||||
|
||||
return $path === '/' ? '/' : rtrim($path, '/');
|
||||
}
|
||||
|
||||
function url(string $path = '/', array $query = []): string
|
||||
{
|
||||
$path = trim($path);
|
||||
|
||||
if ($path === '') {
|
||||
$path = '/';
|
||||
}
|
||||
|
||||
if (preg_match('#^(?:[a-z][a-z0-9+.-]*:)?//#i', $path) || str_starts_with($path, '#')) {
|
||||
return append_query($path, $query);
|
||||
}
|
||||
|
||||
$fragment = '';
|
||||
$hashPosition = strpos($path, '#');
|
||||
if ($hashPosition !== false) {
|
||||
$fragment = substr($path, $hashPosition);
|
||||
$path = substr($path, 0, $hashPosition);
|
||||
}
|
||||
|
||||
$existingQuery = '';
|
||||
$queryPosition = strpos($path, '?');
|
||||
if ($queryPosition !== false) {
|
||||
$existingQuery = substr($path, $queryPosition + 1);
|
||||
$path = substr($path, 0, $queryPosition);
|
||||
}
|
||||
|
||||
$targetPath = '/' . ltrim($path, '/');
|
||||
$targetPath = $targetPath === '//' ? '/' : $targetPath;
|
||||
$targetPath = $targetPath === '/' ? '/' : rtrim($targetPath, '/');
|
||||
$queryString = build_query_string($existingQuery, $query);
|
||||
$basePath = base_path();
|
||||
|
||||
if ($basePath !== '') {
|
||||
return $basePath . ($targetPath === '/' ? '/' : $targetPath) . $queryString . $fragment;
|
||||
}
|
||||
|
||||
return relative_url($targetPath) . $queryString . $fragment;
|
||||
}
|
||||
|
||||
function append_query(string $url, array $query): string
|
||||
{
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
function build_query_string(string $existingQuery, array $query): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if ($existingQuery !== '') {
|
||||
$parts[] = $existingQuery;
|
||||
}
|
||||
|
||||
if ($query !== []) {
|
||||
$parts[] = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
return $parts === [] ? '' : '?' . implode('&', $parts);
|
||||
}
|
||||
|
||||
function relative_url(string $targetPath): string
|
||||
{
|
||||
$currentPath = current_path();
|
||||
$fromSegments = path_segments($currentPath);
|
||||
|
||||
if (!str_ends_with($currentPath, '/')) {
|
||||
array_pop($fromSegments);
|
||||
}
|
||||
|
||||
$toSegments = path_segments($targetPath);
|
||||
$common = 0;
|
||||
$maxCommon = min(count($fromSegments), count($toSegments));
|
||||
|
||||
while ($common < $maxCommon && $fromSegments[$common] === $toSegments[$common]) {
|
||||
$common++;
|
||||
}
|
||||
|
||||
if ($toSegments !== [] && $common === count($toSegments) && $common === count($fromSegments)) {
|
||||
return '../' . end($toSegments);
|
||||
}
|
||||
|
||||
$relativeSegments = array_merge(
|
||||
array_fill(0, count($fromSegments) - $common, '..'),
|
||||
array_slice($toSegments, $common)
|
||||
);
|
||||
|
||||
return $relativeSegments === [] ? '.' : implode('/', $relativeSegments);
|
||||
}
|
||||
|
||||
function path_segments(string $path): array
|
||||
{
|
||||
$trimmed = trim($path, '/');
|
||||
|
||||
return $trimmed === '' ? [] : explode('/', $trimmed);
|
||||
}
|
||||
|
||||
function asset_url(string $path): string
|
||||
{
|
||||
return '/assets/' . ltrim($path, '/');
|
||||
return url('/assets/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
function old(string $key, mixed $default = ''): mixed
|
||||
@@ -52,6 +186,11 @@ function flash(Session $session, string $key, mixed $default = null): mixed
|
||||
}
|
||||
|
||||
function tenant_url(string $tenantSlug, string $path = ''): string
|
||||
{
|
||||
return url(tenant_path($tenantSlug, $path));
|
||||
}
|
||||
|
||||
function tenant_path(string $tenantSlug, string $path = ''): string
|
||||
{
|
||||
$suffix = $path === '' ? '' : '/' . ltrim($path, '/');
|
||||
|
||||
|
||||
+25
-1
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Core\Autoloader;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\DatabaseSqlite;
|
||||
use App\Core\Env;
|
||||
use App\Core\Session;
|
||||
use App\Core\View;
|
||||
@@ -30,7 +31,30 @@ $session = new Session();
|
||||
$session->start();
|
||||
|
||||
$csrf = new Csrf($session);
|
||||
$database = new Database($config['db']);
|
||||
|
||||
// SQLite oder MySQL basierend auf DB_HOST Konfiguration und verfügbaren Treibern
|
||||
if (($config['db']['host'] ?? '') === 'sqlite') {
|
||||
$database = new DatabaseSqlite(['path' => $config['db']['name']]);
|
||||
} else {
|
||||
// Prüfe ob MySQL PDO Treiber verfügbar ist
|
||||
$availableDrivers = PDO::getAvailableDrivers();
|
||||
if (in_array('mysql', $availableDrivers)) {
|
||||
try {
|
||||
$database = new Database($config['db']);
|
||||
// Teste die Verbindung
|
||||
$database->pdo();
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback zu SQLite wenn MySQL-Verbindung fehlschlägt
|
||||
error_log('MySQL connection failed, falling back to SQLite: ' . $e->getMessage());
|
||||
$database = new DatabaseSqlite(['path' => $rootPath . '/storage/database.sqlite']);
|
||||
}
|
||||
} else {
|
||||
// Fallback zu SQLite wenn MySQL-Treiber nicht verfügbar
|
||||
error_log('MySQL PDO driver not available, using SQLite');
|
||||
$database = new DatabaseSqlite(['path' => $rootPath . '/storage/database.sqlite']);
|
||||
}
|
||||
}
|
||||
|
||||
$view = new View($rootPath, [
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Autoloader;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\DatabaseSqlite;
|
||||
use App\Core\Env;
|
||||
use App\Core\Session;
|
||||
use App\Core\View;
|
||||
|
||||
/**
|
||||
* Test-Bootstrap für SQLite-basierte Entwicklungsumgebung.
|
||||
* Lädt .env.test oder .env, erstellt eine SQLite-Datenbank in storage/.
|
||||
*/
|
||||
|
||||
$rootPath = dirname(__DIR__);
|
||||
|
||||
require_once $rootPath . '/app/Support/helpers.php';
|
||||
require_once $rootPath . '/app/Core/Autoloader.php';
|
||||
|
||||
Autoloader::register($rootPath);
|
||||
|
||||
// Versuche zuerst .env.test, dann .env
|
||||
$envFile = $rootPath . '/.env.test';
|
||||
if (!is_file($envFile)) {
|
||||
$envFile = $rootPath . '/.env';
|
||||
}
|
||||
Env::load($envFile);
|
||||
|
||||
// Test-Konfiguration überschreiben falls .env.test Werte setzt
|
||||
$config = require $rootPath . '/config/app.php';
|
||||
$config['env'] = 'development';
|
||||
$config['debug'] = true;
|
||||
$config['name'] = 'Kaffeekasse DEV';
|
||||
$config['allow_public_registration'] = true;
|
||||
$config['rate_limits']['login']['max_attempts'] = 50;
|
||||
$config['rate_limits']['login']['window_seconds'] = 3600;
|
||||
$config['rate_limits']['registration']['max_attempts'] = 50;
|
||||
$config['rate_limits']['registration']['window_seconds'] = 3600;
|
||||
|
||||
date_default_timezone_set($config['timezone']);
|
||||
|
||||
// Session-Konfiguration NUR setzen, wenn noch keine Session aktiv
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_trans_sid', '0');
|
||||
session_name('kaffeekasse_session_test');
|
||||
}
|
||||
|
||||
$session = new Session();
|
||||
$session->start();
|
||||
$csrf = new Csrf($session);
|
||||
|
||||
// SQLite-Datenbank
|
||||
$dbConfig = [
|
||||
'path' => $rootPath . '/storage/test.sqlite',
|
||||
];
|
||||
$database = new DatabaseSqlite($dbConfig);
|
||||
$database->initializeSchema();
|
||||
|
||||
// Prüfe, ob ein Plattform-Admin existiert, sonst lege Demo-Admin an
|
||||
try {
|
||||
$pdo = $database->pdo();
|
||||
$adminStmt = $pdo->query("SELECT id FROM users WHERE platform_role = 'platform_admin' LIMIT 1");
|
||||
if (!$adminStmt->fetchColumn()) {
|
||||
$pdo->prepare(
|
||||
"INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, 'platform_admin')"
|
||||
)->execute([
|
||||
'full_name' => 'Admin',
|
||||
'email' => 'admin@demo.kaffeekasse',
|
||||
'password_hash' => password_hash('admin12345678', PASSWORD_BCRYPT),
|
||||
]);
|
||||
echo "[Test-Bootstrap] Demo-Admin angelegt: admin@demo.kaffeekasse / admin12345678\n";
|
||||
}
|
||||
|
||||
// Prüfe, ob ein Demo-Tenant existiert, sonst lege an
|
||||
$tenantStmt = $pdo->query("SELECT id FROM tenants WHERE slug = 'demo' LIMIT 1");
|
||||
if (!$tenantStmt->fetchColumn()) {
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
|
||||
// Tenant
|
||||
$pdo->prepare(
|
||||
"INSERT INTO tenants (name, slug, plan, status, created_at)
|
||||
VALUES (:name, :slug, :plan, :status, :created_at)"
|
||||
)->execute([
|
||||
'name' => 'Demo-Tenant',
|
||||
'slug' => 'demo',
|
||||
'plan' => 'team',
|
||||
'status' => 'active',
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$tenantId = (int) $pdo->lastInsertId();
|
||||
|
||||
// Owner-Benutzer
|
||||
$pdo->prepare(
|
||||
"INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, 'user')"
|
||||
)->execute([
|
||||
'full_name' => 'Demo-Owner',
|
||||
'email' => 'owner@demo.kaffeekasse',
|
||||
'password_hash' => password_hash('owner12345678', PASSWORD_BCRYPT),
|
||||
]);
|
||||
$ownerUserId = (int) $pdo->lastInsertId();
|
||||
|
||||
// Member-Benutzer
|
||||
$pdo->prepare(
|
||||
"INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, 'user')"
|
||||
)->execute([
|
||||
'full_name' => 'Demo-Member',
|
||||
'email' => 'member@demo.kaffeekasse',
|
||||
'password_hash' => password_hash('member12345678', PASSWORD_BCRYPT),
|
||||
]);
|
||||
$memberUserId = (int) $pdo->lastInsertId();
|
||||
|
||||
// Memberships
|
||||
$pdo->prepare(
|
||||
"INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||
VALUES (:tenant_id, :user_id, :role, 'active')"
|
||||
)->execute(['tenant_id' => $tenantId, 'user_id' => $ownerUserId, 'role' => 'owner']);
|
||||
|
||||
$pdo->prepare(
|
||||
"INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||
VALUES (:tenant_id, :user_id, :role, 'active')"
|
||||
)->execute(['tenant_id' => $tenantId, 'user_id' => $memberUserId, 'role' => 'member']);
|
||||
|
||||
// Settings
|
||||
$pdo->prepare(
|
||||
"INSERT INTO tenant_settings (tenant_id, currency_code, default_product_price_cents, allow_self_service, allow_paper_lists, allow_rfid)
|
||||
VALUES (:tenant_id, 'EUR', 120, 1, 1, 0)"
|
||||
)->execute(['tenant_id' => $tenantId]);
|
||||
|
||||
// Members
|
||||
$pdo->prepare(
|
||||
"INSERT INTO members (tenant_id, user_id, display_name, email, status)
|
||||
VALUES (:tenant_id, :user_id, :display_name, :email, 'active')"
|
||||
)->execute(['tenant_id' => $tenantId, 'user_id' => $ownerUserId, 'display_name' => 'Demo-Owner', 'email' => 'owner@demo.kaffeekasse']);
|
||||
|
||||
$memberMemberIdStmt = $pdo->prepare(
|
||||
"INSERT INTO members (tenant_id, user_id, display_name, email, status)
|
||||
VALUES (:tenant_id, :user_id, :display_name, :email, 'active')"
|
||||
);
|
||||
$memberMemberIdStmt->execute(['tenant_id' => $tenantId, 'user_id' => $memberUserId, 'display_name' => 'Demo-Member', 'email' => 'member@demo.kaffeekasse']);
|
||||
$memberMemberId = (int) $pdo->lastInsertId();
|
||||
|
||||
// Capture Sources
|
||||
foreach ([
|
||||
['digital_self', 'Digitale Selbstbuchung', 'digital'],
|
||||
['paper_sheet', 'Papierliste / Nacherfassung', 'paper'],
|
||||
['admin_backoffice', 'Backoffice-Buchung', 'admin'],
|
||||
['rfid_reader', 'RFID-Geraet', 'rfid'],
|
||||
] as [$code, $sourceName, $channel]) {
|
||||
$pdo->prepare(
|
||||
"INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
|
||||
VALUES (:tenant_id, :code, :name, :channel, 1)"
|
||||
)->execute(['tenant_id' => $tenantId, 'code' => $code, 'name' => $sourceName, 'channel' => $channel]);
|
||||
}
|
||||
|
||||
// Products
|
||||
foreach ([
|
||||
['Kaffee', 120],
|
||||
['Espresso', 120],
|
||||
['Tee', 100],
|
||||
] as [$productName, $priceCents]) {
|
||||
$pdo->prepare(
|
||||
"INSERT INTO products (tenant_id, name, sku, is_active)
|
||||
VALUES (:tenant_id, :name, :sku, 1)"
|
||||
)->execute(['tenant_id' => $tenantId, 'name' => $productName, 'sku' => strtolower(str_replace(' ', '-', $productName))]);
|
||||
$productId = (int) $pdo->lastInsertId();
|
||||
|
||||
$pdo->prepare(
|
||||
"INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
||||
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)"
|
||||
)->execute(['tenant_id' => $tenantId, 'product_id' => $productId, 'price_cents' => $priceCents, 'valid_from' => $now]);
|
||||
}
|
||||
|
||||
echo "[Test-Bootstrap] Demo-Tenant 'demo' angelegt\n";
|
||||
echo " Owner-Login: owner@demo.kaffeekasse / owner12345678\n";
|
||||
echo " Member-Login: member@demo.kaffeekasse / member12345678\n";
|
||||
echo " Tenant-Pfad: /t/demo\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "[Test-Bootstrap] Fehler: " . $e->getMessage() . "\n";
|
||||
}
|
||||
|
||||
$view = new View($rootPath, [
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
'csrf' => $csrf,
|
||||
]);
|
||||
|
||||
return [
|
||||
'rootPath' => $rootPath,
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
'csrf' => $csrf,
|
||||
'database' => $database,
|
||||
'view' => $view,
|
||||
];
|
||||
Reference in New Issue
Block a user