diff --git a/.env.example b/.env.example index 1f46c90..93cbad8 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ APP_NAME="Kaffeekasse SaaS" APP_ENV=production APP_DEBUG=0 APP_URL=http://localhost:8080 +# APP_BASE_PATH: Leer für direkten Zugriff, /proxy/8080 für code-server Proxy +APP_BASE_PATH= APP_TIMEZONE=Europe/Berlin APP_KEY= ALLOW_PUBLIC_REGISTRATION=0 diff --git a/.env.netcup b/.env.netcup new file mode 100644 index 0000000..cc0c15b --- /dev/null +++ b/.env.netcup @@ -0,0 +1,41 @@ +APP_NAME="Kaffeekasse SaaS" +APP_ENV=production +APP_DEBUG=0 +APP_URL=https://your-domain.tld +# APP_BASE_PATH: Leer für direkten Zugriff auf Netcup +APP_BASE_PATH= +APP_TIMEZONE=Europe/Berlin +APP_KEY=your-32-character-app-key-here +ALLOW_PUBLIC_REGISTRATION=0 +RFID_INGEST_ENABLED=0 + +# Netcup MySQL Konfiguration +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=your_database_name +DB_USER=your_database_user +DB_PASS=your_database_password + +# Mail-Konfiguration für Netcup +MAIL_FROM=noreply@your-domain.tld +MAIL_REPLY_TO=support@your-domain.tld +MAIL_SUBJECT_PREFIX="[Kaffeekasse] " +MAIL_LOG_FALLBACK=1 +MAIL_LOG_FILE=storage/logs/mail.log + +# Password Reset URLs für Netcup +PASSWORD_RESET_URL=https://your-domain.tld/reset-password?token={{token}} +PASSWORD_RESET_TTL_MINUTES=60 +PASSWORD_RESET_MAX_ACTIVE_TOKENS=3 +PASSWORD_RESET_SUBJECT="Passwort zurücksetzen" + +# Rate Limiting +RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5 +RATE_LIMIT_LOGIN_WINDOW_SECONDS=900 +RATE_LIMIT_LOGIN_BLOCK_SECONDS=900 +RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3 +RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600 +RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600 + +# RFID Secret (ändern Sie diesen Wert!) +RFID_SHARED_SECRET=your-secure-rfid-secret-here \ No newline at end of file diff --git a/PROXY-SETUP.md b/PROXY-SETUP.md new file mode 100644 index 0000000..dd42918 --- /dev/null +++ b/PROXY-SETUP.md @@ -0,0 +1,186 @@ +# Quick Start: Proxy & Webspace Setup + +## 🚀 Schnellstart für Code-Server Proxy + +```bash +# 1. .env erstellen +cp .env.example .env + +# 2. .env anpassen +nano .env +# Setzen Sie: APP_BASE_PATH=/proxy/8080 + +# 3. Server starten +php -S 0.0.0.0:8080 -t public public/router.php + +# 4. Zugriff über Browser +# https://ihr-code-server.de/proxy/8080/ +``` + +## 🌐 Schnellstart für Netcup Webspace + +```bash +# 1. Dateien hochladen (FTP/SFTP) +# Document Root → /public/ + +# 2. .env erstellen +cp .env.example .env + +# 3. .env anpassen +nano .env +# Setzen Sie: +# APP_URL=https://ihre-domain.de +# APP_BASE_PATH= +# DB_HOST=localhost +# DB_NAME=ihre_datenbank +# DB_USER=ihr_benutzer +# DB_PASS=ihr_passwort + +# 4. Rechte setzen +chmod 755 storage/ storage/cache/ storage/logs/ storage/uploads/ + +# 5. Installation +# Besuchen Sie: https://ihre-domain.de/install +``` + +## ✅ Funktioniert automatisch + +Die Anwendung erkennt automatisch: +- ✅ Code-Server Proxy (via `X-Forwarded-Prefix` Header) +- ✅ Subdirectory-Installationen (via `APP_BASE_PATH`) +- ✅ HTTPS hinter Reverse Proxy (via `X-Forwarded-Proto`) +- ✅ Alle URLs werden dynamisch generiert + +## 📖 Detaillierte Dokumentation + +Siehe: `docs/deployment-proxy-guide.md` + +## 🔧 Konfigurationsoptionen + +### .env Variablen für Proxy-Setup + +```env +# Für Code-Server Proxy +APP_BASE_PATH=/proxy/8080 + +# Für Subdirectory (z.B. https://example.com/kaffeekasse/) +APP_BASE_PATH=/kaffeekasse + +# Für Root-Installation (Standard) +APP_BASE_PATH= +``` + +### Manuelle Proxy-Prefix Überschreibung + +Falls automatische Erkennung nicht funktioniert: + +```bash +# Apache .htaccess +SetEnv KAFFEEKASSE_PROXY_PREFIX /proxy/8080 + +# Nginx +fastcgi_param KAFFEEKASSE_PROXY_PREFIX /proxy/8080; + +# PHP Development Server +KAFFEEKASSE_PROXY_PREFIX=/proxy/8080 php -S 0.0.0.0:8080 -t public public/router.php +``` + +## 🧪 Testen + +```bash +# Base Path prüfen +php -r "require 'app/Support/helpers.php'; var_dump(base_path());" + +# URL-Generierung testen +php -r " +require 'app/Support/helpers.php'; +\$_ENV['APP_BASE_PATH'] = '/proxy/8080'; +echo url('/admin/login') . PHP_EOL; +echo tenant_url('test', 'bookings') . PHP_EOL; +echo asset_url('app.css') . PHP_EOL; +" +``` + +## 🐛 Troubleshooting + +### URLs sind falsch + +```bash +# Debug-Ausgabe in public/index.php einfügen (temporär): +var_dump([ + 'base_path' => base_path(), + 'current_path' => current_path(), + 'REQUEST_URI' => $_SERVER['REQUEST_URI'] ?? null, + 'X-Forwarded-Prefix' => $_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null, +]); +exit; +``` + +### CSS lädt nicht + +```env +# In .env explizit setzen: +APP_BASE_PATH=/proxy/8080 +``` + +### Sessions funktionieren nicht + +```bash +# Prüfen Sie Verzeichnisrechte: +chmod 755 storage/ +ls -la storage/ +``` + +## 📋 Checkliste + +### Code-Server Proxy +- [ ] `.env` erstellt und `APP_BASE_PATH=/proxy/8080` gesetzt +- [ ] PHP Server gestartet: `php -S 0.0.0.0:8080 -t public public/router.php` +- [ ] Zugriff über `/proxy/8080/` funktioniert +- [ ] CSS und Assets werden geladen +- [ ] Login funktioniert + +### Netcup Webspace +- [ ] Dateien hochgeladen (Document Root → `/public/`) +- [ ] `.env` erstellt und konfiguriert +- [ ] `APP_BASE_PATH=` (leer gelassen) +- [ ] Datenbank-Zugangsdaten eingetragen +- [ ] Verzeichnisrechte gesetzt (755 für storage/) +- [ ] Installation unter `/install` durchgeführt +- [ ] HTTPS funktioniert +- [ ] Login funktioniert + +## 💡 Tipps + +### Entwicklung mit Code-Server +```bash +# Screen-Session für dauerhaften Betrieb +screen -dmS kaffeekasse bash -c 'cd /config/workspace/kaffeeliste-neustart && php -S 0.0.0.0:8080 -t public public/router.php' + +# Session wieder verbinden +screen -r kaffeekasse + +# Session beenden +screen -X -S kaffeekasse quit +``` + +### Production auf Netcup +```apache +# HTTPS erzwingen in public/.htaccess +RewriteCond %{HTTPS} off +RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] +``` + +### SQLite für Entwicklung +```env +# Einfacher für lokale Entwicklung +DB_HOST=sqlite +DB_NAME=/config/workspace/kaffeeliste-neustart/storage/database.sqlite +``` + +## 🔗 Weitere Ressourcen + +- **Vollständige Dokumentation**: `docs/deployment-proxy-guide.md` +- **Deployment Guide**: `DEPLOY.md` +- **Runbook**: `RUNBOOK.md` +- **Netcup Checkliste**: `docs/go-live-checklist-netcup.md` diff --git a/README.md b/README.md index 30bc1e2..04719e6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,31 @@ Organisation, waehrend Produktlogik, Betrieb und Vermarktung zentral bleiben. 3. Webroot auf `public/` zeigen lassen. 4. Plattform-Admin anlegen und anschliessend Mandanten registrieren. +## Testumgebung im code-server + +Fuer die SQLite-basierte Testumgebung kann der eingebaute PHP-Server genutzt werden: + +```bash +php -S 0.0.0.0:8080 public/router_test.php +``` + +Der Test-Router unterstuetzt code-server-Proxy-Pfade wie `/proxy/8080` und +`/absproxy/8080`. Links, Form-Actions und Assets werden ueber zentrale Helper +erzeugt, damit CSS und JavaScript auch hinter einem Pfad-Prefix geladen werden. +Wenn ein Proxy den Prefix nicht als Header durchreicht, bleiben die erzeugten +URLs relativ und funktionieren weiterhin innerhalb des aktuellen Proxy-Pfads. + +Optional kann ein fester Pfad-Prefix gesetzt werden, z.B. in `.env.test`: + +```dotenv +APP_BASE_PATH=/proxy/8080 +``` + +Ein einheitlicher Hosteintrag ist dafuer nicht noetig. Zeitgemaesser ist eine +Basis-Pfad-/Forwarded-Prefix-Unterstuetzung, weil Entwicklungsumgebungen wie +code-server Anwendungen haeufig unter einem Unterpfad statt unter einem eigenen +Host ausliefern. + ## Wichtige Dateien - [public/index.php](/config/workspace/kaffeeliste-neustart/public/index.php) diff --git a/app/Controllers/PlatformController.php b/app/Controllers/PlatformController.php index d4549e0..1c872f4 100644 --- a/app/Controllers/PlatformController.php +++ b/app/Controllers/PlatformController.php @@ -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 diff --git a/app/Controllers/TenantController.php b/app/Controllers/TenantController.php index c7d78b0..7bfd919 100644 --- a/app/Controllers/TenantController.php +++ b/app/Controllers/TenantController.php @@ -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, diff --git a/app/Core/Auth.php b/app/Core/Auth.php index bd11f89..8fb5bb8 100644 --- a/app/Core/Auth.php +++ b/app/Core/Auth.php @@ -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'], diff --git a/app/Core/Database.php b/app/Core/Database.php index d0295af..add5a49 100644 --- a/app/Core/Database.php +++ b/app/Core/Database.php @@ -5,7 +5,7 @@ namespace App\Core; use PDO; use PDOException; -final class Database +final class Database implements DatabaseInterface { private ?PDO $pdo = null; diff --git a/app/Core/DatabaseInterface.php b/app/Core/DatabaseInterface.php new file mode 100644 index 0000000..d8e3bcb --- /dev/null +++ b/app/Core/DatabaseInterface.php @@ -0,0 +1,17 @@ +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; + } +} \ No newline at end of file diff --git a/app/Core/Response.php b/app/Core/Response.php index 0b49fd1..81176bb 100644 --- a/app/Core/Response.php +++ b/app/Core/Response.php @@ -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; } diff --git a/app/Services/RateLimiterService.php b/app/Services/RateLimiterService.php index d495e3a..10584a1 100644 --- a/app/Services/RateLimiterService.php +++ b/app/Services/RateLimiterService.php @@ -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); diff --git a/app/Services/RateLimiterSqliteService.php b/app/Services/RateLimiterSqliteService.php new file mode 100644 index 0000000..a7f440c --- /dev/null +++ b/app/Services/RateLimiterSqliteService.php @@ -0,0 +1,271 @@ +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(); + } +} \ No newline at end of file diff --git a/app/Services/SetupService.php b/app/Services/SetupService.php index 331ac2f..1710aa1 100644 --- a/app/Services/SetupService.php +++ b/app/Services/SetupService.php @@ -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; } diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 8c9f630..ef3e79f 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -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, '/'); diff --git a/app/bootstrap.php b/app/bootstrap.php index 12115da..551638d 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -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, diff --git a/app/bootstrap_test.php b/app/bootstrap_test.php new file mode 100644 index 0000000..6628bcf --- /dev/null +++ b/app/bootstrap_test.php @@ -0,0 +1,202 @@ +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, +]; \ No newline at end of file diff --git a/database/schema.sqlite.sql b/database/schema.sqlite.sql new file mode 100644 index 0000000..c2f3e42 --- /dev/null +++ b/database/schema.sqlite.sql @@ -0,0 +1,278 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + full_name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + platform_role TEXT NOT NULL DEFAULT 'user' CHECK(platform_role IN ('platform_admin', 'user')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + requested_ip_hash TEXT, + requested_user_agent_hash TEXT, + expires_at TEXT NOT NULL, + consumed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_password_reset_user_created ON password_reset_tokens (user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens (expires_at); + +CREATE TABLE IF NOT EXISTS rate_limits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action_key TEXT NOT NULL, + bucket_key TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + window_start TEXT NOT NULL, + last_attempt_at TEXT NOT NULL, + blocked_until TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (action_key, bucket_key) +); + +CREATE INDEX IF NOT EXISTS idx_rate_limit_blocked_until ON rate_limits (blocked_until); +CREATE INDEX IF NOT EXISTS idx_rate_limit_last_attempt ON rate_limits (last_attempt_at); + +CREATE TABLE IF NOT EXISTS tenants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + plan TEXT NOT NULL DEFAULT 'starter' CHECK(plan IN ('starter', 'team', 'business')), + status TEXT NOT NULL DEFAULT 'trial' CHECK(status IN ('trial', 'active', 'suspended')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS tenant_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + host TEXT NOT NULL UNIQUE, + is_primary INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tenant_memberships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('owner', 'admin', 'member', 'device')), + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'invited', 'disabled')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (tenant_id, user_id), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tenant_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL UNIQUE, + currency_code TEXT NOT NULL DEFAULT 'EUR', + default_product_price_cents INTEGER NOT NULL DEFAULT 120, + allow_self_service INTEGER NOT NULL DEFAULT 1, + allow_paper_lists INTEGER NOT NULL DEFAULT 1, + allow_rfid INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + user_id INTEGER, + display_name TEXT NOT NULL, + email TEXT, + access_pin TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'archived')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (tenant_id, email), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + sku TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS product_prices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + price_cents INTEGER NOT NULL, + valid_from TEXT NOT NULL, + valid_until TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS capture_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + code TEXT NOT NULL, + name TEXT NOT NULL, + channel TEXT NOT NULL CHECK(channel IN ('paper', 'digital', 'admin', 'rfid')), + is_system INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (tenant_id, code), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS paper_sheets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + title TEXT NOT NULL, + period_label TEXT, + period_start TEXT, + period_end TEXT, + status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'posted')), + notes TEXT, + created_by_user_id INTEGER, + posted_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS paper_sheet_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_sheet_id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + member_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + quantity REAL NOT NULL DEFAULT 1.00, + effective_at TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (paper_sheet_id) REFERENCES paper_sheets (id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS consumption_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + member_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + source_id INTEGER NOT NULL, + paper_sheet_line_id INTEGER, + quantity REAL NOT NULL DEFAULT 1.00, + unit_price_cents INTEGER NOT NULL, + total_cents INTEGER NOT NULL, + effective_at TEXT NOT NULL, + recorded_at TEXT NOT NULL, + recorded_by_user_id INTEGER, + source_reference TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE CASCADE, + FOREIGN KEY (paper_sheet_line_id) REFERENCES paper_sheet_lines (id) ON DELETE SET NULL, + FOREIGN KEY (recorded_by_user_id) REFERENCES users (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + member_id INTEGER, + source_id INTEGER, + type TEXT NOT NULL CHECK(type IN ('consumption', 'payment', 'correction', 'credit', 'opening_balance')), + reference_type TEXT, + reference_id INTEGER, + description TEXT NOT NULL, + occurred_at TEXT NOT NULL, + created_by_user_id INTEGER, + reversal_of_entry_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL, + FOREIGN KEY (source_id) REFERENCES capture_sources (id) ON DELETE SET NULL, + FOREIGN KEY (created_by_user_id) REFERENCES users (id) ON DELETE SET NULL, + FOREIGN KEY (reversal_of_entry_id) REFERENCES ledger_entries (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS ledger_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ledger_entry_id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + member_id INTEGER, + account_code TEXT NOT NULL CHECK(account_code IN ('member_balance', 'cashbox', 'coffee_revenue')), + amount_cents INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (ledger_entry_id) REFERENCES ledger_entries (id) ON DELETE CASCADE, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS rfid_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + location_label TEXT, + device_token TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'planned' CHECK(status IN ('planned', 'active', 'disabled')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS rfid_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + member_id INTEGER, + uid_hash TEXT NOT NULL, + label TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'lost', 'retired')), + last_seen_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (tenant_id, uid_hash), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (member_id) REFERENCES members (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS rfid_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL, + device_id INTEGER, + external_event_id TEXT, + uid_hash TEXT NOT NULL, + payload_json TEXT, + status TEXT NOT NULL DEFAULT 'received' CHECK(status IN ('received', 'linked', 'ignored', 'error')), + received_at TEXT NOT NULL, + processed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (tenant_id, external_event_id), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE CASCADE, + FOREIGN KEY (device_id) REFERENCES rfid_devices (id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER, + actor_user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id INTEGER, + result TEXT NOT NULL CHECK(result IN ('success', 'failure')), + request_id TEXT NOT NULL, + ip_hash TEXT, + user_agent_hash TEXT, + meta_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE SET NULL, + FOREIGN KEY (actor_user_id) REFERENCES users (id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_tenant_created ON audit_events (tenant_id, created_at); \ No newline at end of file diff --git a/debug_pdo.php b/debug_pdo.php new file mode 100644 index 0000000..6c04d98 --- /dev/null +++ b/debug_pdo.php @@ -0,0 +1,50 @@ +PDO Debug Information"; + +echo "
" . implode(', ', PDO::getAvailableDrivers()) . "";
+
+echo "";
+$extensions = get_loaded_extensions();
+sort($extensions);
+foreach ($extensions as $ext) {
+ if (strpos(strtolower($ext), 'pdo') !== false || strpos(strtolower($ext), 'mysql') !== false) {
+ echo "✓ " . $ext . "\n";
+ }
+}
+echo "";
+
+echo "✓ MySQL connection successful"; +} catch (Exception $e) { + echo "
✗ MySQL connection failed: " . htmlspecialchars($e->getMessage()) . ""; +} + +echo "
"; +echo "DB_HOST: " . ($_ENV['DB_HOST'] ?? 'not set') . "\n"; +echo "DB_PORT: " . ($_ENV['DB_PORT'] ?? 'not set') . "\n"; +echo "DB_NAME: " . ($_ENV['DB_NAME'] ?? 'not set') . "\n"; +echo "DB_USER: " . ($_ENV['DB_USER'] ?? 'not set') . "\n"; +echo ""; + +echo "
" . phpversion() . ""; + +echo "
PDO section not found in phpinfo"; +} \ No newline at end of file diff --git a/docs/deployment-proxy-guide.md b/docs/deployment-proxy-guide.md new file mode 100644 index 0000000..c748bc6 --- /dev/null +++ b/docs/deployment-proxy-guide.md @@ -0,0 +1,322 @@ +# Deployment Guide: Netcup Webspace & Code-Server Proxy + +Diese Anleitung erklärt, wie die Kaffeekasse-SaaS-Anwendung sowohl auf einem Netcup Webspace als auch über code-server Proxy-Weiterleitung betrieben werden kann. + +## Übersicht + +Die Anwendung ist so entwickelt, dass sie automatisch erkennt, ob sie: +1. **Direkt** auf einem Webspace läuft (z.B. `https://example.com/`) +2. **Hinter einem Reverse Proxy** läuft (z.B. `https://code-server.example.com/proxy/8080/`) + +## 1. Deployment auf Netcup Webspace + +### Voraussetzungen +- PHP 8.0 oder höher +- MySQL/MariaDB Datenbank +- Apache mit mod_rewrite aktiviert +- HTTPS-Zertifikat (Let's Encrypt empfohlen) + +### Schritte + +1. **Dateien hochladen** + ```bash + # Via FTP/SFTP alle Dateien hochladen + # Document Root sollte auf /public/ zeigen + ``` + +2. **.env Datei konfigurieren** + ```bash + cp .env.example .env + nano .env + ``` + + Wichtige Einstellungen: + ```env + APP_URL=https://ihre-domain.de + APP_BASE_PATH= + + DB_HOST=localhost + DB_NAME=ihre_datenbank + DB_USER=ihr_benutzer + DB_PASS=ihr_passwort + + MAIL_FROM=noreply@ihre-domain.de + PASSWORD_RESET_URL=https://ihre-domain.de/reset-password?token={{token}} + ``` + +3. **Verzeichnisrechte setzen** + ```bash + chmod 755 storage/ + chmod 755 storage/cache/ + chmod 755 storage/logs/ + chmod 755 storage/uploads/ + ``` + +4. **Installation durchführen** + - Besuchen Sie `https://ihre-domain.de/install` + - Folgen Sie dem Setup-Assistenten + +### Apache Virtual Host Konfiguration (falls nötig) + +```apache +
' . e($config['debug'] ? $throwable->getMessage() : 'Die Anfrage konnte gerade nicht verarbeitet werden.') . '
'; +} \ No newline at end of file diff --git a/public/router_test.php b/public/router_test.php new file mode 100644 index 0000000..80f13fd --- /dev/null +++ b/public/router_test.php @@ -0,0 +1,48 @@ + 'text/css; charset=UTF-8', + 'js' => 'application/javascript; charset=UTF-8', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + ]; + if (isset($mimeTypes[$ext])) { + header('Content-Type: ' . $mimeTypes[$ext]); + } + return false; +} + +require __DIR__ . '/index_test.php'; diff --git a/resources/views/admin/index.php b/resources/views/admin/index.php index 758fdd5..b485728 100644 --- a/resources/views/admin/index.php +++ b/resources/views/admin/index.php @@ -6,7 +6,7 @@Ueberblick ueber Mandanten, Paketstand und direkte Einstiege in die Kundenumgebungen.
- diff --git a/resources/views/admin/login.php b/resources/views/admin/login.php index 1e17f7f..cdbcaec 100644 --- a/resources/views/admin/login.php +++ b/resources/views/admin/login.php @@ -1,5 +1,5 @@ @@ -10,7 +10,7 @@ require __DIR__ . '/../partials/layout-top.php';