| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110 |
- <?php
- namespace Libs;
- /**
- * Rate limiter de janela fixa com armazenamento em arquivo.
- *
- * Em produção a aplicação roda sob `php -S` (servidor embutido do PHP), que
- * atende requisições de forma serial e NÃO mantém estado estático entre elas.
- * Por isso um contador em memória não funcionaria; persistimos em arquivo.
- *
- * Cada chave vira um arquivo JSON: { "count": <int>, "reset_at": <timestamp> }.
- * Ao expirar a janela (now >= reset_at) o contador zera automaticamente.
- */
- final class RateLimiter
- {
- /**
- * Diretório de armazenamento. Configurável por RATELIMIT_DIR; por padrão
- * usa o diretório temporário do sistema.
- */
- private static function dir(): string
- {
- $dir = $_ENV['RATELIMIT_DIR'] ?? (sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nettown_ratelimit');
- if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
- // Sem diretório utilizável o limiter vira no-op silencioso (fail-open).
- Logger::warning('RateLimiter storage dir unavailable', ['dir' => $dir]);
- }
- return $dir;
- }
- private static function path(string $key): string
- {
- return self::dir() . DIRECTORY_SEPARATOR . hash('sha256', $key) . '.json';
- }
- private static function read(string $key): array
- {
- $file = self::path($key);
- if (!is_file($file)) {
- return ['count' => 0, 'reset_at' => 0];
- }
- $raw = @file_get_contents($file);
- $data = $raw !== false ? json_decode($raw, true) : null;
- if (!is_array($data) || !isset($data['count'], $data['reset_at'])) {
- return ['count' => 0, 'reset_at' => 0];
- }
- return ['count' => (int) $data['count'], 'reset_at' => (int) $data['reset_at']];
- }
- private static function write(string $key, array $data): void
- {
- $encoded = json_encode($data);
- if ($encoded === false) {
- return;
- }
- @file_put_contents(self::path($key), $encoded, LOCK_EX);
- }
- /**
- * Segundos restantes de bloqueio, ou 0 se ainda há tentativas disponíveis.
- */
- public static function retryAfter(string $key, int $maxAttempts): int
- {
- $data = self::read($key);
- $now = time();
- if ($now >= $data['reset_at']) {
- return 0;
- }
- if ($data['count'] < $maxAttempts) {
- return 0;
- }
- return $data['reset_at'] - $now;
- }
- /**
- * Registra uma tentativa dentro da janela. Inicia uma nova janela quando
- * a anterior expirou.
- */
- public static function hit(string $key, int $windowSeconds): void
- {
- $data = self::read($key);
- $now = time();
- if ($now >= $data['reset_at']) {
- $data = ['count' => 0, 'reset_at' => $now + $windowSeconds];
- }
- $data['count']++;
- self::write($key, $data);
- }
- /**
- * Zera o contador (ex.: após autenticação bem-sucedida).
- */
- public static function clear(string $key): void
- {
- $file = self::path($key);
- if (is_file($file)) {
- @unlink($file);
- }
- }
- }
|