Files
visit.waterschurch.org/public/api/lib/RateLimit.php
T

50 lines
2.0 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
/** Fixed-window rate limiter backed by the database (works on shared hosting without Redis). */
final class RateLimit
{
/**
* @return array{allowed:bool, retryAfter:int}
*/
public static function hit(string $bucket, int $limit, int $windowSeconds): array
{
$pdo = Db::pdo();
$now = time();
$pdo->beginTransaction();
try {
$st = $pdo->prepare('SELECT hits, window_start FROM rate_limits WHERE bucket = ?');
$st->execute([$bucket]);
$row = $st->fetch();
if (!$row || ($now - (int)$row['window_start']) >= $windowSeconds) {
if (Db::isSqlite()) {
$pdo->prepare('INSERT OR REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
} else {
$pdo->prepare('REPLACE INTO rate_limits (bucket, hits, window_start) VALUES (?, 1, ?)')->execute([$bucket, $now]);
}
$pdo->commit();
return ['allowed' => true, 'retryAfter' => 0];
}
$hits = (int)$row['hits'] + 1;
$pdo->prepare('UPDATE rate_limits SET hits = ? WHERE bucket = ?')->execute([$hits, $bucket]);
$pdo->commit();
if ($hits > $limit) {
return ['allowed' => false, 'retryAfter' => max(1, $windowSeconds - ($now - (int)$row['window_start']))];
}
return ['allowed' => true, 'retryAfter' => 0];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
Log::warn('Rate limit storage failed; allowing request', ['error' => $e->getMessage()]);
return ['allowed' => true, 'retryAfter' => 0];
}
}
/** Stable, non-reversible bucket key for an IP. */
public static function ipBucket(string $prefix, string $ip): string
{
return $prefix . ':' . substr(hash('sha256', $ip . '|' . (Config::get('HASH_SALT') ?? 'visit')), 0, 32);
}
}