weitere Bearibeitung

This commit is contained in:
2026-06-17 16:45:14 +02:00
parent 06645f1e9c
commit 1891ec0a51
38 changed files with 2720 additions and 109 deletions
+271
View File
@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace App\Services;
use DateTimeImmutable;
use DateTimeZone;
use PDO;
use RuntimeException;
/**
* SQLite-kompatible Version des RateLimiterService.
* Ersetzt MySQL-spezifisches ON DUPLICATE KEY UPDATE durch SELECT+INSERT.
*/
final class RateLimiterSqliteService
{
private readonly DateTimeZone $utc;
public function __construct(
private readonly PDO $pdo,
private readonly array $config = []
) {
$this->utc = new DateTimeZone('UTC');
}
public function inspect(string $action, string $scope): array
{
$policy = $this->resolvePolicy($action);
$statement = $this->pdo->prepare(
'SELECT attempts, window_start, last_attempt_at, blocked_until
FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key
LIMIT 1'
);
$statement->execute([
'action_key' => $policy['action'],
'bucket_key' => $this->bucketKey($scope),
]);
return $this->buildState($policy, $statement->fetch() ?: null, time());
}
public function hit(string $action, string $scope): array
{
$policy = $this->resolvePolicy($action);
$bucketKey = $this->bucketKey($scope);
$nowTs = time();
$now = $this->formatTimestamp($nowTs);
$this->pdo->beginTransaction();
try {
// SQLite-kompatibler Upsert: SELECT + INSERT falls nicht vorhanden
$existingStmt = $this->pdo->prepare(
'SELECT id FROM rate_limits WHERE action_key = :action_key AND bucket_key = :bucket_key LIMIT 1'
);
$existingStmt->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
if (!$existingStmt->fetchColumn()) {
$insertStmt = $this->pdo->prepare(
'INSERT INTO rate_limits (action_key, bucket_key, attempts, window_start, last_attempt_at, created_at)
VALUES (:action_key, :bucket_key, 0, :window_start, :last_attempt_at, :created_at)'
);
$insertStmt->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
'window_start' => $now,
'last_attempt_at' => $now,
'created_at' => $now,
]);
}
$selectStatement = $this->pdo->prepare(
'SELECT attempts, window_start, last_attempt_at, blocked_until
FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key
LIMIT 1'
);
$selectStatement->execute([
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
$row = $selectStatement->fetch();
if (!$row) {
throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.');
}
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
$isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs;
$resetWindow = $windowStartTs === null
|| ($windowStartTs + $policy['window_seconds']) <= $nowTs
|| ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs);
if ($isBlocked) {
$updatedRow = $row;
} else {
$attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1);
$windowStart = $resetWindow ? $now : (string) $row['window_start'];
$blockedUntil = $attempts >= $policy['max_attempts']
? $this->formatTimestamp($nowTs + $policy['block_seconds'])
: null;
$updateStatement = $this->pdo->prepare(
'UPDATE rate_limits
SET attempts = :attempts,
window_start = :window_start,
last_attempt_at = :last_attempt_at,
blocked_until = :blocked_until
WHERE action_key = :action_key
AND bucket_key = :bucket_key'
);
$updateStatement->execute([
'attempts' => $attempts,
'window_start' => $windowStart,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil,
'action_key' => $policy['action'],
'bucket_key' => $bucketKey,
]);
$updatedRow = [
'attempts' => $attempts,
'window_start' => $windowStart,
'last_attempt_at' => $now,
'blocked_until' => $blockedUntil,
];
}
$this->pdo->commit();
} catch (\Throwable $throwable) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $throwable;
}
return $this->buildState($policy, $updatedRow, $nowTs);
}
public function clear(string $action, string $scope): void
{
$policy = $this->resolvePolicy($action);
$statement = $this->pdo->prepare(
'DELETE FROM rate_limits
WHERE action_key = :action_key
AND bucket_key = :bucket_key'
);
$statement->execute([
'action_key' => $policy['action'],
'bucket_key' => $this->bucketKey($scope),
]);
}
public function tooManyAttempts(string $action, string $scope): bool
{
return !$this->inspect($action, $scope)['allowed'];
}
public function prune(int $staleAfterSeconds = 604800): int
{
$cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds));
$statement = $this->pdo->prepare(
'DELETE FROM rate_limits
WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff)
OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)'
);
$statement->execute(['cutoff' => $cutoff]);
return $statement->rowCount();
}
private function buildState(array $policy, ?array $row, int $nowTs): array
{
$attempts = 0;
$blockedUntil = null;
$retryAfter = 0;
$allowed = true;
$resetAtTs = $nowTs + $policy['window_seconds'];
if ($row) {
$windowStartTs = $this->timestampFromDatabase($row['window_start']);
$blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
$attempts = (int) $row['attempts'];
$blockedUntil = (string) $row['blocked_until'];
$retryAfter = max(1, $blockedUntilTs - $nowTs);
$allowed = false;
$resetAtTs = $blockedUntilTs;
} elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) {
$attempts = (int) $row['attempts'];
$resetAtTs = $windowStartTs + $policy['window_seconds'];
}
}
return [
'action' => $policy['action'],
'allowed' => $allowed,
'attempts' => $attempts,
'max_attempts' => $policy['max_attempts'],
'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts),
'window_seconds' => $policy['window_seconds'],
'block_seconds' => $policy['block_seconds'],
'retry_after_seconds' => $retryAfter,
'reset_at' => $this->formatTimestamp($resetAtTs),
'blocked_until' => $blockedUntil,
];
}
private function resolvePolicy(string $action): array
{
$normalizedAction = mb_strtolower(trim($action));
if ($normalizedAction === '') {
throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.');
}
$policy = $this->config[$normalizedAction] ?? null;
if (!is_array($policy)) {
throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction));
}
return [
'action' => $normalizedAction,
'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)),
'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)),
'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)),
];
}
private function bucketKey(string $scope): string
{
$scope = trim($scope);
if ($scope === '') {
throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.');
}
return hash('sha256', $scope);
}
private function formatTimestamp(int $timestamp): string
{
return gmdate('Y-m-d H:i:s', $timestamp);
}
private function timestampFromDatabase(?string $value): ?int
{
if ($value === null || trim($value) === '') {
return null;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
if (!$date instanceof DateTimeImmutable) {
return null;
}
return $date->getTimestamp();
}
}