int, 'window_seconds' => int], ...] * @throws RuntimeException Se il rate limit è superato */ public static function check(string $key, array $limits): void { $timestamps = self::loadTimestamps($key); $now = time(); foreach ($limits as $limit) { $windowStart = $now - $limit['window_seconds']; $hitsInWindow = count(array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart)); if ($hitsInWindow >= $limit['max']) { // Calcola secondi rimanenti fino alla scadenza del hit più vecchio nella finestra $oldestInWindow = array_values(array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart)); sort($oldestInWindow); $retryAfter = ($oldestInWindow[0] ?? $now) + $limit['window_seconds'] - $now; $retryAfter = max(1, $retryAfter); throw new RuntimeException( "Troppi tentativi. Riprova tra {$retryAfter} secondi.", 429 ); } } } /** * Registra un hit per la chiave specificata * * @param string $key Identificatore univoco */ public static function increment(string $key): void { $timestamps = self::loadTimestamps($key); $timestamps[] = time(); self::saveTimestamps($key, $timestamps); } /** * Verifica se il rate limit è superato senza lanciare eccezione * * @param string $key Identificatore univoco * @param array $limits Array di finestre * @return bool True se il limite è superato */ public static function isExceeded(string $key, array $limits): bool { try { self::check($key, $limits); return false; } catch (RuntimeException) { return true; } } /** * Restituisce i tentativi rimanenti per ogni finestra * * @param string $key Identificatore univoco * @param array $limits Array di finestre * @return array Array di ['max' => int, 'window_seconds' => int, 'remaining' => int, 'reset_at' => int] */ public static function getRemaining(string $key, array $limits): array { $timestamps = self::loadTimestamps($key); $now = time(); $result = []; foreach ($limits as $limit) { $windowStart = $now - $limit['window_seconds']; $hitsInWindow = array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart); $hitCount = count($hitsInWindow); // Calcola quando si resetta la finestra (scadenza del hit più vecchio) $resetAt = $now + $limit['window_seconds']; if (!empty($hitsInWindow)) { $oldest = min($hitsInWindow); $resetAt = $oldest + $limit['window_seconds']; } $result[] = [ 'max' => $limit['max'], 'window_seconds' => $limit['window_seconds'], 'remaining' => max(0, $limit['max'] - $hitCount), 'reset_at' => $resetAt, ]; } return $result; } /** * Pulisce i file di rate limit scaduti * Da chiamare periodicamente (es. cron ogni 10 minuti) */ public static function cleanup(): void { $dir = self::STORAGE_DIR; if (!is_dir($dir)) { return; } $now = time(); // Finestra massima ragionevole: 1 ora. Elimina file non modificati da più di 2 ore $maxAge = 7200; $files = glob($dir . '/*.json'); if ($files === false) { return; } foreach ($files as $file) { $mtime = filemtime($file); if ($mtime !== false && ($now - $mtime) > $maxAge) { @unlink($file); continue; } // Pulisci anche i timestamp scaduti all'interno dei file ancora attivi $content = @file_get_contents($file); if ($content === false) { continue; } $timestamps = json_decode($content, true); if (!is_array($timestamps)) { @unlink($file); continue; } $cutoff = $now - $maxAge; $filtered = array_values(array_filter($timestamps, fn(int $ts): bool => $ts > $cutoff)); if (empty($filtered)) { @unlink($file); } else { @file_put_contents($file, json_encode($filtered), LOCK_EX); } } } // ───────────────────────────────────────────────────────────────────────── // Metodi privati // ───────────────────────────────────────────────────────────────────────── /** * Carica i timestamp dal file di storage */ private static function loadTimestamps(string $key): array { $file = self::getFilePath($key); if (!file_exists($file)) { return []; } $content = @file_get_contents($file); if ($content === false) { return []; } $timestamps = json_decode($content, true); return is_array($timestamps) ? $timestamps : []; } /** * Salva i timestamp nel file di storage */ private static function saveTimestamps(string $key, array $timestamps): void { $dir = self::STORAGE_DIR; if (!is_dir($dir)) { if (!mkdir($dir, 0700, true) && !is_dir($dir)) { throw new RuntimeException("Impossibile creare directory rate limit: {$dir}"); } } $file = self::getFilePath($key); if (file_put_contents($file, json_encode($timestamps), LOCK_EX) === false) { throw new RuntimeException("Impossibile scrivere file rate limit: {$file}"); } } /** * Genera il percorso del file per una chiave */ private static function getFilePath(string $key): string { return self::STORAGE_DIR . '/' . md5($key) . '.json'; } }