52 lines
2.0 KiB
PHP
52 lines
2.0 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace FDMTech;
|
|
|
|
/** Shared per-IP fixed window. Uses the peer IP only, never untrusted forwarded headers. */
|
|
final class RateLimiter
|
|
{
|
|
public function __construct(private Config $config) {}
|
|
|
|
public function consume(string $ip): bool
|
|
{
|
|
$key = $this->config->get('APP_KEY');
|
|
if (strlen($key) < 32) {
|
|
throw new \RuntimeException('APP_KEY must have at least 32 characters');
|
|
}
|
|
$directory = $this->config->get('RATE_LIMIT_DIR') ?: $this->config->root . '/var/rate-limits';
|
|
if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) {
|
|
throw new \RuntimeException('Rate limit storage unavailable');
|
|
}
|
|
$file = $directory . '/' . hash_hmac('sha256', $ip, $key) . '.json';
|
|
$handle = @fopen($file, 'c+');
|
|
if (!$handle) {
|
|
throw new \RuntimeException('Rate limit storage unavailable');
|
|
}
|
|
try {
|
|
if (!flock($handle, LOCK_EX)) {
|
|
throw new \RuntimeException('Rate limit lock unavailable');
|
|
}
|
|
$now = time();
|
|
$window = $this->config->number('INQUIRY_RATE_WINDOW', 900);
|
|
$record = json_decode(stream_get_contents($handle), true);
|
|
if (!is_array($record) || !isset($record['start'], $record['count']) || $record['start'] + $window <= $now) {
|
|
$record = ['start' => $now, 'count' => 0];
|
|
}
|
|
if ($record['count'] >= $this->config->number('INQUIRY_RATE_MAX', 5)) {
|
|
return false;
|
|
}
|
|
$record['count']++;
|
|
$payload = json_encode($record, JSON_THROW_ON_ERROR);
|
|
rewind($handle);
|
|
if (!ftruncate($handle, 0) || fwrite($handle, $payload) !== strlen($payload) || !fflush($handle)) {
|
|
throw new \RuntimeException('Rate limit write unavailable');
|
|
}
|
|
return true;
|
|
} finally {
|
|
flock($handle, LOCK_UN);
|
|
fclose($handle);
|
|
}
|
|
}
|
|
}
|