apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); } public function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array { return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); } public function isBlocked(string $scope, string $subject): array { $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return ['allowed' => true, 'retry_after' => 0]; } try { $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); if (!is_array($row)) { return ['allowed' => true, 'retry_after' => 0]; } $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? '')); if ($blockedUntilTs === null) { return ['allowed' => true, 'retry_after' => 0]; } $nowTs = time(); if ($blockedUntilTs <= $nowTs) { $this->rateLimitRepository->updateStateById( (int) ($row['id'] ?? 0), 0, gmdate('Y-m-d H:i:s', $nowTs), null ); return ['allowed' => true, 'retry_after' => 0]; } return [ 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; } catch (\Throwable $exception) { // Fail-open: never lock users out because of rate limit storage issues. return ['allowed' => true, 'retry_after' => 0]; } } public function reset(string $scope, string $subject): void { $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return; } try { $this->rateLimitRepository->deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']); } catch (\Throwable $exception) { // Ignore reset failures. } } private function apply( string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds, bool $increment ): array { $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return ['allowed' => true, 'retry_after' => 0]; } $maxHits = max(1, $maxHits); $windowSeconds = max(1, $windowSeconds); $blockSeconds = max(1, $blockSeconds); try { $nowTs = time(); $nowSql = gmdate('Y-m-d H:i:s', $nowTs); for ($attempt = 0; $attempt < 2; $attempt++) { $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); if (!is_array($row)) { if (!$increment) { return ['allowed' => true, 'retry_after' => 0]; } $hits = 1; $created = $this->rateLimitRepository->create( $normalized['scope'], $normalized['subject_hash'], $hits, $nowSql, null ); if ($created) { return ['allowed' => true, 'retry_after' => 0]; } continue; } $id = (int) ($row['id'] ?? 0); if ($id <= 0) { return ['allowed' => true, 'retry_after' => 0]; } $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? '')); if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) { return [ 'allowed' => false, 'retry_after' => max(1, $blockedUntilTs - $nowTs), ]; } $windowStartedTs = $this->parseTimestamp((string) ($row['window_started_at'] ?? '')); if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) { $windowStartedTs = $nowTs; $hits = 0; } else { $hits = max(0, (int) ($row['hits'] ?? 0)); } if ($increment) { $hits++; } $newBlockedUntilTs = null; if ($hits > $maxHits) { $newBlockedUntilTs = $nowTs + $blockSeconds; } $this->rateLimitRepository->updateStateById( $id, $hits, gmdate('Y-m-d H:i:s', $windowStartedTs), $newBlockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $newBlockedUntilTs) : null ); if ($newBlockedUntilTs !== null) { return [ 'allowed' => false, 'retry_after' => max(1, $newBlockedUntilTs - $nowTs), ]; } return ['allowed' => true, 'retry_after' => 0]; } return ['allowed' => true, 'retry_after' => 0]; } catch (\Throwable $exception) { // Fail-open on storage failures. return ['allowed' => true, 'retry_after' => 0]; } } private function normalize(string $scope, string $subject): ?array { $scope = strtolower(trim($scope)); $subject = trim($subject); if ($scope === '' || $subject === '') { return null; } if (strlen($scope) > 64) { $scope = substr($scope, 0, 64); } return [ 'scope' => $scope, 'subject_hash' => hash(self::HASH_ALGO, $subject), ]; } private function parseTimestamp(string $value): ?int { $value = trim($value); if ($value === '') { return null; } $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, new DateTimeZone('UTC')); if ($date instanceof DateTimeImmutable) { return $date->getTimestamp(); } $timestamp = strtotime($value . ' UTC'); if ($timestamp === false || $timestamp <= 0) { return null; } return $timestamp; } }