renew
This commit is contained in:
@@ -1,459 +0,0 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use App\Core\View;
|
||||
use App\Services\AuditService;
|
||||
use App\Services\MailerService;
|
||||
use App\Services\PasswordResetService;
|
||||
use App\Services\RateLimiterService;
|
||||
use App\Services\SetupService;
|
||||
use App\Services\TenantRegistrationService;
|
||||
use RuntimeException;
|
||||
|
||||
final class PlatformController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
private readonly array $config,
|
||||
private readonly DatabaseInterface $database,
|
||||
private readonly View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf
|
||||
) {
|
||||
}
|
||||
|
||||
public function home(): void
|
||||
{
|
||||
$setup = new SetupService($this->rootPath);
|
||||
$stats = null;
|
||||
|
||||
if ($setup->isInstalled() && $this->database->isConfigured()) {
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$stats = [
|
||||
'tenants' => (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn(),
|
||||
'members' => (int) $pdo->query('SELECT COUNT(*) FROM members')->fetchColumn(),
|
||||
'events' => (int) $pdo->query('SELECT COUNT(*) FROM consumption_events')->fetchColumn(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
$stats = null;
|
||||
}
|
||||
}
|
||||
|
||||
$this->view->render('home/index', [
|
||||
'title' => 'Kaffeeliste für Teams, Vereine und Büroküchen',
|
||||
'setupComplete' => $setup->isInstalled(),
|
||||
'stats' => $stats,
|
||||
'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function installForm(): void
|
||||
{
|
||||
$setup = new SetupService($this->rootPath);
|
||||
|
||||
if ($setup->isInstalled()) {
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
$this->view->render('install/index', [
|
||||
'title' => 'Installation',
|
||||
]);
|
||||
}
|
||||
|
||||
public function installSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
$setup = new SetupService($this->rootPath);
|
||||
|
||||
try {
|
||||
$setup->install($_POST);
|
||||
$this->session->flash('success', 'Die Installation wurde abgeschlossen. Sie können sich jetzt als Plattform-Administrator anmelden.');
|
||||
Response::redirect('/admin/login');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/install');
|
||||
}
|
||||
}
|
||||
|
||||
public function registerForm(): void
|
||||
{
|
||||
if (!(new SetupService($this->rootPath))->isInstalled()) {
|
||||
Response::redirect('/install');
|
||||
}
|
||||
|
||||
if (!$this->allowsPublicRegistration()) {
|
||||
$this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Bitte kontaktieren Sie den Betreiber.');
|
||||
Response::redirect('/');
|
||||
}
|
||||
|
||||
$this->view->render('home/register', [
|
||||
'title' => 'Kaffeeliste registrieren',
|
||||
]);
|
||||
}
|
||||
|
||||
public function registerSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
if (!$this->allowsPublicRegistration()) {
|
||||
throw new RuntimeException('Die offene Registrierung ist derzeit deaktiviert.');
|
||||
}
|
||||
|
||||
$pdo = $this->database->pdo();
|
||||
$rateLimiter = $this->rateLimiter($pdo);
|
||||
$email = mb_strtolower(trim((string) ($_POST['owner_email'] ?? '')));
|
||||
$scopes = $this->buildScopes('registration', $email);
|
||||
|
||||
$this->ensureAllowed($rateLimiter, 'registration', $scopes);
|
||||
|
||||
$audit = new AuditService($pdo);
|
||||
$service = new TenantRegistrationService($pdo, $audit);
|
||||
$slug = $service->register($_POST);
|
||||
$this->clearScopes($rateLimiter, 'registration', $scopes);
|
||||
$this->session->flash('success', 'Die Kaffeeliste wurde angelegt. Sie können sich jetzt anmelden.');
|
||||
Response::redirect('/t/' . rawurlencode($slug) . '/login');
|
||||
} catch (\Throwable $throwable) {
|
||||
if ($this->database->isConfigured()) {
|
||||
try {
|
||||
$this->registerFailure('registration', mb_strtolower(trim((string) ($_POST['owner_email'] ?? ''))));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/register');
|
||||
}
|
||||
}
|
||||
|
||||
public function adminLoginForm(): void
|
||||
{
|
||||
$this->view->render('admin/login', [
|
||||
'title' => 'Kaffeelisten verwalten',
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminLoginSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$email = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
|
||||
$rateLimiter = $this->rateLimiter($pdo);
|
||||
$scopes = $this->buildScopes('login', 'platform|' . $email);
|
||||
|
||||
$this->ensureAllowed($rateLimiter, 'login', $scopes);
|
||||
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$audit = new AuditService($pdo);
|
||||
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
|
||||
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
$this->hitScopes($rateLimiter, 'login', $scopes);
|
||||
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||
}
|
||||
|
||||
$this->clearScopes($rateLimiter, 'login', $scopes);
|
||||
$this->session->flash('success', 'Willkommen im Plattformbereich.');
|
||||
Response::redirect('/admin');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
}
|
||||
|
||||
public function passwordResetRequestForm(): void
|
||||
{
|
||||
$scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
|
||||
$tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
|
||||
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||
|
||||
$this->view->render('auth/reset-request', [
|
||||
'title' => 'Passwort vergessen',
|
||||
'scope' => $scope,
|
||||
'tenantSlug' => $tenantSlug,
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
}
|
||||
|
||||
public function passwordResetRequestSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||
$service = new PasswordResetService(
|
||||
$pdo,
|
||||
new MailerService($this->config['mail'] ?? []),
|
||||
$this->config['password_reset'] ?? []
|
||||
);
|
||||
$service->requestReset((string) ($_POST['email'] ?? ''), $this->passwordResetContext($scope, $tenant, $tenantSlug));
|
||||
$this->session->flash('success', 'Falls ein Konto existiert, wurde ein Link zum Zuruecksetzen versendet.');
|
||||
Response::redirect($this->passwordResetRequestUrl($scope, $tenant, $tenantSlug));
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||
Response::redirect($this->passwordResetRequestUrl($scope, null, $tenantSlug));
|
||||
}
|
||||
}
|
||||
|
||||
public function passwordResetConfirmForm(): void
|
||||
{
|
||||
$scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
|
||||
$tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
|
||||
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||
$token = (string) ($_GET['token'] ?? '');
|
||||
$resetToken = null;
|
||||
|
||||
if ($this->database->isConfigured() && trim($token) !== '') {
|
||||
try {
|
||||
$resetToken = $this->passwordResetService($this->database->pdo())->findValidToken($token);
|
||||
} catch (\Throwable) {
|
||||
$resetToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
$this->view->render('auth/reset-confirm', [
|
||||
'title' => 'Neues Passwort setzen',
|
||||
'scope' => $scope,
|
||||
'tenantSlug' => $tenantSlug,
|
||||
'tenant' => $tenant,
|
||||
'token' => $token,
|
||||
'resetToken' => $resetToken,
|
||||
]);
|
||||
}
|
||||
|
||||
public function passwordResetConfirmSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||
$tenant = $this->passwordResetTenant($scope, $tenantSlug);
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
$passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
|
||||
|
||||
if ($password !== $passwordConfirm) {
|
||||
throw new RuntimeException('Die Passwort-Bestätigung stimmt nicht überein.');
|
||||
}
|
||||
|
||||
$pdo = $this->database->pdo();
|
||||
$service = $this->passwordResetService($pdo);
|
||||
$userId = $service->resetPassword(
|
||||
(string) ($_POST['token'] ?? ''),
|
||||
$password
|
||||
);
|
||||
|
||||
(new AuditService($pdo))->log(null, $userId, 'password.reset', 'user', $userId, 'success');
|
||||
|
||||
$this->session->flash('success', 'Das Passwort wurde aktualisiert. Du kannst dich jetzt anmelden.');
|
||||
Response::redirect($this->passwordResetLoginUrl($scope, $tenant, $tenantSlug));
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
$token = trim((string) ($_POST['token'] ?? ''));
|
||||
$scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
|
||||
$tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
|
||||
Response::redirect($this->passwordResetConfirmUrl($token, $scope, null, $tenantSlug));
|
||||
}
|
||||
}
|
||||
|
||||
public function adminDashboard(): void
|
||||
{
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
|
||||
if (!$auth->checkPlatformAdmin()) {
|
||||
$this->session->flash('error', 'Bitte zuerst als Plattform-Admin anmelden.');
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
$tenants = $pdo->query(
|
||||
'SELECT t.*,
|
||||
COUNT(DISTINCT m.id) AS member_count,
|
||||
COUNT(DISTINCT p.id) AS product_count
|
||||
FROM tenants t
|
||||
LEFT JOIN members m ON m.tenant_id = t.id
|
||||
LEFT JOIN products p ON p.tenant_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.created_at DESC'
|
||||
)->fetchAll();
|
||||
|
||||
$this->view->render('admin/index', [
|
||||
'title' => 'Plattform-Dashboard',
|
||||
'tenants' => $tenants,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminLogout(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$auth->logout();
|
||||
$this->session->flash('success', 'Du wurdest aus dem Plattformbereich abgemeldet.');
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
private function assertCsrf(): void
|
||||
{
|
||||
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
|
||||
}
|
||||
}
|
||||
|
||||
private function allowsPublicRegistration(): bool
|
||||
{
|
||||
return (bool) ($this->config['allow_public_registration'] ?? false);
|
||||
}
|
||||
|
||||
private function rateLimiter(\PDO $pdo): RateLimiterService
|
||||
{
|
||||
return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []);
|
||||
}
|
||||
|
||||
private function passwordResetService(\PDO $pdo): PasswordResetService
|
||||
{
|
||||
return new PasswordResetService(
|
||||
$pdo,
|
||||
new MailerService($this->config['mail'] ?? []),
|
||||
$this->config['password_reset'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
private function buildScopes(string $action, string $identifier = ''): array
|
||||
{
|
||||
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
|
||||
$scopes = [$action . '|ip|' . $ip];
|
||||
|
||||
if (trim($identifier) !== '') {
|
||||
$scopes[] = $action . '|subject|' . mb_strtolower(trim($identifier));
|
||||
}
|
||||
|
||||
return $scopes;
|
||||
}
|
||||
|
||||
private function ensureAllowed(RateLimiterService $rateLimiter, string $action, array $scopes): void
|
||||
{
|
||||
foreach ($scopes as $scope) {
|
||||
$state = $rateLimiter->inspect($action, $scope);
|
||||
|
||||
if (!$state['allowed']) {
|
||||
throw new RuntimeException(
|
||||
'Zu viele Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function hitScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
|
||||
{
|
||||
foreach ($scopes as $scope) {
|
||||
$rateLimiter->hit($action, $scope);
|
||||
}
|
||||
}
|
||||
|
||||
private function clearScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
|
||||
{
|
||||
foreach ($scopes as $scope) {
|
||||
$rateLimiter->clear($action, $scope);
|
||||
}
|
||||
}
|
||||
|
||||
private function registerFailure(string $action, string $identifier = ''): void
|
||||
{
|
||||
$pdo = $this->database->pdo();
|
||||
$rateLimiter = $this->rateLimiter($pdo);
|
||||
|
||||
foreach ($this->buildScopes($action, $identifier) as $scope) {
|
||||
$rateLimiter->hit($action, $scope);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePasswordResetScope(string $scope): string
|
||||
{
|
||||
return trim($scope) === 'tenant' ? 'tenant' : 'platform';
|
||||
}
|
||||
|
||||
private function passwordResetTenant(string $scope, string $tenantSlug): ?array
|
||||
{
|
||||
$tenantSlug = trim($tenantSlug);
|
||||
|
||||
if ($scope !== 'tenant' || $tenantSlug === '' || !$this->database->isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = (new TenantContext($this->database->pdo()))->bySlug($tenantSlug);
|
||||
|
||||
return is_array($tenant) ? $tenant : null;
|
||||
}
|
||||
|
||||
private function passwordResetContext(string $scope, ?array $tenant, string $tenantSlug): array
|
||||
{
|
||||
$context = ['scope' => $scope];
|
||||
$resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
|
||||
|
||||
if ($scope === 'tenant' && is_string($resolvedSlug) && $resolvedSlug !== '') {
|
||||
$context['tenant'] = $resolvedSlug;
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
private function passwordResetRequestUrl(string $scope, ?array $tenant, string $tenantSlug): string
|
||||
{
|
||||
$query = http_build_query($this->passwordResetContext($scope, $tenant, $tenantSlug), '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
return '/password/forgot' . ($query !== '' ? '?' . $query : '');
|
||||
}
|
||||
|
||||
private function passwordResetConfirmUrl(string $token, string $scope, ?array $tenant, string $tenantSlug): string
|
||||
{
|
||||
$query = $this->passwordResetContext($scope, $tenant, $tenantSlug);
|
||||
|
||||
if (trim($token) !== '') {
|
||||
$query['token'] = $token;
|
||||
}
|
||||
|
||||
$encoded = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
return '/reset-password' . ($encoded !== '' ? '?' . $encoded : '');
|
||||
}
|
||||
|
||||
private function passwordResetLoginUrl(string $scope, ?array $tenant, string $tenantSlug): string
|
||||
{
|
||||
if ($scope === 'tenant') {
|
||||
$resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
|
||||
|
||||
if (is_string($resolvedSlug) && $resolvedSlug !== '') {
|
||||
return tenant_url($resolvedSlug, 'login');
|
||||
}
|
||||
}
|
||||
|
||||
return '/admin/login';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly Session $session
|
||||
) {
|
||||
}
|
||||
|
||||
public function attemptPlatformLogin(string $email, string $password): bool
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, password_hash, platform_role FROM users WHERE email = :email LIMIT 1'
|
||||
);
|
||||
$statement->execute(['email' => mb_strtolower($email)]);
|
||||
$user = $statement->fetch();
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($user['platform_role'] ?? 'user') !== 'platform_admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->rehashPasswordIfNeeded((int) $user['id'], (string) $user['password_hash'], $password);
|
||||
|
||||
$this->session->regenerate();
|
||||
$this->session->put('auth', [
|
||||
'user_id' => (int) $user['id'],
|
||||
'platform_role' => $user['platform_role'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function attemptTenantLogin(string $tenantSlug, string $email, string $password): bool
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT u.id,
|
||||
u.password_hash,
|
||||
m.tenant_id,
|
||||
m.role,
|
||||
t.slug AS tenant_slug,
|
||||
member.id AS member_id
|
||||
FROM users u
|
||||
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
|
||||
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
|
||||
LEFT JOIN members member
|
||||
ON member.user_id = u.id
|
||||
AND member.tenant_id = m.tenant_id
|
||||
AND member.status = "active"
|
||||
WHERE u.email = :email
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'slug' => $tenantSlug,
|
||||
'email' => mb_strtolower($email),
|
||||
]);
|
||||
$membership = $statement->fetch();
|
||||
|
||||
if (!$membership || !password_verify($password, $membership['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->rehashPasswordIfNeeded((int) $membership['user_id'], (string) $membership['password_hash'], $password);
|
||||
|
||||
$this->session->regenerate();
|
||||
$this->session->put('auth', [
|
||||
'user_id' => (int) $membership['user_id'],
|
||||
'tenant_id' => (int) $membership['tenant_id'],
|
||||
'tenant_slug' => $membership['tenant_slug'],
|
||||
'tenant_role' => $membership['role'],
|
||||
'member_id' => isset($membership['member_id']) ? (int) $membership['member_id'] : null,
|
||||
'platform_role' => 'user',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function user(): ?array
|
||||
{
|
||||
$auth = $this->session->get('auth');
|
||||
|
||||
return is_array($auth) ? $auth : null;
|
||||
}
|
||||
|
||||
public function checkPlatformAdmin(): bool
|
||||
{
|
||||
return ($this->user()['platform_role'] ?? null) === 'platform_admin';
|
||||
}
|
||||
|
||||
public function checkTenantAccess(string $tenantSlug): bool
|
||||
{
|
||||
$auth = $this->user();
|
||||
|
||||
return is_array($auth)
|
||||
&& isset($auth['tenant_slug'])
|
||||
&& hash_equals($auth['tenant_slug'], $tenantSlug);
|
||||
}
|
||||
|
||||
public function requireRole(array $allowedRoles): bool
|
||||
{
|
||||
$role = $this->user()['tenant_role'] ?? null;
|
||||
|
||||
return is_string($role) && in_array($role, $allowedRoles, true);
|
||||
}
|
||||
|
||||
public function tenantMemberId(): ?int
|
||||
{
|
||||
$memberId = $this->user()['member_id'] ?? null;
|
||||
|
||||
return is_int($memberId) ? $memberId : null;
|
||||
}
|
||||
|
||||
public function canManageTenant(): bool
|
||||
{
|
||||
$role = $this->user()['tenant_role'] ?? null;
|
||||
|
||||
return is_string($role) && in_array($role, ['owner', 'admin'], true);
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$this->session->invalidate();
|
||||
}
|
||||
|
||||
private function rehashPasswordIfNeeded(int $userId, string $currentHash, string $password): void
|
||||
{
|
||||
if (!password_needs_rehash($currentHash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE users SET password_hash = :password_hash WHERE id = :id'
|
||||
);
|
||||
$statement->execute([
|
||||
'password_hash' => secure_password_hash($password),
|
||||
'id' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Autoloader
|
||||
{
|
||||
public static function register(string $rootPath): void
|
||||
{
|
||||
spl_autoload_register(static function (string $class) use ($rootPath): void {
|
||||
$prefix = 'App\\';
|
||||
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = $rootPath . '/app/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Csrf
|
||||
{
|
||||
public function __construct(private readonly Session $session)
|
||||
{
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
$token = $this->session->get('_csrf_token');
|
||||
|
||||
if (!is_string($token) || $token === '') {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$this->session->put('_csrf_token', $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate(?string $token): bool
|
||||
{
|
||||
$expected = $this->session->get('_csrf_token');
|
||||
|
||||
return is_string($expected) && is_string($token) && hash_equals($expected, $token);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
final class Database implements DatabaseInterface
|
||||
{
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
public function __construct(private readonly array $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function pdo(): PDO
|
||||
{
|
||||
if ($this->pdo instanceof PDO) {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$this->config['host'],
|
||||
$this->config['port'],
|
||||
$this->config['name'],
|
||||
$this->config['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
$this->pdo = new PDO($dsn, $this->config['user'], $this->config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $exception) {
|
||||
throw new PDOException(
|
||||
'Database connection failed: ' . $exception->getMessage(),
|
||||
(int) $exception->getCode(),
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->config['name'] !== '' && $this->config['user'] !== '';
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Env
|
||||
{
|
||||
public static function load(string $file): void
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value] = array_pad(explode('=', $trimmed, 2), 2, '');
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if ($value !== '' && ($value[0] === '"' || $value[0] === '\'')) {
|
||||
$value = trim($value, "\"'");
|
||||
}
|
||||
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public static function notFound(string $message = 'Seite nicht gefunden.'): void
|
||||
{
|
||||
http_response_code(404);
|
||||
echo $message;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Session
|
||||
{
|
||||
public function start(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||
'path' => '/',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_SESSION[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function put(string $key, mixed $value): void
|
||||
{
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public function forget(string $key): void
|
||||
{
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
public function flash(string $key, mixed $value): void
|
||||
{
|
||||
$_SESSION['_flash'][$key] = $value;
|
||||
}
|
||||
|
||||
public function pullFlash(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = $_SESSION['_flash'][$key] ?? $default;
|
||||
unset($_SESSION['_flash'][$key]);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function regenerate(): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class TenantContext
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function bySlug(string $slug): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT * FROM tenants WHERE slug = :slug LIMIT 1'
|
||||
);
|
||||
$statement->execute(['slug' => $slug]);
|
||||
$tenant = $statement->fetch();
|
||||
|
||||
return $tenant ?: null;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class View
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
private readonly array $shared = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function render(string $template, array $data = []): void
|
||||
{
|
||||
$templateFile = $this->rootPath . '/resources/views/' . $template . '.php';
|
||||
|
||||
if (!is_file($templateFile)) {
|
||||
http_response_code(500);
|
||||
echo 'View not found: ' . htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
||||
return;
|
||||
}
|
||||
|
||||
$shared = $this->shared;
|
||||
|
||||
extract($shared, EXTR_SKIP);
|
||||
extract($data, EXTR_SKIP);
|
||||
|
||||
require $templateFile;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AuditService
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function log(
|
||||
?int $tenantId,
|
||||
?int $actorUserId,
|
||||
string $action,
|
||||
string $targetType,
|
||||
?int $targetId,
|
||||
string $result,
|
||||
array $meta = []
|
||||
): void {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO audit_events (
|
||||
tenant_id,
|
||||
actor_user_id,
|
||||
action,
|
||||
target_type,
|
||||
target_id,
|
||||
result,
|
||||
request_id,
|
||||
ip_hash,
|
||||
user_agent_hash,
|
||||
meta_json
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:actor_user_id,
|
||||
:action,
|
||||
:target_type,
|
||||
:target_id,
|
||||
:result,
|
||||
:request_id,
|
||||
:ip_hash,
|
||||
:user_agent_hash,
|
||||
:meta_json
|
||||
)'
|
||||
);
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(18));
|
||||
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'actor_user_id' => $actorUserId,
|
||||
'action' => $action,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'result' => $result,
|
||||
'request_id' => substr($requestId, 0, 36),
|
||||
'ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
|
||||
'user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
|
||||
'meta_json' => $meta !== [] ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class ExportService
|
||||
{
|
||||
private const CSV_DELIMITER = ';';
|
||||
private const FLUSH_EVERY_ROWS = 250;
|
||||
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function streamMemberBalancesCsv(int $tenantId, ?string $filename = null): void
|
||||
{
|
||||
$this->assertTenantId($tenantId);
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT
|
||||
m.id AS member_id,
|
||||
m.display_name,
|
||||
m.email,
|
||||
m.status,
|
||||
m.created_at,
|
||||
COALESCE(balance_summary.balance_cents, 0) AS balance_cents,
|
||||
balance_summary.last_occurred_at
|
||||
FROM members m
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ll.member_id,
|
||||
SUM(ll.amount_cents) AS balance_cents,
|
||||
MAX(le.occurred_at) AS last_occurred_at
|
||||
FROM ledger_lines ll
|
||||
INNER JOIN ledger_entries le
|
||||
ON le.id = ll.ledger_entry_id
|
||||
AND le.tenant_id = ll.tenant_id
|
||||
WHERE ll.tenant_id = :balance_tenant_id
|
||||
AND ll.account_code = "member_balance"
|
||||
AND ll.member_id IS NOT NULL
|
||||
GROUP BY ll.member_id
|
||||
) balance_summary
|
||||
ON balance_summary.member_id = m.id
|
||||
WHERE m.tenant_id = :tenant_id
|
||||
ORDER BY m.display_name ASC, m.id ASC'
|
||||
);
|
||||
$statement->execute([
|
||||
'balance_tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
$handle = $this->openCsvOutput($filename ?? $this->defaultFilename('member-balances', $tenantId));
|
||||
|
||||
try {
|
||||
$this->writeCsvRow($handle, [
|
||||
'mitglied_id',
|
||||
'anzeigename',
|
||||
'email',
|
||||
'status',
|
||||
'saldo_cents',
|
||||
'saldo_eur',
|
||||
'letzte_buchung_am',
|
||||
'angelegt_am',
|
||||
]);
|
||||
|
||||
$rowCount = 0;
|
||||
|
||||
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
|
||||
$balanceCents = (int) $row['balance_cents'];
|
||||
|
||||
$this->writeCsvRow($handle, [
|
||||
(int) $row['member_id'],
|
||||
(string) $row['display_name'],
|
||||
$row['email'],
|
||||
(string) $row['status'],
|
||||
$balanceCents,
|
||||
$this->formatMoneyFromCents($balanceCents),
|
||||
$row['last_occurred_at'],
|
||||
(string) $row['created_at'],
|
||||
]);
|
||||
|
||||
$rowCount++;
|
||||
$this->flushIfNeeded($rowCount);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
public function streamBookingsCsv(
|
||||
int $tenantId,
|
||||
?int $memberId = null,
|
||||
?string $from = null,
|
||||
?string $until = null,
|
||||
?string $filename = null
|
||||
): void {
|
||||
$this->assertTenantId($tenantId);
|
||||
|
||||
if ($memberId !== null && $memberId <= 0) {
|
||||
throw new RuntimeException('Die Mitglieds-ID muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$from = $this->normalizeDateTimeFilter($from, false);
|
||||
$until = $this->normalizeDateTimeFilter($until, true);
|
||||
|
||||
$sql = 'SELECT
|
||||
le.id AS booking_id,
|
||||
le.type,
|
||||
le.reference_type,
|
||||
le.reference_id,
|
||||
le.description,
|
||||
le.occurred_at,
|
||||
le.created_at AS entry_created_at,
|
||||
le.member_id,
|
||||
m.display_name AS member_name,
|
||||
s.code AS source_code,
|
||||
s.name AS source_name,
|
||||
COALESCE(line_totals.member_balance_cents, 0) AS member_balance_cents,
|
||||
COALESCE(line_totals.cashbox_cents, 0) AS cashbox_cents,
|
||||
COALESCE(line_totals.coffee_revenue_cents, 0) AS coffee_revenue_cents,
|
||||
c.id AS consumption_event_id,
|
||||
c.quantity,
|
||||
c.unit_price_cents,
|
||||
c.total_cents,
|
||||
c.effective_at,
|
||||
c.recorded_at,
|
||||
c.source_reference,
|
||||
c.notes,
|
||||
c.paper_sheet_line_id,
|
||||
p.id AS product_id,
|
||||
p.name AS product_name
|
||||
FROM ledger_entries le
|
||||
LEFT JOIN members m
|
||||
ON m.id = le.member_id
|
||||
AND m.tenant_id = le.tenant_id
|
||||
LEFT JOIN capture_sources s
|
||||
ON s.id = le.source_id
|
||||
AND s.tenant_id = le.tenant_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ledger_entry_id,
|
||||
SUM(CASE WHEN account_code = "member_balance" THEN amount_cents ELSE 0 END) AS member_balance_cents,
|
||||
SUM(CASE WHEN account_code = "cashbox" THEN amount_cents ELSE 0 END) AS cashbox_cents,
|
||||
SUM(CASE WHEN account_code = "coffee_revenue" THEN amount_cents ELSE 0 END) AS coffee_revenue_cents
|
||||
FROM ledger_lines
|
||||
WHERE tenant_id = :line_tenant_id
|
||||
GROUP BY ledger_entry_id
|
||||
) line_totals
|
||||
ON line_totals.ledger_entry_id = le.id
|
||||
LEFT JOIN consumption_events c
|
||||
ON c.id = le.reference_id
|
||||
AND c.tenant_id = le.tenant_id
|
||||
AND le.reference_type = "consumption_event"
|
||||
LEFT JOIN products p
|
||||
ON p.id = c.product_id
|
||||
AND p.tenant_id = c.tenant_id
|
||||
WHERE le.tenant_id = :tenant_id';
|
||||
|
||||
$params = [
|
||||
'line_tenant_id' => $tenantId,
|
||||
'tenant_id' => $tenantId,
|
||||
];
|
||||
|
||||
if ($memberId !== null) {
|
||||
$sql .= ' AND le.member_id = :member_id';
|
||||
$params['member_id'] = $memberId;
|
||||
}
|
||||
|
||||
if ($from !== null) {
|
||||
$sql .= ' AND le.occurred_at >= :from_occurred_at';
|
||||
$params['from_occurred_at'] = $from;
|
||||
}
|
||||
|
||||
if ($until !== null) {
|
||||
$sql .= ' AND le.occurred_at <= :until_occurred_at';
|
||||
$params['until_occurred_at'] = $until;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY le.occurred_at DESC, le.id DESC';
|
||||
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
$handle = $this->openCsvOutput($filename ?? $this->defaultFilename('bookings', $tenantId));
|
||||
|
||||
try {
|
||||
$this->writeCsvRow($handle, [
|
||||
'buchung_id',
|
||||
'typ',
|
||||
'buchungsdatum',
|
||||
'mitglied_id',
|
||||
'mitglied_name',
|
||||
'beschreibung',
|
||||
'quellcode',
|
||||
'quelle',
|
||||
'referenz_typ',
|
||||
'referenz_id',
|
||||
'saldo_delta_cents',
|
||||
'saldo_delta_eur',
|
||||
'kasse_delta_cents',
|
||||
'kasse_delta_eur',
|
||||
'umsatz_delta_cents',
|
||||
'umsatz_delta_eur',
|
||||
'produkt_id',
|
||||
'produkt_name',
|
||||
'menge',
|
||||
'einzelpreis_cents',
|
||||
'einzelpreis_eur',
|
||||
'gesamt_cents',
|
||||
'gesamt_eur',
|
||||
'notizen',
|
||||
'wirksam_am',
|
||||
'erfasst_am',
|
||||
'quellreferenz',
|
||||
'papierlisten_zeile_id',
|
||||
'angelegt_am',
|
||||
]);
|
||||
|
||||
$rowCount = 0;
|
||||
|
||||
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
|
||||
$memberBalanceCents = (int) $row['member_balance_cents'];
|
||||
$cashboxCents = (int) $row['cashbox_cents'];
|
||||
$coffeeRevenueCents = (int) $row['coffee_revenue_cents'];
|
||||
$unitPriceCents = $row['unit_price_cents'] !== null ? (int) $row['unit_price_cents'] : null;
|
||||
$totalCents = $row['total_cents'] !== null ? (int) $row['total_cents'] : null;
|
||||
$recordedAt = $row['recorded_at'] ?? $row['entry_created_at'];
|
||||
|
||||
$this->writeCsvRow($handle, [
|
||||
(int) $row['booking_id'],
|
||||
(string) $row['type'],
|
||||
(string) $row['occurred_at'],
|
||||
$row['member_id'] !== null ? (int) $row['member_id'] : '',
|
||||
$row['member_name'],
|
||||
(string) $row['description'],
|
||||
$row['source_code'],
|
||||
$row['source_name'],
|
||||
$row['reference_type'],
|
||||
$row['reference_id'],
|
||||
$memberBalanceCents,
|
||||
$this->formatMoneyFromCents($memberBalanceCents),
|
||||
$cashboxCents,
|
||||
$this->formatMoneyFromCents($cashboxCents),
|
||||
$coffeeRevenueCents,
|
||||
$this->formatMoneyFromCents($coffeeRevenueCents),
|
||||
$row['product_id'] !== null ? (int) $row['product_id'] : '',
|
||||
$row['product_name'],
|
||||
$this->formatQuantity($row['quantity']),
|
||||
$unitPriceCents ?? '',
|
||||
$unitPriceCents !== null ? $this->formatMoneyFromCents($unitPriceCents) : '',
|
||||
$totalCents ?? '',
|
||||
$totalCents !== null ? $this->formatMoneyFromCents($totalCents) : '',
|
||||
$row['notes'],
|
||||
$row['effective_at'] ?? $row['occurred_at'],
|
||||
$recordedAt,
|
||||
$row['source_reference'],
|
||||
$row['paper_sheet_line_id'],
|
||||
(string) $row['entry_created_at'],
|
||||
]);
|
||||
|
||||
$rowCount++;
|
||||
$this->flushIfNeeded($rowCount);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTenantId(int $tenantId): void
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
throw new RuntimeException('Die Standort-ID muss größer als 0 sein.');
|
||||
}
|
||||
}
|
||||
|
||||
private function defaultFilename(string $prefix, int $tenantId): string
|
||||
{
|
||||
return sprintf('%s-tenant-%d-%s.csv', $prefix, $tenantId, gmdate('Ymd-His'));
|
||||
}
|
||||
|
||||
private function openCsvOutput(string $filename)
|
||||
{
|
||||
if (headers_sent($file, $line)) {
|
||||
throw new RuntimeException(sprintf('Export-Header können nicht mehr gesendet werden (%s:%d).', $file, $line));
|
||||
}
|
||||
|
||||
$safeFilename = $this->sanitizeFilename($filename);
|
||||
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . rawurlencode($safeFilename));
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
header('X-Accel-Buffering: no');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$handle = fopen('php://output', 'wb');
|
||||
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Die Exportausgabe konnte nicht geöffnet werden.');
|
||||
}
|
||||
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
return $handle;
|
||||
}
|
||||
|
||||
private function sanitizeFilename(string $filename): string
|
||||
{
|
||||
$sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($filename)) ?? '';
|
||||
$sanitized = trim($sanitized, '.-');
|
||||
|
||||
if ($sanitized === '') {
|
||||
$sanitized = 'export';
|
||||
}
|
||||
|
||||
if (!str_ends_with(strtolower($sanitized), '.csv')) {
|
||||
$sanitized .= '.csv';
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
private function writeCsvRow($handle, array $row): void
|
||||
{
|
||||
$values = array_map(function (mixed $value): string {
|
||||
return $this->sanitizeCsvValue($value);
|
||||
}, $row);
|
||||
|
||||
if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) {
|
||||
throw new RuntimeException('Die Exportzeile konnte nicht geschrieben werden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function sanitizeCsvValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
$string = str_replace(["\r\n", "\r"], "\n", (string) $value);
|
||||
|
||||
// Prevent spreadsheet formula execution for user-controlled text cells.
|
||||
if (
|
||||
$string !== ''
|
||||
&& in_array($string[0], ['=', '+', '-', '@', "\t"], true)
|
||||
&& !is_numeric($string)
|
||||
) {
|
||||
return "'" . $string;
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
private function formatMoneyFromCents(int $amountCents): string
|
||||
{
|
||||
return number_format($amountCents / 100, 2, ',', '.');
|
||||
}
|
||||
|
||||
private function formatQuantity(mixed $quantity): string
|
||||
{
|
||||
if ($quantity === null || $quantity === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return rtrim(rtrim(number_format((float) $quantity, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
private function normalizeDateTimeFilter(?string $value, bool $endOfDay): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = str_replace('T', ' ', $value);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
||||
return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00');
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) {
|
||||
return $value . ':00';
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function flushIfNeeded(int $rowCount): void
|
||||
{
|
||||
if ($rowCount % self::FLUSH_EVERY_ROWS !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ob_get_level() > 0) {
|
||||
ob_flush();
|
||||
}
|
||||
|
||||
flush();
|
||||
}
|
||||
}
|
||||
@@ -1,748 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class LedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createConsumption(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
string $sourceCode,
|
||||
string $effectiveAt,
|
||||
int $recordedByUserId,
|
||||
float $quantity,
|
||||
?string $notes = null,
|
||||
?int $paperSheetLineId = null,
|
||||
?string $sourceReference = null
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$this->assertTenantMembership($tenantId, $recordedByUserId);
|
||||
$this->assertTenantMember($tenantId, $memberId);
|
||||
$this->assertTenantProduct($tenantId, $productId);
|
||||
$source = $this->findSource($tenantId, $sourceCode);
|
||||
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
|
||||
$totalCents = (int) round($priceCents * $quantity);
|
||||
$description = sprintf('Verbrauch %s x #%d', rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'), $productId);
|
||||
$recordedAt = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$eventInsert = $this->pdo->prepare(
|
||||
'INSERT INTO consumption_events (
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
source_id,
|
||||
paper_sheet_line_id,
|
||||
quantity,
|
||||
unit_price_cents,
|
||||
total_cents,
|
||||
effective_at,
|
||||
recorded_at,
|
||||
recorded_by_user_id,
|
||||
source_reference,
|
||||
notes
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:source_id,
|
||||
:paper_sheet_line_id,
|
||||
:quantity,
|
||||
:unit_price_cents,
|
||||
:total_cents,
|
||||
:effective_at,
|
||||
:recorded_at,
|
||||
:recorded_by_user_id,
|
||||
:source_reference,
|
||||
:notes
|
||||
)'
|
||||
);
|
||||
$eventInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'source_id' => $source['id'],
|
||||
'paper_sheet_line_id' => $paperSheetLineId,
|
||||
'quantity' => $quantity,
|
||||
'unit_price_cents' => $priceCents,
|
||||
'total_cents' => $totalCents,
|
||||
'effective_at' => $effectiveAt,
|
||||
'recorded_at' => $recordedAt,
|
||||
'recorded_by_user_id' => $recordedByUserId,
|
||||
'source_reference' => $sourceReference,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
$eventId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
reference_type,
|
||||
reference_id,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"consumption",
|
||||
"consumption_event",
|
||||
:reference_id,
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'reference_id' => $eventId,
|
||||
'description' => $description,
|
||||
'occurred_at' => $effectiveAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
$entryId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$lineInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_lines (
|
||||
ledger_entry_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
account_code,
|
||||
amount_cents
|
||||
) VALUES (
|
||||
:ledger_entry_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:account_code,
|
||||
:amount_cents
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => $totalCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'coffee_revenue',
|
||||
'amount_cents' => -$totalCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'consumption.created', 'consumption_event', $eventId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'source' => $sourceCode,
|
||||
'total_cents' => $totalCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPayment(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $amountCents,
|
||||
string $occurredAt,
|
||||
int $recordedByUserId,
|
||||
?string $notes = null
|
||||
): void {
|
||||
if ($amountCents <= 0) {
|
||||
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$this->assertTenantMembership($tenantId, $recordedByUserId);
|
||||
$this->assertTenantMember($tenantId, $memberId);
|
||||
$source = $this->findSource($tenantId, 'admin_backoffice');
|
||||
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id,
|
||||
reference_type
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"payment",
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id,
|
||||
"payment"
|
||||
)'
|
||||
);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'description' => $notes !== null && trim($notes) !== '' ? $notes : 'Einzahlung',
|
||||
'occurred_at' => $occurredAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
|
||||
$entryId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$lineInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_lines (
|
||||
ledger_entry_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
account_code,
|
||||
amount_cents
|
||||
) VALUES (
|
||||
:ledger_entry_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:account_code,
|
||||
:amount_cents
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'cashbox',
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => -$amountCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'payment.created', 'ledger_entry', $entryId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPaperSheet(
|
||||
int $tenantId,
|
||||
string $title,
|
||||
?string $periodLabel,
|
||||
?string $periodStart,
|
||||
?string $periodEnd,
|
||||
?string $notes,
|
||||
int $createdByUserId
|
||||
): int {
|
||||
$this->assertTenantMembership($tenantId, $createdByUserId);
|
||||
|
||||
if (trim($title) === '') {
|
||||
throw new RuntimeException('Bitte einen Titel für die Liste angeben.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheets (
|
||||
tenant_id,
|
||||
title,
|
||||
period_label,
|
||||
period_start,
|
||||
period_end,
|
||||
notes,
|
||||
created_by_user_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:title,
|
||||
:period_label,
|
||||
:period_start,
|
||||
:period_end,
|
||||
:notes,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => $title,
|
||||
'period_label' => $periodLabel,
|
||||
'period_start' => $periodStart ?: null,
|
||||
'period_end' => $periodEnd ?: null,
|
||||
'notes' => $notes,
|
||||
'created_by_user_id' => $createdByUserId,
|
||||
]);
|
||||
|
||||
$sheetId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $createdByUserId, 'paper_sheet.created', 'paper_sheet', $sheetId, 'success');
|
||||
|
||||
return $sheetId;
|
||||
}
|
||||
|
||||
public function addPaperSheetLine(
|
||||
int $tenantId,
|
||||
int $sheetId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
float $quantity,
|
||||
?string $effectiveAt,
|
||||
?string $note,
|
||||
int $actorUserId
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||
$this->assertTenantMember($tenantId, $memberId);
|
||||
$this->assertTenantProduct($tenantId, $productId);
|
||||
|
||||
$sheet = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheet->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$paperSheet = $sheet->fetch();
|
||||
|
||||
if (!$paperSheet) {
|
||||
throw new RuntimeException('Die Liste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($paperSheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Nur Entwürfe können erweitert werden.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheet_lines (
|
||||
paper_sheet_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
quantity,
|
||||
effective_at,
|
||||
note
|
||||
) VALUES (
|
||||
:paper_sheet_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:quantity,
|
||||
:effective_at,
|
||||
:note
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'effective_at' => $effectiveAt ?: null,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$lineId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.line_added', 'paper_sheet_line', $lineId, 'success', [
|
||||
'paper_sheet_id' => $sheetId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
|
||||
{
|
||||
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||
|
||||
$sheetStatement = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheetStatement->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$sheet = $sheetStatement->fetch();
|
||||
|
||||
if (!$sheet) {
|
||||
throw new RuntimeException('Die Liste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($sheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Die Liste wurde bereits verbucht.');
|
||||
}
|
||||
|
||||
$linesStatement = $this->pdo->prepare(
|
||||
'SELECT * FROM paper_sheet_lines WHERE paper_sheet_id = :paper_sheet_id AND tenant_id = :tenant_id ORDER BY id ASC'
|
||||
);
|
||||
$linesStatement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$lines = $linesStatement->fetchAll();
|
||||
|
||||
if ($lines === []) {
|
||||
throw new RuntimeException('Die Liste enthält noch keine Positionen.');
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$this->createConsumption(
|
||||
$tenantId,
|
||||
(int) $line['member_id'],
|
||||
(int) $line['product_id'],
|
||||
'paper_sheet',
|
||||
$line['effective_at'] ?: gmdate('Y-m-d H:i:s'),
|
||||
$actorUserId,
|
||||
(float) $line['quantity'],
|
||||
$line['note'] ?: 'Liste',
|
||||
(int) $line['id'],
|
||||
'sheet:' . $sheetId
|
||||
);
|
||||
}
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE paper_sheets SET status = "posted", posted_at = :posted_at WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$update->execute([
|
||||
'posted_at' => gmdate('Y-m-d H:i:s'),
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
|
||||
}
|
||||
|
||||
public function reverseLedgerEntry(int $tenantId, int $ledgerEntryId, int $actorUserId, ?string $reason = null): int
|
||||
{
|
||||
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||
|
||||
$entry = $this->ledgerEntry($tenantId, $ledgerEntryId);
|
||||
|
||||
if ($this->hasReversal($tenantId, $ledgerEntryId)) {
|
||||
throw new RuntimeException('Dieser Vorgang wurde bereits storniert.');
|
||||
}
|
||||
|
||||
$linesStatement = $this->pdo->prepare(
|
||||
'SELECT member_id, account_code, amount_cents
|
||||
FROM ledger_lines
|
||||
WHERE tenant_id = :tenant_id AND ledger_entry_id = :ledger_entry_id
|
||||
ORDER BY id ASC'
|
||||
);
|
||||
$linesStatement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'ledger_entry_id' => $ledgerEntryId,
|
||||
]);
|
||||
$lines = $linesStatement->fetchAll();
|
||||
|
||||
if ($lines === []) {
|
||||
throw new RuntimeException('Es wurden keine Buchungszeilen für den Vorgang gefunden.');
|
||||
}
|
||||
|
||||
$description = 'Storno: ' . $entry['description'];
|
||||
|
||||
if ($reason !== null && trim($reason) !== '') {
|
||||
$description .= ' (' . trim($reason) . ')';
|
||||
}
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
reference_type,
|
||||
reference_id,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id,
|
||||
reversal_of_entry_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"correction",
|
||||
"ledger_reversal",
|
||||
:reference_id,
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id,
|
||||
:reversal_of_entry_id
|
||||
)'
|
||||
);
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $entry['member_id'],
|
||||
'source_id' => $entry['source_id'],
|
||||
'reference_id' => $ledgerEntryId,
|
||||
'description' => $description,
|
||||
'occurred_at' => gmdate('Y-m-d H:i:s'),
|
||||
'created_by_user_id' => $actorUserId,
|
||||
'reversal_of_entry_id' => $ledgerEntryId,
|
||||
]);
|
||||
$reversalId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$lineInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_lines (
|
||||
ledger_entry_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
account_code,
|
||||
amount_cents
|
||||
) VALUES (
|
||||
:ledger_entry_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:account_code,
|
||||
:amount_cents
|
||||
)'
|
||||
);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $reversalId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $line['member_id'] !== null ? (int) $line['member_id'] : null,
|
||||
'account_code' => $line['account_code'],
|
||||
'amount_cents' => 0 - (int) $line['amount_cents'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'ledger_entry.reversed', 'ledger_entry', $reversalId, 'success', [
|
||||
'reversal_of_entry_id' => $ledgerEntryId,
|
||||
]);
|
||||
|
||||
return $reversalId;
|
||||
}
|
||||
|
||||
public function reversePaperSheet(int $tenantId, int $sheetId, int $actorUserId, ?string $reason = null): int
|
||||
{
|
||||
$this->assertTenantMembership($tenantId, $actorUserId);
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT DISTINCT le.id
|
||||
FROM ledger_entries le
|
||||
INNER JOIN consumption_events ce
|
||||
ON ce.id = le.reference_id
|
||||
AND le.reference_type = "consumption_event"
|
||||
INNER JOIN paper_sheet_lines psl
|
||||
ON psl.id = ce.paper_sheet_line_id
|
||||
WHERE le.tenant_id = :tenant_id
|
||||
AND psl.paper_sheet_id = :paper_sheet_id
|
||||
ORDER BY le.id ASC'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'paper_sheet_id' => $sheetId,
|
||||
]);
|
||||
$entryIds = $statement->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if ($entryIds === []) {
|
||||
throw new RuntimeException('Zu dieser Liste wurden keine verbuchten Einträge gefunden.');
|
||||
}
|
||||
|
||||
$reversed = 0;
|
||||
|
||||
foreach ($entryIds as $entryId) {
|
||||
if ($this->hasReversal($tenantId, (int) $entryId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->reverseLedgerEntry(
|
||||
$tenantId,
|
||||
(int) $entryId,
|
||||
$actorUserId,
|
||||
$reason !== null && trim($reason) !== '' ? $reason : 'Liste #' . $sheetId
|
||||
);
|
||||
$reversed++;
|
||||
}
|
||||
|
||||
if ($reversed === 0) {
|
||||
throw new RuntimeException('Diese Liste wurde bereits vollständig storniert.');
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [
|
||||
'reversed_entries' => $reversed,
|
||||
]);
|
||||
|
||||
return $reversed;
|
||||
}
|
||||
|
||||
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT price_cents
|
||||
FROM product_prices
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND product_id = :product_id
|
||||
AND valid_from <= :effective_at
|
||||
AND (valid_until IS NULL OR valid_until > :effective_at)
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'effective_at' => $effectiveAt,
|
||||
]);
|
||||
$price = $statement->fetchColumn();
|
||||
|
||||
if ($price === false) {
|
||||
throw new RuntimeException('Für das Produkt ist kein aktiver Preis hinterlegt.');
|
||||
}
|
||||
|
||||
return (int) $price;
|
||||
}
|
||||
|
||||
private function findSource(int $tenantId, string $code): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, code FROM capture_sources WHERE tenant_id = :tenant_id AND code = :code LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
]);
|
||||
$source = $statement->fetch();
|
||||
|
||||
if (!$source) {
|
||||
throw new RuntimeException('Die Erfassungsquelle wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
return $source;
|
||||
}
|
||||
|
||||
private function assertTenantMember(int $tenantId, int $memberId): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id FROM members WHERE id = :id AND tenant_id = :tenant_id AND status = "active" LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $memberId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
if (!$statement->fetchColumn()) {
|
||||
throw new RuntimeException('Das gewählte Mitglied gehört nicht zu diesem Standort.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTenantProduct(int $tenantId, int $productId): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id FROM products WHERE id = :id AND tenant_id = :tenant_id AND is_active = 1 LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $productId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
if (!$statement->fetchColumn()) {
|
||||
throw new RuntimeException('Das gewählte Produkt gehört nicht zu dieser Kaffeeliste.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertTenantMembership(int $tenantId, int $userId): void
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id
|
||||
FROM tenant_memberships
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND user_id = :user_id
|
||||
AND status = "active"
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
if (!$statement->fetchColumn()) {
|
||||
throw new RuntimeException('Der Benutzer ist für diesen Standort nicht aktiv.');
|
||||
}
|
||||
}
|
||||
|
||||
private function ledgerEntry(int $tenantId, int $ledgerEntryId): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM ledger_entries
|
||||
WHERE id = :id
|
||||
AND tenant_id = :tenant_id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'id' => $ledgerEntryId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$entry = $statement->fetch();
|
||||
|
||||
if (!$entry) {
|
||||
throw new RuntimeException('Der angeforderte Vorgang wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function hasReversal(int $tenantId, int $ledgerEntryId): bool
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id
|
||||
FROM ledger_entries
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND reversal_of_entry_id = :reversal_of_entry_id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'reversal_of_entry_id' => $ledgerEntryId,
|
||||
]);
|
||||
|
||||
return (bool) $statement->fetchColumn();
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class MailerService
|
||||
{
|
||||
private readonly string $rootPath;
|
||||
|
||||
public function __construct(private readonly array $config = [])
|
||||
{
|
||||
$this->rootPath = dirname(__DIR__, 2);
|
||||
}
|
||||
|
||||
public function send(string $to, string $subject, string $textBody, array $headers = []): bool
|
||||
{
|
||||
$to = trim($to);
|
||||
$subject = trim($subject);
|
||||
$textBody = trim($textBody);
|
||||
|
||||
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die Zieladresse für den Mailversand ist ungültig.');
|
||||
}
|
||||
|
||||
if ($subject === '' || $textBody === '') {
|
||||
throw new RuntimeException('Betreff und Inhalt für den Mailversand sind erforderlich.');
|
||||
}
|
||||
|
||||
$subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject));
|
||||
$headerLines = $this->buildHeaders($headers);
|
||||
$body = $this->normalizeBody($textBody);
|
||||
|
||||
$sent = false;
|
||||
|
||||
if (function_exists('mail')) {
|
||||
$sent = @mail($to, $subjectLine, $body, implode("\r\n", $headerLines));
|
||||
}
|
||||
|
||||
if ($sent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$this->usesLogFallback()) {
|
||||
throw new RuntimeException('Die E-Mail konnte nicht versendet werden.');
|
||||
}
|
||||
|
||||
$this->writeFallbackLog([
|
||||
'to' => $to,
|
||||
'subject' => $this->subjectPrefix() . $subject,
|
||||
'headers' => $headerLines,
|
||||
'body' => $textBody,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function buildHeaders(array $headers): array
|
||||
{
|
||||
$headerLines = [
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=UTF-8',
|
||||
'Content-Transfer-Encoding: 8bit',
|
||||
'X-Mailer: Kaffeeliste',
|
||||
];
|
||||
|
||||
$from = trim((string) ($this->config['from'] ?? ''));
|
||||
|
||||
if ($from !== '') {
|
||||
$headerLines[] = 'From: ' . $this->sanitizeHeaderValue($from);
|
||||
}
|
||||
|
||||
$replyTo = trim((string) ($this->config['reply_to'] ?? ''));
|
||||
|
||||
if ($replyTo !== '') {
|
||||
$headerLines[] = 'Reply-To: ' . $this->sanitizeHeaderValue($replyTo);
|
||||
}
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
if (is_int($name)) {
|
||||
$line = trim((string) $value);
|
||||
|
||||
if ($line !== '' && str_contains($line, ':')) {
|
||||
[$rawName, $rawValue] = array_pad(explode(':', $line, 2), 2, '');
|
||||
$this->appendHeader($headerLines, $rawName, $rawValue);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->appendHeader($headerLines, (string) $name, (string) $value);
|
||||
}
|
||||
|
||||
return $headerLines;
|
||||
}
|
||||
|
||||
private function appendHeader(array &$headerLines, string $name, string $value): void
|
||||
{
|
||||
$name = trim($name);
|
||||
|
||||
if ($name === '' || preg_match('/[^A-Za-z0-9-]/', $name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$headerLines[] = $name . ': ' . $this->sanitizeHeaderValue($value);
|
||||
}
|
||||
|
||||
private function writeFallbackLog(array $payload): void
|
||||
{
|
||||
$path = $this->resolveLogPath();
|
||||
$directory = dirname($path);
|
||||
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Das Mail-Log-Verzeichnis konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
$encodedPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if (!is_string($encodedPayload)) {
|
||||
$encodedPayload = '{"error":"mail payload could not be encoded"}';
|
||||
}
|
||||
|
||||
$entry = '[' . gmdate(DATE_ATOM) . "] mail_fallback\n" . $encodedPayload . "\n\n";
|
||||
|
||||
if (file_put_contents($path, $entry, FILE_APPEND | LOCK_EX) === false) {
|
||||
throw new RuntimeException('Das Mail-Fallback-Log konnte nicht geschrieben werden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveLogPath(): string
|
||||
{
|
||||
$configuredPath = trim((string) ($this->config['log_file'] ?? ''));
|
||||
|
||||
if ($configuredPath === '') {
|
||||
return $this->rootPath . '/storage/logs/mail.log';
|
||||
}
|
||||
|
||||
if (str_starts_with($configuredPath, '/')) {
|
||||
return $configuredPath;
|
||||
}
|
||||
|
||||
return $this->rootPath . '/' . ltrim($configuredPath, '/');
|
||||
}
|
||||
|
||||
private function usesLogFallback(): bool
|
||||
{
|
||||
return (bool) ($this->config['log_fallback'] ?? true);
|
||||
}
|
||||
|
||||
private function subjectPrefix(): string
|
||||
{
|
||||
return (string) ($this->config['subject_prefix'] ?? '');
|
||||
}
|
||||
|
||||
private function sanitizeHeaderValue(string $value): string
|
||||
{
|
||||
return trim(str_replace(["\r", "\n"], ' ', $value));
|
||||
}
|
||||
|
||||
private function encodeSubject(string $subject): string
|
||||
{
|
||||
if ($subject === '') {
|
||||
return $subject;
|
||||
}
|
||||
|
||||
if (function_exists('mb_encode_mimeheader')) {
|
||||
return mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n");
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
private function normalizeBody(string $textBody): string
|
||||
{
|
||||
return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $textBody));
|
||||
}
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class PasswordResetService
|
||||
{
|
||||
private readonly DateTimeZone $utc;
|
||||
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly MailerService $mailer,
|
||||
private readonly array $config = []
|
||||
) {
|
||||
$this->utc = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
public function requestReset(string $email, array $context = []): void
|
||||
{
|
||||
$email = $this->normalizeEmail($email);
|
||||
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->findUserByEmail($email);
|
||||
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $this->createTokenForUser((int) $user['id'], $context);
|
||||
$this->sendResetMail($token);
|
||||
}
|
||||
|
||||
public function createTokenForUser(int $userId, array $context = []): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
throw new RuntimeException('Der Benutzer für den Passwortwechsel ist ungültig.');
|
||||
}
|
||||
|
||||
$user = $this->findUserById($userId);
|
||||
|
||||
if (!$user) {
|
||||
throw new RuntimeException('Der Benutzer für den Passwortwechsel wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
$nowTs = time();
|
||||
$now = $this->formatTimestamp($nowTs);
|
||||
$expiresAt = $this->formatTimestamp($nowTs + ($this->ttlMinutes() * 60));
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$cleanupStatement = $this->pdo->prepare(
|
||||
'DELETE FROM password_reset_tokens
|
||||
WHERE user_id = :user_id
|
||||
AND (consumed_at IS NOT NULL OR expires_at < :now)'
|
||||
);
|
||||
$cleanupStatement->execute([
|
||||
'user_id' => $userId,
|
||||
'now' => $now,
|
||||
]);
|
||||
|
||||
$insertStatement = $this->pdo->prepare(
|
||||
'INSERT INTO password_reset_tokens (
|
||||
user_id,
|
||||
email,
|
||||
token_hash,
|
||||
requested_ip_hash,
|
||||
requested_user_agent_hash,
|
||||
expires_at,
|
||||
consumed_at,
|
||||
created_at
|
||||
) VALUES (
|
||||
:user_id,
|
||||
:email,
|
||||
:token_hash,
|
||||
:requested_ip_hash,
|
||||
:requested_user_agent_hash,
|
||||
:expires_at,
|
||||
NULL,
|
||||
:created_at
|
||||
)'
|
||||
);
|
||||
$insertStatement->execute([
|
||||
'user_id' => $userId,
|
||||
'email' => $user['email'],
|
||||
'token_hash' => $tokenHash,
|
||||
'requested_ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
|
||||
'requested_user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
|
||||
'expires_at' => $expiresAt,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$trimStatement = $this->pdo->prepare(
|
||||
'SELECT id
|
||||
FROM password_reset_tokens
|
||||
WHERE user_id = :user_id
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at >= :now
|
||||
ORDER BY created_at DESC, id DESC'
|
||||
);
|
||||
$trimStatement->execute([
|
||||
'user_id' => $userId,
|
||||
'now' => $now,
|
||||
]);
|
||||
|
||||
$tokenIds = array_map(
|
||||
static fn (array $row): int => (int) $row['id'],
|
||||
$trimStatement->fetchAll()
|
||||
);
|
||||
|
||||
$idsToDelete = array_slice($tokenIds, $this->maxActiveTokens());
|
||||
|
||||
if ($idsToDelete !== []) {
|
||||
$deleteStatement = $this->pdo->prepare(
|
||||
'DELETE FROM password_reset_tokens
|
||||
WHERE user_id = :user_id
|
||||
AND id = :id'
|
||||
);
|
||||
|
||||
foreach ($idsToDelete as $tokenId) {
|
||||
$deleteStatement->execute([
|
||||
'user_id' => $userId,
|
||||
'id' => $tokenId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'email' => $user['email'],
|
||||
'full_name' => $user['full_name'],
|
||||
'token' => $token,
|
||||
'expires_at' => $expiresAt,
|
||||
'reset_url' => $this->buildResetUrl($token, $context),
|
||||
];
|
||||
}
|
||||
|
||||
public function findValidToken(string $token): ?array
|
||||
{
|
||||
$token = trim($token);
|
||||
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT prt.id,
|
||||
prt.user_id,
|
||||
prt.email,
|
||||
prt.expires_at,
|
||||
prt.consumed_at,
|
||||
prt.created_at,
|
||||
u.full_name
|
||||
FROM password_reset_tokens prt
|
||||
INNER JOIN users u ON u.id = prt.user_id
|
||||
WHERE prt.token_hash = :token_hash
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'token_hash' => hash('sha256', $token),
|
||||
]);
|
||||
$resetToken = $statement->fetch();
|
||||
|
||||
if (!$resetToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
|
||||
|
||||
if (
|
||||
$resetToken['consumed_at'] !== null
|
||||
|| $expiresAtTs === null
|
||||
|| $expiresAtTs < time()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $resetToken;
|
||||
}
|
||||
|
||||
public function resetPassword(string $token, string $newPassword): int
|
||||
{
|
||||
$token = trim($token);
|
||||
$newPassword = (string) $newPassword;
|
||||
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
|
||||
}
|
||||
|
||||
if (mb_strlen($newPassword) < 12) {
|
||||
throw new RuntimeException('Das neue Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$nowTs = time();
|
||||
$now = $this->formatTimestamp($nowTs);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, user_id, expires_at, consumed_at
|
||||
FROM password_reset_tokens
|
||||
WHERE token_hash = :token_hash
|
||||
LIMIT 1
|
||||
FOR UPDATE'
|
||||
);
|
||||
$statement->execute(['token_hash' => $tokenHash]);
|
||||
$resetToken = $statement->fetch();
|
||||
|
||||
if (!$resetToken) {
|
||||
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
|
||||
}
|
||||
|
||||
$expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
|
||||
|
||||
if (
|
||||
$resetToken['consumed_at'] !== null
|
||||
|| $expiresAtTs === null
|
||||
|| $expiresAtTs < $nowTs
|
||||
) {
|
||||
throw new RuntimeException('Der Link ist ungültig oder abgelaufen.');
|
||||
}
|
||||
|
||||
$updateUserStatement = $this->pdo->prepare(
|
||||
'UPDATE users
|
||||
SET password_hash = :password_hash
|
||||
WHERE id = :user_id'
|
||||
);
|
||||
$updateUserStatement->execute([
|
||||
'password_hash' => secure_password_hash($newPassword),
|
||||
'user_id' => $resetToken['user_id'],
|
||||
]);
|
||||
|
||||
$consumeTokenStatement = $this->pdo->prepare(
|
||||
'UPDATE password_reset_tokens
|
||||
SET consumed_at = :consumed_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
$consumeTokenStatement->execute([
|
||||
'consumed_at' => $now,
|
||||
'id' => $resetToken['id'],
|
||||
]);
|
||||
|
||||
$revokeOtherTokensStatement = $this->pdo->prepare(
|
||||
'DELETE FROM password_reset_tokens
|
||||
WHERE user_id = :user_id
|
||||
AND id <> :id'
|
||||
);
|
||||
$revokeOtherTokensStatement->execute([
|
||||
'user_id' => $resetToken['user_id'],
|
||||
'id' => $resetToken['id'],
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
return (int) $resetToken['user_id'];
|
||||
}
|
||||
|
||||
public function revokeTokensForUser(int $userId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'DELETE FROM password_reset_tokens
|
||||
WHERE user_id = :user_id'
|
||||
);
|
||||
$statement->execute(['user_id' => $userId]);
|
||||
|
||||
return $statement->rowCount();
|
||||
}
|
||||
|
||||
public function purgeExpiredTokens(): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'DELETE FROM password_reset_tokens
|
||||
WHERE consumed_at IS NOT NULL
|
||||
OR expires_at < :now'
|
||||
);
|
||||
$statement->execute(['now' => $this->formatTimestamp(time())]);
|
||||
|
||||
return $statement->rowCount();
|
||||
}
|
||||
|
||||
private function sendResetMail(array $token): void
|
||||
{
|
||||
$name = trim((string) ($token['full_name'] ?? ''));
|
||||
$greeting = $name !== '' ? 'Guten Tag ' . $name . ',' : 'Guten Tag,';
|
||||
$body = implode("\n\n", [
|
||||
$greeting,
|
||||
'für Ihr Konto wurde ein Passwortwechsel angefragt.',
|
||||
'Bitte nutzen Sie diesen Link, um ein neues Passwort zu setzen:',
|
||||
(string) $token['reset_url'],
|
||||
'Der Link ist gültig bis ' . (string) $token['expires_at'] . '.',
|
||||
'Falls Sie den Passwortwechsel nicht angefragt haben, können Sie diese Nachricht ignorieren.',
|
||||
]);
|
||||
|
||||
$this->mailer->send(
|
||||
(string) $token['email'],
|
||||
$this->mailSubject(),
|
||||
$body
|
||||
);
|
||||
}
|
||||
|
||||
private function findUserByEmail(string $email): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, full_name, email
|
||||
FROM users
|
||||
WHERE email = :email
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute(['email' => $email]);
|
||||
$user = $statement->fetch();
|
||||
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
private function findUserById(int $userId): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, full_name, email
|
||||
FROM users
|
||||
WHERE id = :id
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute(['id' => $userId]);
|
||||
$user = $statement->fetch();
|
||||
|
||||
return $user ?: null;
|
||||
}
|
||||
|
||||
private function normalizeEmail(string $email): string
|
||||
{
|
||||
return mb_strtolower(trim($email));
|
||||
}
|
||||
|
||||
private function buildResetUrl(string $token, array $context = []): string
|
||||
{
|
||||
$template = trim((string) ($this->config['reset_url'] ?? ''));
|
||||
|
||||
if ($template === '') {
|
||||
$template = 'http://localhost:8080/reset-password?token={{token}}';
|
||||
}
|
||||
|
||||
$query = [];
|
||||
|
||||
foreach ($context as $key => $value) {
|
||||
if (!is_string($key) || trim($key) === '' || !is_scalar($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedValue = trim((string) $value);
|
||||
|
||||
if ($normalizedValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query[$key] = $normalizedValue;
|
||||
}
|
||||
|
||||
if (str_contains($template, '{{token}}')) {
|
||||
$url = str_replace('{{token}}', rawurlencode($token), $template);
|
||||
|
||||
if ($query === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
|
||||
return $url . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
$query['token'] = $token;
|
||||
$separator = str_contains($template, '?') ? '&' : '?';
|
||||
|
||||
return $template . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
private function mailSubject(): string
|
||||
{
|
||||
$subject = trim((string) ($this->config['subject'] ?? ''));
|
||||
|
||||
return $subject !== '' ? $subject : 'Passwort zurücksetzen';
|
||||
}
|
||||
|
||||
private function ttlMinutes(): int
|
||||
{
|
||||
return max(5, (int) ($this->config['ttl_minutes'] ?? 60));
|
||||
}
|
||||
|
||||
private function maxActiveTokens(): int
|
||||
{
|
||||
return max(1, (int) ($this->config['max_active_tokens'] ?? 3));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class RateLimiterService
|
||||
{
|
||||
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);
|
||||
|
||||
$isSqlite = $this->isSqlite();
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
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' . $forUpdate
|
||||
);
|
||||
$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 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);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class RfidService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string
|
||||
{
|
||||
if (trim($name) === '') {
|
||||
throw new RuntimeException('Bitte einen Namen für den Kartenleser angeben.');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(20));
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
|
||||
VALUES (:tenant_id, :name, :location_label, :device_token, "active")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => trim($name),
|
||||
'location_label' => $location,
|
||||
'device_token' => $token,
|
||||
]);
|
||||
$deviceId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.device_created', 'rfid_device', $deviceId, 'success');
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function assignTag(int $tenantId, int $memberId, string $uid, ?string $label, int $actorUserId): void
|
||||
{
|
||||
if (trim($uid) === '') {
|
||||
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
|
||||
}
|
||||
|
||||
$memberStatement = $this->pdo->prepare(
|
||||
'SELECT id FROM members WHERE tenant_id = :tenant_id AND id = :id AND status = "active" LIMIT 1'
|
||||
);
|
||||
$memberStatement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'id' => $memberId,
|
||||
]);
|
||||
|
||||
if (!$memberStatement->fetchColumn()) {
|
||||
throw new RuntimeException('Das ausgewählte Mitglied ist für Karten nicht verfügbar.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
|
||||
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'uid_hash' => hash('sha256', trim($uid)),
|
||||
'label' => $label,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.tag_assigned', 'rfid_tag', (int) $this->pdo->lastInsertId(), 'success');
|
||||
}
|
||||
|
||||
public function ingest(array $payload): void
|
||||
{
|
||||
$deviceToken = trim((string) ($payload['device_token'] ?? ''));
|
||||
$uid = trim((string) ($payload['uid'] ?? ''));
|
||||
$externalEventId = trim((string) ($payload['event_id'] ?? ''));
|
||||
|
||||
if ($deviceToken === '' || $uid === '') {
|
||||
throw new RuntimeException('device_token und uid sind Pflichtfelder.');
|
||||
}
|
||||
|
||||
$deviceStatement = $this->pdo->prepare(
|
||||
'SELECT d.id, d.tenant_id, d.status
|
||||
FROM rfid_devices d
|
||||
WHERE d.device_token = :device_token
|
||||
LIMIT 1'
|
||||
);
|
||||
$deviceStatement->execute(['device_token' => $deviceToken]);
|
||||
$device = $deviceStatement->fetch();
|
||||
|
||||
if (!$device) {
|
||||
throw new RuntimeException('Der Kartenleser ist unbekannt.');
|
||||
}
|
||||
|
||||
if (($device['status'] ?? '') !== 'active') {
|
||||
throw new RuntimeException('Der Kartenleser ist nicht aktiv.');
|
||||
}
|
||||
|
||||
$status = 'received';
|
||||
$tagStatement = $this->pdo->prepare(
|
||||
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
|
||||
);
|
||||
$tagStatement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
]);
|
||||
|
||||
if ($tagStatement->fetch()) {
|
||||
$status = 'linked';
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_events (
|
||||
tenant_id,
|
||||
device_id,
|
||||
external_event_id,
|
||||
uid_hash,
|
||||
payload_json,
|
||||
status,
|
||||
received_at
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:device_id,
|
||||
:external_event_id,
|
||||
:uid_hash,
|
||||
:payload_json,
|
||||
:status,
|
||||
:received_at
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'device_id' => $device['id'],
|
||||
'external_event_id' => $externalEventId !== '' ? $externalEventId : null,
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'status' => $status,
|
||||
'received_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$this->audit->log((int) $device['tenant_id'], null, 'rfid.event_received', 'rfid_event', (int) $this->pdo->lastInsertId(), 'success', [
|
||||
'status' => $status,
|
||||
'device_id' => (int) $device['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
final class SecurityHeadersService
|
||||
{
|
||||
public function buildHeaders(?bool $isHttps = null): array
|
||||
{
|
||||
$isHttps = $isHttps ?? $this->isHttpsRequest();
|
||||
|
||||
$headers = [
|
||||
'Content-Security-Policy' => $this->buildContentSecurityPolicy($isHttps),
|
||||
'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||
'Permissions-Policy' => 'accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
|
||||
'Cross-Origin-Opener-Policy' => 'same-origin',
|
||||
'Cross-Origin-Resource-Policy' => 'same-origin',
|
||||
'Origin-Agent-Cluster' => '?1',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-Frame-Options' => 'SAMEORIGIN',
|
||||
'X-Permitted-Cross-Domain-Policies' => 'none',
|
||||
];
|
||||
|
||||
if ($isHttps) {
|
||||
$headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function send(array $overrides = [], ?bool $isHttps = null): void
|
||||
{
|
||||
if (headers_sent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = $this->buildHeaders($isHttps);
|
||||
|
||||
foreach ($overrides as $name => $value) {
|
||||
if (!is_string($name) || trim($name) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
unset($headers[$name]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$headers[$name] = (string) $value;
|
||||
}
|
||||
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
header($name . ': ' . $value, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildContentSecurityPolicy(bool $isHttps): string
|
||||
{
|
||||
$directives = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"object-src 'none'",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self' data:",
|
||||
"img-src 'self' data:",
|
||||
"manifest-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
];
|
||||
|
||||
if ($isHttps) {
|
||||
$directives[] = 'upgrade-insecure-requests';
|
||||
}
|
||||
|
||||
return implode('; ', $directives);
|
||||
}
|
||||
|
||||
private function isHttpsRequest(): bool
|
||||
{
|
||||
$https = $_SERVER['HTTPS'] ?? null;
|
||||
|
||||
if (is_string($https) && $https !== '' && strtolower($https) !== 'off') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$forwardedProto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '';
|
||||
|
||||
if (is_string($forwardedProto) && strtolower($forwardedProto) === 'https') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (string) ($_SERVER['SERVER_PORT'] ?? '') === '443';
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class SetupService
|
||||
{
|
||||
public function __construct(private readonly string $rootPath)
|
||||
{
|
||||
}
|
||||
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
return is_file($this->rootPath . '/storage/installed.lock');
|
||||
}
|
||||
|
||||
public function install(array $input): void
|
||||
{
|
||||
$required = [
|
||||
'app_url',
|
||||
'db_host',
|
||||
'db_port',
|
||||
'db_name',
|
||||
'db_user',
|
||||
'admin_name',
|
||||
'admin_email',
|
||||
'admin_password',
|
||||
];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (trim((string) ($input[$field] ?? '')) === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfüllen.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die Admin-E-Mail ist ungültig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($input['admin_password']) < 12) {
|
||||
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url']));
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
|
||||
$existing = $statement->fetchColumn();
|
||||
|
||||
if (!$existing) {
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "platform_admin")'
|
||||
);
|
||||
$insert->execute([
|
||||
'full_name' => trim((string) $input['admin_name']),
|
||||
'email' => mb_strtolower((string) $input['admin_email']),
|
||||
'password_hash' => secure_password_hash((string) $input['admin_password']),
|
||||
]);
|
||||
}
|
||||
|
||||
$envContent = $this->buildEnvFile($input, $useMySQL);
|
||||
|
||||
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
|
||||
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
$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 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);
|
||||
$appKey = bin2hex(random_bytes(32));
|
||||
$rfidSecret = bin2hex(random_bytes(24));
|
||||
|
||||
$lines = [
|
||||
'APP_NAME="Kaffeeliste"',
|
||||
'APP_ENV=production',
|
||||
'APP_DEBUG=0',
|
||||
'APP_URL=' . $appUrl,
|
||||
'APP_TIMEZONE=Europe/Berlin',
|
||||
'APP_KEY=' . $appKey,
|
||||
'ALLOW_PUBLIC_REGISTRATION=1',
|
||||
'RFID_INGEST_ENABLED=0',
|
||||
'',
|
||||
];
|
||||
|
||||
// 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,
|
||||
'MAIL_SUBJECT_PREFIX="[Kaffeeliste] "',
|
||||
'MAIL_LOG_FALLBACK=1',
|
||||
'MAIL_LOG_FILE=storage/logs/mail.log',
|
||||
'',
|
||||
'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}',
|
||||
'PASSWORD_RESET_TTL_MINUTES=60',
|
||||
'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3',
|
||||
'PASSWORD_RESET_SUBJECT="Passwort zurücksetzen"',
|
||||
'',
|
||||
'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_SHARED_SECRET=' . $rfidSecret,
|
||||
]);
|
||||
|
||||
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||
}
|
||||
|
||||
private function normalizeAppUrl(string $appUrl): string
|
||||
{
|
||||
return rtrim(trim($appUrl), '/');
|
||||
}
|
||||
|
||||
private function resolveMailFrom(array $input, string $appUrl): string
|
||||
{
|
||||
$mailFrom = mb_strtolower(trim((string) ($input['mail_from'] ?? '')));
|
||||
|
||||
if ($mailFrom === '' || $mailFrom === 'noreply@example.com') {
|
||||
return $this->defaultMailFrom($appUrl);
|
||||
}
|
||||
|
||||
if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die Mail-Absenderadresse ist ungültig.');
|
||||
}
|
||||
|
||||
return $mailFrom;
|
||||
}
|
||||
|
||||
private function defaultMailFrom(string $appUrl): string
|
||||
{
|
||||
$host = parse_url($appUrl, PHP_URL_HOST);
|
||||
|
||||
if (!is_string($host)) {
|
||||
return 'noreply@example.com';
|
||||
}
|
||||
|
||||
$host = preg_replace('/^www\./i', '', mb_strtolower(trim($host))) ?? '';
|
||||
|
||||
if ($host === '' || !str_contains($host, '.')) {
|
||||
return 'noreply@example.com';
|
||||
}
|
||||
|
||||
return 'noreply@' . $host;
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class TenantRegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function register(array $input): string
|
||||
{
|
||||
$name = trim((string) ($input['tenant_name'] ?? ''));
|
||||
$slug = $this->slugify((string) ($input['tenant_slug'] ?? $name));
|
||||
$ownerName = trim((string) ($input['owner_name'] ?? ''));
|
||||
$ownerEmail = mb_strtolower(trim((string) ($input['owner_email'] ?? '')));
|
||||
$ownerPassword = (string) ($input['owner_password'] ?? '');
|
||||
$plan = (string) ($input['plan'] ?? 'starter');
|
||||
$defaultPriceCents = max(50, (int) round(((float) ($input['default_price_eur'] ?? 1.20)) * 100));
|
||||
|
||||
if ($name === '' || $slug === '' || $ownerName === '' || $ownerEmail === '' || $ownerPassword === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||
}
|
||||
|
||||
if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist ungültig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($ownerPassword) < 12) {
|
||||
throw new RuntimeException('Das Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$allowedPlans = ['starter', 'team', 'business'];
|
||||
|
||||
if (!in_array($plan, $allowedPlans, true)) {
|
||||
$plan = 'starter';
|
||||
}
|
||||
|
||||
$existingTenant = $this->pdo->prepare('SELECT id FROM tenants WHERE slug = :slug LIMIT 1');
|
||||
$existingTenant->execute(['slug' => $slug]);
|
||||
|
||||
if ($existingTenant->fetchColumn()) {
|
||||
throw new RuntimeException('Der gewuenschte Kurzname ist bereits vergeben.');
|
||||
}
|
||||
|
||||
$existingUser = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$existingUser->execute(['email' => $ownerEmail]);
|
||||
|
||||
if ($existingUser->fetchColumn()) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Bitte verwenden Sie eine andere Adresse.');
|
||||
}
|
||||
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$userInsert = $this->pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "user")'
|
||||
);
|
||||
$userInsert->execute([
|
||||
'full_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
'password_hash' => secure_password_hash($ownerPassword),
|
||||
]);
|
||||
$userId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$tenantInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenants (name, slug, plan, status, created_at)
|
||||
VALUES (:name, :slug, :plan, "active", :created_at)'
|
||||
);
|
||||
$tenantInsert->execute([
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'plan' => $plan,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$tenantId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$membershipInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||
VALUES (:tenant_id, :user_id, "owner", "active")'
|
||||
);
|
||||
$membershipInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$settingsInsert = $this->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",
|
||||
:default_product_price_cents,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
)'
|
||||
);
|
||||
$settingsInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'default_product_price_cents' => $defaultPriceCents,
|
||||
]);
|
||||
|
||||
$memberInsert = $this->pdo->prepare(
|
||||
'INSERT INTO members (tenant_id, user_id, display_name, email, status)
|
||||
VALUES (:tenant_id, :user_id, :display_name, :email, "active")'
|
||||
);
|
||||
$memberInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'display_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
]);
|
||||
|
||||
$sourceInsert = $this->pdo->prepare(
|
||||
'INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
|
||||
VALUES (:tenant_id, :code, :name, :channel, 1)'
|
||||
);
|
||||
|
||||
foreach ([
|
||||
['digital_self', 'Selbst gebucht', 'digital'],
|
||||
['paper_sheet', 'Papierliste / Nachtrag', 'paper'],
|
||||
['admin_backoffice', 'Nachgetragen durch Verwaltung', 'admin'],
|
||||
['rfid_reader', 'Kartenleser', 'rfid'],
|
||||
] as [$code, $sourceName, $channel]) {
|
||||
$sourceInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
'name' => $sourceName,
|
||||
'channel' => $channel,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['Kaffee', $defaultPriceCents],
|
||||
['Espresso', $defaultPriceCents],
|
||||
['Tee', max(80, $defaultPriceCents - 20)],
|
||||
] as [$productName, $priceCents]) {
|
||||
$productInsert = $this->pdo->prepare(
|
||||
'INSERT INTO products (tenant_id, name, sku, is_active)
|
||||
VALUES (:tenant_id, :name, :sku, 1)'
|
||||
);
|
||||
$productInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $productName,
|
||||
'sku' => strtolower(str_replace(' ', '-', $productName)),
|
||||
]);
|
||||
$productId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$priceInsert = $this->pdo->prepare(
|
||||
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
||||
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
|
||||
);
|
||||
$priceInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'price_cents' => $priceCents,
|
||||
'valid_from' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $userId, 'tenant.registered', 'tenant', $tenantId, 'success', [
|
||||
'plan' => $plan,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = mb_strtolower($value);
|
||||
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
|
||||
$value = trim($value, '-');
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
<?php
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
function current_path(): string
|
||||
{
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
|
||||
$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 url('/assets/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
function old(string $key, mixed $default = ''): mixed
|
||||
{
|
||||
return $_SESSION['_old'][$key] ?? $default;
|
||||
}
|
||||
|
||||
function remember_old_input(array $input): void
|
||||
{
|
||||
foreach (['password', 'admin_password', 'login_password', 'owner_password', 'db_pass'] as $sensitiveKey) {
|
||||
if (array_key_exists($sensitiveKey, $input)) {
|
||||
unset($input[$sensitiveKey]);
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['_old'] = $input;
|
||||
}
|
||||
|
||||
function clear_old_input(): void
|
||||
{
|
||||
unset($_SESSION['_old']);
|
||||
}
|
||||
|
||||
function csrf_field(Csrf $csrf): string
|
||||
{
|
||||
return '<input type="hidden" name="_token" value="' . e($csrf->token()) . '">';
|
||||
}
|
||||
|
||||
function flash(Session $session, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $session->pullFlash($key, $default);
|
||||
}
|
||||
|
||||
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, '/');
|
||||
|
||||
return '/t/' . rawurlencode($tenantSlug) . $suffix;
|
||||
}
|
||||
|
||||
function money_from_cents(int $amountCents): string
|
||||
{
|
||||
return number_format($amountCents / 100, 2, ',', '.') . ' EUR';
|
||||
}
|
||||
|
||||
function secure_password_hash(string $password): string
|
||||
{
|
||||
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
|
||||
|
||||
return password_hash($password, $algo);
|
||||
}
|
||||
|
||||
function normalize_datetime_input(?string $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$normalized = str_replace('T', ' ', $value);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
|
||||
return $normalized . ':00';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function request_origin_matches_host(): bool
|
||||
{
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if ($host === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$originHost = parse_url((string) $_SERVER[$header], PHP_URL_HOST);
|
||||
|
||||
if (is_string($originHost) && $originHost !== '' && !hash_equals($host, $originHost)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
$rootPath = dirname(__DIR__);
|
||||
|
||||
require_once $rootPath . '/app/Support/helpers.php';
|
||||
require_once $rootPath . '/app/Core/Autoloader.php';
|
||||
|
||||
Autoloader::register($rootPath);
|
||||
Env::load($rootPath . '/.env');
|
||||
|
||||
$config = require $rootPath . '/config/app.php';
|
||||
|
||||
date_default_timezone_set($config['timezone']);
|
||||
|
||||
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');
|
||||
|
||||
$session = new Session();
|
||||
$session->start();
|
||||
|
||||
$csrf = new Csrf($session);
|
||||
|
||||
// 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,
|
||||
'csrf' => $csrf,
|
||||
]);
|
||||
|
||||
return [
|
||||
'rootPath' => $rootPath,
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
'csrf' => $csrf,
|
||||
'database' => $database,
|
||||
'view' => $view,
|
||||
];
|
||||
@@ -1,202 +0,0 @@
|
||||
<?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