221 lines
6.1 KiB
PHP
221 lines
6.1 KiB
PHP
<?php
|
|
|
|
/**
|
|
* BOHA Automation - IP-based Rate Limiter
|
|
*
|
|
* Implements rate limiting using file-based storage to prevent abuse
|
|
* and protect API endpoints from excessive requests.
|
|
*
|
|
* Features:
|
|
* - IP-based rate limiting
|
|
* - Configurable limits per endpoint
|
|
* - File-based storage (no database dependency)
|
|
* - Automatic cleanup of expired entries
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
class RateLimiter
|
|
{
|
|
/** @var string Directory for storing rate limit data */
|
|
private string $storagePath;
|
|
|
|
/** @var int Default requests per minute */
|
|
private int $defaultLimit = 60;
|
|
|
|
/** @var int Time window in seconds (1 minute) */
|
|
private int $windowSeconds = 60;
|
|
|
|
/** @var bool Whether storage directory has been verified */
|
|
private static bool $dirVerified = false;
|
|
|
|
/**
|
|
* Initialize the rate limiter
|
|
*
|
|
* @param string|null $storagePath Path to store rate limit files
|
|
*/
|
|
public function __construct(?string $storagePath = null)
|
|
{
|
|
$this->storagePath = $storagePath
|
|
?? (defined('RATE_LIMIT_STORAGE_PATH')
|
|
? RATE_LIMIT_STORAGE_PATH
|
|
: __DIR__ . '/../rate_limits');
|
|
|
|
// Only check directory once per process (static flag)
|
|
if (!self::$dirVerified) {
|
|
if (!is_dir($this->storagePath)) {
|
|
mkdir($this->storagePath, 0755, true);
|
|
}
|
|
self::$dirVerified = true;
|
|
}
|
|
|
|
// Cleanup old files very rarely (0.1% of requests instead of 1%)
|
|
if (rand(1, 1000) === 1) {
|
|
$this->cleanup();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the request should be rate limited
|
|
*
|
|
* Uses exclusive file locking for the entire read-check-increment-write cycle
|
|
* to prevent race conditions under concurrent requests.
|
|
*
|
|
* @param string $endpoint Endpoint identifier (e.g., 'login', 'session')
|
|
* @param int|null $limit Custom limit for this endpoint (requests per minute)
|
|
* @return bool True if request is allowed, false if rate limited
|
|
*/
|
|
/** @var bool Fail-closed: blokuj request pri chybe FS (pro kriticke endpointy) */
|
|
private bool $failClosed = false;
|
|
|
|
public function setFailClosed(bool $failClosed = true): self
|
|
{
|
|
$this->failClosed = $failClosed;
|
|
return $this;
|
|
}
|
|
|
|
public function check(string $endpoint, ?int $limit = null): bool
|
|
{
|
|
$limit = $limit ?? $this->defaultLimit;
|
|
$ip = $this->getClientIp();
|
|
$key = $this->getKey($ip, $endpoint);
|
|
$file = $this->storagePath . '/' . $key . '.json';
|
|
$now = time();
|
|
|
|
// Open file with exclusive lock for atomic read-check-increment-write
|
|
$fp = @fopen($file, 'c+');
|
|
if (!$fp) {
|
|
return !$this->failClosed;
|
|
}
|
|
|
|
if (!flock($fp, LOCK_EX)) {
|
|
fclose($fp);
|
|
return !$this->failClosed;
|
|
}
|
|
|
|
// Read current data under lock
|
|
$content = stream_get_contents($fp);
|
|
$data = $content ? json_decode($content, true) : null;
|
|
|
|
if (is_array($data) && $data['window_start'] > ($now - $this->windowSeconds)) {
|
|
// Same window - check count
|
|
if ($data['count'] >= $limit) {
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
return false; // Rate limited
|
|
}
|
|
$data['count']++;
|
|
} else {
|
|
// New window - reset counter
|
|
$data = ['window_start' => $now, 'count' => 1];
|
|
}
|
|
|
|
// Write updated data
|
|
ftruncate($fp, 0);
|
|
rewind($fp);
|
|
fwrite($fp, json_encode($data));
|
|
fflush($fp);
|
|
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Enforce rate limit and return 429 response if exceeded
|
|
*
|
|
* @param string $endpoint Endpoint identifier
|
|
* @param int|null $limit Custom limit for this endpoint
|
|
*/
|
|
public function enforce(string $endpoint, ?int $limit = null): void
|
|
{
|
|
if (!$this->check($endpoint, $limit)) {
|
|
$this->sendRateLimitResponse();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send 429 Too Many Requests response and exit
|
|
*/
|
|
private function sendRateLimitResponse(): void
|
|
{
|
|
http_response_code(429);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Retry-After: ' . $this->windowSeconds);
|
|
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => 'Prilis mnoho pozadavku. Zkuste to prosim pozdeji.',
|
|
'retry_after' => $this->windowSeconds,
|
|
], JSON_UNESCAPED_UNICODE);
|
|
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Get client IP address
|
|
*
|
|
* @return string IP address
|
|
*/
|
|
private function getClientIp(): string
|
|
{
|
|
// Use the global helper if available
|
|
if (function_exists('getClientIp')) {
|
|
return getClientIp();
|
|
}
|
|
|
|
// Fallback: use only REMOTE_ADDR (cannot be spoofed)
|
|
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
|
}
|
|
|
|
/**
|
|
* Generate storage key for IP and endpoint
|
|
*
|
|
* @param string $ip Client IP
|
|
* @param string $endpoint Endpoint identifier
|
|
* @return string Storage key (filename-safe)
|
|
*/
|
|
private function getKey(string $ip, string $endpoint): string
|
|
{
|
|
// Create a safe filename from IP and endpoint
|
|
return md5($ip . ':' . $endpoint);
|
|
}
|
|
|
|
/**
|
|
* Cleanup expired rate limit files
|
|
*
|
|
* Removes files older than the time window to prevent disk space issues
|
|
*/
|
|
private function cleanup(): void
|
|
{
|
|
if (!is_dir($this->storagePath)) {
|
|
return;
|
|
}
|
|
|
|
$files = glob($this->storagePath . '/*.json');
|
|
$expireTime = time() - ($this->windowSeconds * 2); // Keep for 2x window to be safe
|
|
|
|
foreach ($files as $file) {
|
|
if (filemtime($file) < $expireTime) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all rate limit data (useful for testing)
|
|
*/
|
|
public function clearAll(): void
|
|
{
|
|
if (!is_dir($this->storagePath)) {
|
|
return;
|
|
}
|
|
|
|
$files = glob($this->storagePath . '/*.json');
|
|
foreach ($files as $file) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
}
|