50 lines
2.0 KiB
PHP
50 lines
2.0 KiB
PHP
<?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);
|
||
|
|
}
|
||
|
|
}
|