[], 'blocked_until' => 0]; if (file_exists($rateFile)) { $contents = @file_get_contents($rateFile); if ($contents) { $decoded = @json_decode($contents, true); if (is_array($decoded)) { $data = $decoded; } } } // Check if currently blocked if (isset($data['blocked_until']) && $data['blocked_until'] > $now) { return false; } // Clean old requests outside the window $windowStart = $now - $window; $data['requests'] = array_filter($data['requests'], function($timestamp) use ($windowStart) { return $timestamp > $windowStart; }); $data['requests'] = array_values($data['requests']); // Check if over limit if (count($data['requests']) >= $limit) { // Block for remaining window time $data['blocked_until'] = $now + $window; self::saveRateData($rateFile, $data); self::logRateLimit($identifier, $action, count($data['requests'])); return false; } // Add current request $data['requests'][] = $now; $data['blocked_until'] = 0; self::saveRateData($rateFile, $data); // Probabilistic cleanup if (rand(1, self::CLEANUP_PROBABILITY) === 1) { self::cleanup(); } return true; } /** * Save rate data to file * * @param string $rateFile File path * @param array $data Rate data */ private static function saveRateData($rateFile, array $data) { $json = json_encode($data); @file_put_contents($rateFile, $json, LOCK_EX); } /** * Get remaining requests for current user * * @param string $action Action type * @param bool $isWrite Whether this is a write action * @return array Info about remaining requests */ public static function getRemaining($action, $isWrite = false) { $identifier = self::getIdentifier(); $limit = $isWrite ? self::WRITE_LIMIT : self::DEFAULT_LIMIT; $window = $isWrite ? self::WRITE_WINDOW : self::DEFAULT_WINDOW; $rateFile = self::getRateFile($identifier, $action); $now = time(); $data = ['requests' => [], 'blocked_until' => 0]; if (file_exists($rateFile)) { $contents = @file_get_contents($rateFile); if ($contents) { $decoded = @json_decode($contents, true); if (is_array($decoded)) { $data = $decoded; } } } // Check if blocked if (isset($data['blocked_until']) && $data['blocked_until'] > $now) { return [ 'remaining' => 0, 'limit' => $limit, 'reset' => $data['blocked_until'] - $now, 'blocked' => true ]; } // Count requests in window $windowStart = $now - $window; $currentRequests = count(array_filter($data['requests'], function($timestamp) use ($windowStart) { return $timestamp > $windowStart; })); return [ 'remaining' => max(0, $limit - $currentRequests), 'limit' => $limit, 'reset' => $window, 'blocked' => false ]; } /** * Reset rate limit for a user/action * * @param string $action Action type * @param string|null $identifier Specific identifier (null for current user) */ public static function reset($action, $identifier = null) { if ($identifier === null) { $identifier = self::getIdentifier(); } $rateFile = self::getRateFile($identifier, $action); if (file_exists($rateFile)) { @unlink($rateFile); } } /** * Clean up old rate limit files * * @param int $maxAge Maximum age in seconds (default 1 hour) * @return int Number of files cleaned */ public static function cleanup($maxAge = 3600) { $dir = self::getRateDir(); $files = glob($dir . '*.rate'); $cleaned = 0; $now = time(); foreach ($files as $file) { $mtime = filemtime($file); if ($mtime !== false && ($now - $mtime) > $maxAge) { if (@unlink($file)) { $cleaned++; } } } return $cleaned; } /** * Log rate limit event * * @param string $identifier User/IP identifier * @param string $action Action that was limited * @param int $requests Number of requests made */ private static function logRateLimit($identifier, $action, $requests) { if (defined('CALENDAR_DEBUG') && CALENDAR_DEBUG) { error_log("[Calendar RateLimiter] Rate limited: $identifier, action: $action, requests: $requests"); } } /** * Add rate limit headers to response * * @param string $action Action type * @param bool $isWrite Whether this is a write action */ public static function addHeaders($action, $isWrite = false) { $info = self::getRemaining($action, $isWrite); header('X-RateLimit-Limit: ' . $info['limit']); header('X-RateLimit-Remaining: ' . $info['remaining']); header('X-RateLimit-Reset: ' . $info['reset']); if ($info['blocked']) { header('Retry-After: ' . $info['reset']); } } }