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); } }