Initial commit
This commit is contained in:
220
api/includes/RateLimiter.php
Normal file
220
api/includes/RateLimiter.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user