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(); } }