ratelimit.php 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. <?php
  2. declare(strict_types=1);
  3. // File-based per-IP rate limiting for hosting without Redis. State lives
  4. // under storage/ratelimit/ and is therefore not web-readable.
  5. function manageRateLimitClientIp(): string
  6. {
  7. $ip = trim((string) ($_SERVER["REMOTE_ADDR"] ?? ""));
  8. return $ip !== "" ? $ip : "unknown";
  9. }
  10. function manageRateLimitStatePath(string $name): string
  11. {
  12. $name = preg_replace("/[^a-z0-9_-]/", "", strtolower($name)) ?? "";
  13. if ($name === "") {
  14. $name = "default";
  15. }
  16. return manageStorageDir() . "ratelimit" . DIRECTORY_SEPARATOR . $name . ".json";
  17. }
  18. /**
  19. * @return bool true while under the limit; increments the counter when $consume
  20. */
  21. function manageRateLimitEvaluate(
  22. string $name,
  23. int $maxAttempts,
  24. int $windowSeconds,
  25. bool $consume,
  26. ): bool {
  27. if ($maxAttempts < 1 || $windowSeconds < 1) {
  28. return true;
  29. }
  30. $path = manageRateLimitStatePath($name);
  31. $dir = dirname($path);
  32. if (!is_dir($dir) && !@mkdir($dir, 02775, true) && !is_dir($dir)) {
  33. // Without usable state the limiter must not lock anybody out.
  34. return true;
  35. }
  36. @chmod($dir, 02775);
  37. $handle = fopen($path, "c+");
  38. if ($handle === false) {
  39. return true;
  40. }
  41. if (!flock($handle, LOCK_EX)) {
  42. fclose($handle);
  43. return true;
  44. }
  45. rewind($handle);
  46. $raw = stream_get_contents($handle);
  47. $data = [];
  48. if (is_string($raw) && trim($raw) !== "") {
  49. $decoded = json_decode($raw, true);
  50. if (is_array($decoded)) {
  51. $data = $decoded;
  52. }
  53. }
  54. $now = time();
  55. $ip = manageRateLimitClientIp();
  56. foreach ($data as $key => $entry) {
  57. if (!is_array($entry)) {
  58. unset($data[$key]);
  59. continue;
  60. }
  61. if ($now - (int) ($entry["w"] ?? 0) > $windowSeconds * 2) {
  62. unset($data[$key]);
  63. }
  64. }
  65. $entry = isset($data[$ip]) && is_array($data[$ip]) ? $data[$ip] : null;
  66. $windowStart = (int) ($entry["w"] ?? $now);
  67. $count = (int) ($entry["c"] ?? 0);
  68. if ($entry === null || $now - $windowStart > $windowSeconds) {
  69. $windowStart = $now;
  70. $count = 0;
  71. }
  72. if ($count >= $maxAttempts) {
  73. flock($handle, LOCK_UN);
  74. fclose($handle);
  75. return false;
  76. }
  77. if ($consume) {
  78. $data[$ip] = ["w" => $windowStart, "c" => $count + 1];
  79. $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
  80. if ($payload !== false) {
  81. ftruncate($handle, 0);
  82. rewind($handle);
  83. fwrite($handle, $payload);
  84. fflush($handle);
  85. @chmod($path, 0660);
  86. }
  87. }
  88. flock($handle, LOCK_UN);
  89. fclose($handle);
  90. return true;
  91. }
  92. function manageRateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool
  93. {
  94. return manageRateLimitEvaluate($name, $maxAttempts, $windowSeconds, true);
  95. }
  96. function manageRateLimitClearIp(string $name): void
  97. {
  98. $path = manageRateLimitStatePath($name);
  99. if (!is_file($path)) {
  100. return;
  101. }
  102. $handle = fopen($path, "c+");
  103. if ($handle === false) {
  104. return;
  105. }
  106. if (!flock($handle, LOCK_EX)) {
  107. fclose($handle);
  108. return;
  109. }
  110. rewind($handle);
  111. $raw = stream_get_contents($handle);
  112. $data = is_string($raw) && trim($raw) !== "" ? json_decode($raw, true) : [];
  113. if (is_array($data)) {
  114. unset($data[manageRateLimitClientIp()]);
  115. $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
  116. if ($payload !== false) {
  117. ftruncate($handle, 0);
  118. rewind($handle);
  119. fwrite($handle, $payload);
  120. fflush($handle);
  121. }
  122. }
  123. flock($handle, LOCK_UN);
  124. fclose($handle);
  125. }
  126. function manageLoginIsRateLimited(): bool
  127. {
  128. return !manageRateLimitEvaluate(
  129. "admin-login",
  130. (int) MANAGE_LOGIN_RATE_LIMIT_MAX,
  131. (int) MANAGE_LOGIN_RATE_LIMIT_WINDOW,
  132. false,
  133. );
  134. }
  135. function manageLoginRecordFailure(): void
  136. {
  137. manageRateLimitTryConsume(
  138. "admin-login",
  139. (int) MANAGE_LOGIN_RATE_LIMIT_MAX,
  140. (int) MANAGE_LOGIN_RATE_LIMIT_WINDOW,
  141. );
  142. }
  143. function manageLoginClearRateLimit(): void
  144. {
  145. manageRateLimitClearIp("admin-login");
  146. }