1<?php 2/** 3 * Calendar Plugin - Rate Limiter 4 * 5 * Provides rate limiting for AJAX endpoints to prevent abuse. 6 * Uses file-based tracking for simplicity and compatibility. 7 * 8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9 * @author DokuWiki Community 10 * @version 7.2.6 11 */ 12 13if (!defined('DOKU_INC')) die(); 14 15class CalendarRateLimiter { 16 17 /** @var int Default rate limit (requests per window) */ 18 private const DEFAULT_LIMIT = 60; 19 20 /** @var int Default time window in seconds (1 minute) */ 21 private const DEFAULT_WINDOW = 60; 22 23 /** @var int Write action rate limit (more restrictive) */ 24 private const WRITE_LIMIT = 30; 25 26 /** @var int Write action time window */ 27 private const WRITE_WINDOW = 60; 28 29 /** @var string Rate limit data directory */ 30 private const RATE_DIR = 'cache/calendar/ratelimit/'; 31 32 /** @var int Cleanup probability (1 in X requests) */ 33 private const CLEANUP_PROBABILITY = 100; 34 35 /** 36 * Get the rate limit directory path 37 * 38 * @return string Directory path 39 */ 40 private static function getRateDir() { 41 global $conf; 42 $dir = rtrim($conf['cachedir'], '/') . '/calendar/ratelimit/'; 43 if (!is_dir($dir)) { 44 @mkdir($dir, 0755, true); 45 } 46 return $dir; 47 } 48 49 /** 50 * Get identifier for rate limiting (user or IP) 51 * 52 * @return string Identifier hash 53 */ 54 private static function getIdentifier() { 55 // Prefer username if logged in 56 if (!empty($_SERVER['REMOTE_USER'])) { 57 return 'user_' . md5($_SERVER['REMOTE_USER']); 58 } 59 60 // Fall back to IP address 61 $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; 62 63 // Check for proxy headers 64 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { 65 $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); 66 $ip = trim($ips[0]); 67 } elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) { 68 $ip = $_SERVER['HTTP_X_REAL_IP']; 69 } 70 71 return 'ip_' . md5($ip); 72 } 73 74 /** 75 * Get rate limit file path 76 * 77 * @param string $identifier User/IP identifier 78 * @param string $action Action type 79 * @return string File path 80 */ 81 private static function getRateFile($identifier, $action) { 82 $action = preg_replace('/[^a-z0-9_]/', '', strtolower($action)); 83 return self::getRateDir() . "{$identifier}_{$action}.rate"; 84 } 85 86 /** 87 * Check if request is rate limited 88 * 89 * @param string $action Action being performed 90 * @param bool $isWrite Whether this is a write action 91 * @return bool True if allowed, false if rate limited 92 */ 93 public static function check($action, $isWrite = false) { 94 $identifier = self::getIdentifier(); 95 $limit = $isWrite ? self::WRITE_LIMIT : self::DEFAULT_LIMIT; 96 $window = $isWrite ? self::WRITE_WINDOW : self::DEFAULT_WINDOW; 97 98 $rateFile = self::getRateFile($identifier, $action); 99 $now = time(); 100 101 // Read current rate data 102 $data = ['requests' => [], 'blocked_until' => 0]; 103 if (file_exists($rateFile)) { 104 $contents = @file_get_contents($rateFile); 105 if ($contents) { 106 $decoded = @json_decode($contents, true); 107 if (is_array($decoded)) { 108 $data = $decoded; 109 } 110 } 111 } 112 113 // Check if currently blocked 114 if (isset($data['blocked_until']) && $data['blocked_until'] > $now) { 115 return false; 116 } 117 118 // Clean old requests outside the window 119 $windowStart = $now - $window; 120 $data['requests'] = array_filter($data['requests'], function($timestamp) use ($windowStart) { 121 return $timestamp > $windowStart; 122 }); 123 $data['requests'] = array_values($data['requests']); 124 125 // Check if over limit 126 if (count($data['requests']) >= $limit) { 127 // Block for remaining window time 128 $data['blocked_until'] = $now + $window; 129 self::saveRateData($rateFile, $data); 130 131 self::logRateLimit($identifier, $action, count($data['requests'])); 132 return false; 133 } 134 135 // Add current request 136 $data['requests'][] = $now; 137 $data['blocked_until'] = 0; 138 139 self::saveRateData($rateFile, $data); 140 141 // Probabilistic cleanup 142 if (rand(1, self::CLEANUP_PROBABILITY) === 1) { 143 self::cleanup(); 144 } 145 146 return true; 147 } 148 149 /** 150 * Save rate data to file 151 * 152 * @param string $rateFile File path 153 * @param array $data Rate data 154 */ 155 private static function saveRateData($rateFile, array $data) { 156 $json = json_encode($data); 157 @file_put_contents($rateFile, $json, LOCK_EX); 158 } 159 160 /** 161 * Get remaining requests for current user 162 * 163 * @param string $action Action type 164 * @param bool $isWrite Whether this is a write action 165 * @return array Info about remaining requests 166 */ 167 public static function getRemaining($action, $isWrite = false) { 168 $identifier = self::getIdentifier(); 169 $limit = $isWrite ? self::WRITE_LIMIT : self::DEFAULT_LIMIT; 170 $window = $isWrite ? self::WRITE_WINDOW : self::DEFAULT_WINDOW; 171 172 $rateFile = self::getRateFile($identifier, $action); 173 $now = time(); 174 175 $data = ['requests' => [], 'blocked_until' => 0]; 176 if (file_exists($rateFile)) { 177 $contents = @file_get_contents($rateFile); 178 if ($contents) { 179 $decoded = @json_decode($contents, true); 180 if (is_array($decoded)) { 181 $data = $decoded; 182 } 183 } 184 } 185 186 // Check if blocked 187 if (isset($data['blocked_until']) && $data['blocked_until'] > $now) { 188 return [ 189 'remaining' => 0, 190 'limit' => $limit, 191 'reset' => $data['blocked_until'] - $now, 192 'blocked' => true 193 ]; 194 } 195 196 // Count requests in window 197 $windowStart = $now - $window; 198 $currentRequests = count(array_filter($data['requests'], function($timestamp) use ($windowStart) { 199 return $timestamp > $windowStart; 200 })); 201 202 return [ 203 'remaining' => max(0, $limit - $currentRequests), 204 'limit' => $limit, 205 'reset' => $window, 206 'blocked' => false 207 ]; 208 } 209 210 /** 211 * Reset rate limit for a user/action 212 * 213 * @param string $action Action type 214 * @param string|null $identifier Specific identifier (null for current user) 215 */ 216 public static function reset($action, $identifier = null) { 217 if ($identifier === null) { 218 $identifier = self::getIdentifier(); 219 } 220 221 $rateFile = self::getRateFile($identifier, $action); 222 if (file_exists($rateFile)) { 223 @unlink($rateFile); 224 } 225 } 226 227 /** 228 * Clean up old rate limit files 229 * 230 * @param int $maxAge Maximum age in seconds (default 1 hour) 231 * @return int Number of files cleaned 232 */ 233 public static function cleanup($maxAge = 3600) { 234 $dir = self::getRateDir(); 235 $files = glob($dir . '*.rate'); 236 $cleaned = 0; 237 $now = time(); 238 239 foreach ($files as $file) { 240 $mtime = filemtime($file); 241 if ($mtime !== false && ($now - $mtime) > $maxAge) { 242 if (@unlink($file)) { 243 $cleaned++; 244 } 245 } 246 } 247 248 return $cleaned; 249 } 250 251 /** 252 * Log rate limit event 253 * 254 * @param string $identifier User/IP identifier 255 * @param string $action Action that was limited 256 * @param int $requests Number of requests made 257 */ 258 private static function logRateLimit($identifier, $action, $requests) { 259 if (defined('CALENDAR_DEBUG') && CALENDAR_DEBUG) { 260 error_log("[Calendar RateLimiter] Rate limited: $identifier, action: $action, requests: $requests"); 261 } 262 } 263 264 /** 265 * Add rate limit headers to response 266 * 267 * @param string $action Action type 268 * @param bool $isWrite Whether this is a write action 269 */ 270 public static function addHeaders($action, $isWrite = false) { 271 $info = self::getRemaining($action, $isWrite); 272 273 header('X-RateLimit-Limit: ' . $info['limit']); 274 header('X-RateLimit-Remaining: ' . $info['remaining']); 275 header('X-RateLimit-Reset: ' . $info['reset']); 276 277 if ($info['blocked']) { 278 header('Retry-After: ' . $info['reset']); 279 } 280 } 281} 282