Files
FDMTech/app/RateLimiter.php
T

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);
}
}
}