RateLimiter.php 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. <?php
  2. namespace Libs;
  3. /**
  4. * Rate limiter de janela fixa com armazenamento em arquivo.
  5. *
  6. * Em produção a aplicação roda sob `php -S` (servidor embutido do PHP), que
  7. * atende requisições de forma serial e NÃO mantém estado estático entre elas.
  8. * Por isso um contador em memória não funcionaria; persistimos em arquivo.
  9. *
  10. * Cada chave vira um arquivo JSON: { "count": <int>, "reset_at": <timestamp> }.
  11. * Ao expirar a janela (now >= reset_at) o contador zera automaticamente.
  12. */
  13. final class RateLimiter
  14. {
  15. /**
  16. * Diretório de armazenamento. Configurável por RATELIMIT_DIR; por padrão
  17. * usa o diretório temporário do sistema.
  18. */
  19. private static function dir(): string
  20. {
  21. $dir = $_ENV['RATELIMIT_DIR'] ?? (sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nettown_ratelimit');
  22. if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
  23. // Sem diretório utilizável o limiter vira no-op silencioso (fail-open).
  24. Logger::warning('RateLimiter storage dir unavailable', ['dir' => $dir]);
  25. }
  26. return $dir;
  27. }
  28. private static function path(string $key): string
  29. {
  30. return self::dir() . DIRECTORY_SEPARATOR . hash('sha256', $key) . '.json';
  31. }
  32. private static function read(string $key): array
  33. {
  34. $file = self::path($key);
  35. if (!is_file($file)) {
  36. return ['count' => 0, 'reset_at' => 0];
  37. }
  38. $raw = @file_get_contents($file);
  39. $data = $raw !== false ? json_decode($raw, true) : null;
  40. if (!is_array($data) || !isset($data['count'], $data['reset_at'])) {
  41. return ['count' => 0, 'reset_at' => 0];
  42. }
  43. return ['count' => (int) $data['count'], 'reset_at' => (int) $data['reset_at']];
  44. }
  45. private static function write(string $key, array $data): void
  46. {
  47. $encoded = json_encode($data);
  48. if ($encoded === false) {
  49. return;
  50. }
  51. @file_put_contents(self::path($key), $encoded, LOCK_EX);
  52. }
  53. /**
  54. * Segundos restantes de bloqueio, ou 0 se ainda há tentativas disponíveis.
  55. */
  56. public static function retryAfter(string $key, int $maxAttempts): int
  57. {
  58. $data = self::read($key);
  59. $now = time();
  60. if ($now >= $data['reset_at']) {
  61. return 0;
  62. }
  63. if ($data['count'] < $maxAttempts) {
  64. return 0;
  65. }
  66. return $data['reset_at'] - $now;
  67. }
  68. /**
  69. * Registra uma tentativa dentro da janela. Inicia uma nova janela quando
  70. * a anterior expirou.
  71. */
  72. public static function hit(string $key, int $windowSeconds): void
  73. {
  74. $data = self::read($key);
  75. $now = time();
  76. if ($now >= $data['reset_at']) {
  77. $data = ['count' => 0, 'reset_at' => $now + $windowSeconds];
  78. }
  79. $data['count']++;
  80. self::write($key, $data);
  81. }
  82. /**
  83. * Zera o contador (ex.: após autenticação bem-sucedida).
  84. */
  85. public static function clear(string $key): void
  86. {
  87. $file = self::path($key);
  88. if (is_file($file)) {
  89. @unlink($file);
  90. }
  91. }
  92. }