weitere Bearibeitung

This commit is contained in:
2026-06-17 16:45:14 +02:00
parent 06645f1e9c
commit 1891ec0a51
38 changed files with 2720 additions and 109 deletions
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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'],
+1 -1
View File
@@ -5,7 +5,7 @@ namespace App\Core;
use PDO;
use PDOException;
final class Database
final class Database implements DatabaseInterface
{
private ?PDO $pdo = null;
+17
View File
@@ -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;
}
+123
View File
@@ -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;
}
}
+9
View File
@@ -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;
}
+65 -31
View File
@@ -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);
+271
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+202
View File
@@ -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,
];