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