0, 'window_start' => $now]; if (file_exists($filePath)) { $content = @file_get_contents($filePath); if ($content !== false) { $decoded = json_decode($content, true); if (is_array($decoded)) { $rateData = $decoded; } } } // Check if window has expired if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) { $rateData = ['count' => 0, 'window_start' => $now]; } // Increment count $rateData['count']++; // Save updated data @file_put_contents($filePath, json_encode($rateData), LOCK_EX); // Check if over limit return $rateData['count'] <= $limit; } /** * Clean up old rate limit files (call periodically) * * Uses DirectoryIterator instead of glob() for better memory efficiency. * A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup. */ public static function cleanupOldFiles(): void { $dir = self::getRateLimitDir(); $lockFile = $dir . '/.cleanup.lock'; $now = time(); $maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows $maxLockAge = 60; // Release stale locks after 60 seconds // Check for existing lock to prevent concurrent cleanups if (file_exists($lockFile)) { $lockAge = $now - filemtime($lockFile); if ($lockAge < $maxLockAge) { return; // Cleanup already in progress } @unlink($lockFile); // Stale lock } // Try to acquire lock if (!@touch($lockFile)) { return; } try { $iterator = new DirectoryIterator($dir); $deleted = 0; $maxDeletes = 50; // Limit deletions per request to avoid blocking foreach ($iterator as $file) { if ($deleted >= $maxDeletes) { break; // Let cron handle the rest } if ($file->isDot() || !$file->isFile()) { continue; } $filename = $file->getFilename(); if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) { continue; } if ($now - $file->getMTime() > $maxAge) { if (@unlink($file->getPathname())) { $deleted++; } } } } finally { @unlink($lockFile); } } /** * Check rate limit for current request (both session and IP) * * @param string $type 'default' or 'api' * @return bool True if request is allowed, false if rate limited */ public static function check(string $type = 'default'): bool { // First check IP-based rate limit (prevents session bypass) if (!self::checkIpRateLimit($type)) { return false; } // Then check session-based rate limit if (session_status() === PHP_SESSION_NONE) { session_start(); } $limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT; $key = 'rate_limit_' . $type; $now = time(); // Initialize rate limit tracking if (!isset($_SESSION[$key])) { $_SESSION[$key] = [ 'count' => 0, 'window_start' => $now ]; } $rateData = &$_SESSION[$key]; // Check if window has expired if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) { // Reset for new window $rateData['count'] = 0; $rateData['window_start'] = $now; } // Increment request count $rateData['count']++; // Check if over limit if ($rateData['count'] > $limit) { return false; } return true; } /** * Apply rate limiting and send error response if exceeded * * @param string $type 'default' or 'api' * @param bool $addHeaders Whether to add rate limit headers to response */ public static function apply(string $type = 'default', bool $addHeaders = true): void { // Periodically clean up old rate limit files (2% chance per request) // Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup if (mt_rand(1, 50) === 1) { self::cleanupOldFiles(); } if (!self::check($type)) { http_response_code(429); header('Content-Type: application/json'); header('Retry-After: ' . self::WINDOW_SECONDS); if ($addHeaders) { self::addHeaders($type); } echo json_encode([ 'success' => false, 'error' => 'Rate limit exceeded. Please try again later.', 'retry_after' => self::WINDOW_SECONDS ]); exit; } // Add rate limit headers to successful responses if ($addHeaders) { self::addHeaders($type); } } /** * Get current rate limit status * * @param string $type 'default' or 'api' * @return array Rate limit status */ public static function getStatus(string $type = 'default'): array { if (session_status() === PHP_SESSION_NONE) { session_start(); } $limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT; $key = 'rate_limit_' . $type; $now = time(); if (!isset($_SESSION[$key])) { return [ 'limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS ]; } $rateData = $_SESSION[$key]; // Check if window has expired if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) { return [ 'limit' => $limit, 'remaining' => $limit, 'reset' => $now + self::WINDOW_SECONDS ]; } return [ 'limit' => $limit, 'remaining' => max(0, $limit - $rateData['count']), 'reset' => $rateData['window_start'] + self::WINDOW_SECONDS ]; } /** * Add rate limit headers to response * * @param string $type 'default' or 'api' */ public static function addHeaders(string $type = 'default'): void { $status = self::getStatus($type); header('X-RateLimit-Limit: ' . $status['limit']); header('X-RateLimit-Remaining: ' . $status['remaining']); header('X-RateLimit-Reset: ' . $status['reset']); } }