weitere Bearibeitung
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user