1815440faSAtari911<?php 2815440faSAtari911/** 3815440faSAtari911 * Calendar Plugin - Rate Limiter 4815440faSAtari911 * 5815440faSAtari911 * Provides rate limiting for AJAX endpoints to prevent abuse. 6815440faSAtari911 * Uses file-based tracking for simplicity and compatibility. 7815440faSAtari911 * 8815440faSAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9815440faSAtari911 * @author DokuWiki Community 10*2866e827SAtari911 * @version 7.2.6 11815440faSAtari911 */ 12815440faSAtari911 13815440faSAtari911if (!defined('DOKU_INC')) die(); 14815440faSAtari911 15815440faSAtari911class CalendarRateLimiter { 16815440faSAtari911 17815440faSAtari911 /** @var int Default rate limit (requests per window) */ 18815440faSAtari911 private const DEFAULT_LIMIT = 60; 19815440faSAtari911 20815440faSAtari911 /** @var int Default time window in seconds (1 minute) */ 21815440faSAtari911 private const DEFAULT_WINDOW = 60; 22815440faSAtari911 23815440faSAtari911 /** @var int Write action rate limit (more restrictive) */ 24815440faSAtari911 private const WRITE_LIMIT = 30; 25815440faSAtari911 26815440faSAtari911 /** @var int Write action time window */ 27815440faSAtari911 private const WRITE_WINDOW = 60; 28815440faSAtari911 29815440faSAtari911 /** @var string Rate limit data directory */ 30815440faSAtari911 private const RATE_DIR = 'cache/calendar/ratelimit/'; 31815440faSAtari911 32815440faSAtari911 /** @var int Cleanup probability (1 in X requests) */ 33815440faSAtari911 private const CLEANUP_PROBABILITY = 100; 34815440faSAtari911 35815440faSAtari911 /** 36815440faSAtari911 * Get the rate limit directory path 37815440faSAtari911 * 38815440faSAtari911 * @return string Directory path 39815440faSAtari911 */ 40815440faSAtari911 private static function getRateDir() { 41*2866e827SAtari911 global $conf; 42*2866e827SAtari911 $dir = rtrim($conf['cachedir'], '/') . '/calendar/ratelimit/'; 43815440faSAtari911 if (!is_dir($dir)) { 44815440faSAtari911 @mkdir($dir, 0755, true); 45815440faSAtari911 } 46815440faSAtari911 return $dir; 47815440faSAtari911 } 48815440faSAtari911 49815440faSAtari911 /** 50815440faSAtari911 * Get identifier for rate limiting (user or IP) 51815440faSAtari911 * 52815440faSAtari911 * @return string Identifier hash 53815440faSAtari911 */ 54815440faSAtari911 private static function getIdentifier() { 55815440faSAtari911 // Prefer username if logged in 56815440faSAtari911 if (!empty($_SERVER['REMOTE_USER'])) { 57815440faSAtari911 return 'user_' . md5($_SERVER['REMOTE_USER']); 58815440faSAtari911 } 59815440faSAtari911 60815440faSAtari911 // Fall back to IP address 61815440faSAtari911 $ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; 62815440faSAtari911 63815440faSAtari911 // Check for proxy headers 64815440faSAtari911 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { 65815440faSAtari911 $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); 66815440faSAtari911 $ip = trim($ips[0]); 67815440faSAtari911 } elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) { 68815440faSAtari911 $ip = $_SERVER['HTTP_X_REAL_IP']; 69815440faSAtari911 } 70815440faSAtari911 71815440faSAtari911 return 'ip_' . md5($ip); 72815440faSAtari911 } 73815440faSAtari911 74815440faSAtari911 /** 75815440faSAtari911 * Get rate limit file path 76815440faSAtari911 * 77815440faSAtari911 * @param string $identifier User/IP identifier 78815440faSAtari911 * @param string $action Action type 79815440faSAtari911 * @return string File path 80815440faSAtari911 */ 81815440faSAtari911 private static function getRateFile($identifier, $action) { 82815440faSAtari911 $action = preg_replace('/[^a-z0-9_]/', '', strtolower($action)); 83815440faSAtari911 return self::getRateDir() . "{$identifier}_{$action}.rate"; 84815440faSAtari911 } 85815440faSAtari911 86815440faSAtari911 /** 87815440faSAtari911 * Check if request is rate limited 88815440faSAtari911 * 89815440faSAtari911 * @param string $action Action being performed 90815440faSAtari911 * @param bool $isWrite Whether this is a write action 91815440faSAtari911 * @return bool True if allowed, false if rate limited 92815440faSAtari911 */ 93815440faSAtari911 public static function check($action, $isWrite = false) { 94815440faSAtari911 $identifier = self::getIdentifier(); 95815440faSAtari911 $limit = $isWrite ? self::WRITE_LIMIT : self::DEFAULT_LIMIT; 96815440faSAtari911 $window = $isWrite ? self::WRITE_WINDOW : self::DEFAULT_WINDOW; 97815440faSAtari911 98815440faSAtari911 $rateFile = self::getRateFile($identifier, $action); 99815440faSAtari911 $now = time(); 100815440faSAtari911 101815440faSAtari911 // Read current rate data 102815440faSAtari911 $data = ['requests' => [], 'blocked_until' => 0]; 103815440faSAtari911 if (file_exists($rateFile)) { 104815440faSAtari911 $contents = @file_get_contents($rateFile); 105815440faSAtari911 if ($contents) { 106815440faSAtari911 $decoded = @json_decode($contents, true); 107815440faSAtari911 if (is_array($decoded)) { 108815440faSAtari911 $data = $decoded; 109815440faSAtari911 } 110815440faSAtari911 } 111815440faSAtari911 } 112815440faSAtari911 113815440faSAtari911 // Check if currently blocked 114815440faSAtari911 if (isset($data['blocked_until']) && $data['blocked_until'] > $now) { 115815440faSAtari911 return false; 116815440faSAtari911 } 117815440faSAtari911 118815440faSAtari911 // Clean old requests outside the window 119815440faSAtari911 $windowStart = $now - $window; 120815440faSAtari911 $data['requests'] = array_filter($data['requests'], function($timestamp) use ($windowStart) { 121815440faSAtari911 return $timestamp > $windowStart; 122815440faSAtari911 }); 123815440faSAtari911 $data['requests'] = array_values($data['requests']); 124815440faSAtari911 125815440faSAtari911 // Check if over limit 126815440faSAtari911 if (count($data['requests']) >= $limit) { 127815440faSAtari911 // Block for remaining window time 128815440faSAtari911 $data['blocked_until'] = $now + $window; 129815440faSAtari911 self::saveRateData($rateFile, $data); 130815440faSAtari911 131815440faSAtari911 self::logRateLimit($identifier, $action, count($data['requests'])); 132815440faSAtari911 return false; 133815440faSAtari911 } 134815440faSAtari911 135815440faSAtari911 // Add current request 136815440faSAtari911 $data['requests'][] = $now; 137815440faSAtari911 $data['blocked_until'] = 0; 138815440faSAtari911 139815440faSAtari911 self::saveRateData($rateFile, $data); 140815440faSAtari911 141815440faSAtari911 // Probabilistic cleanup 142815440faSAtari911 if (rand(1, self::CLEANUP_PROBABILITY) === 1) { 143815440faSAtari911 self::cleanup(); 144815440faSAtari911 } 145815440faSAtari911 146815440faSAtari911 return true; 147815440faSAtari911 } 148815440faSAtari911 149815440faSAtari911 /** 150815440faSAtari911 * Save rate data to file 151815440faSAtari911 * 152815440faSAtari911 * @param string $rateFile File path 153815440faSAtari911 * @param array $data Rate data 154815440faSAtari911 */ 155815440faSAtari911 private static function saveRateData($rateFile, array $data) { 156815440faSAtari911 $json = json_encode($data); 157815440faSAtari911 @file_put_contents($rateFile, $json, LOCK_EX); 158815440faSAtari911 } 159815440faSAtari911 160815440faSAtari911 /** 161815440faSAtari911 * Get remaining requests for current user 162815440faSAtari911 * 163815440faSAtari911 * @param string $action Action type 164815440faSAtari911 * @param bool $isWrite Whether this is a write action 165815440faSAtari911 * @return array Info about remaining requests 166815440faSAtari911 */ 167815440faSAtari911 public static function getRemaining($action, $isWrite = false) { 168815440faSAtari911 $identifier = self::getIdentifier(); 169815440faSAtari911 $limit = $isWrite ? self::WRITE_LIMIT : self::DEFAULT_LIMIT; 170815440faSAtari911 $window = $isWrite ? self::WRITE_WINDOW : self::DEFAULT_WINDOW; 171815440faSAtari911 172815440faSAtari911 $rateFile = self::getRateFile($identifier, $action); 173815440faSAtari911 $now = time(); 174815440faSAtari911 175815440faSAtari911 $data = ['requests' => [], 'blocked_until' => 0]; 176815440faSAtari911 if (file_exists($rateFile)) { 177815440faSAtari911 $contents = @file_get_contents($rateFile); 178815440faSAtari911 if ($contents) { 179815440faSAtari911 $decoded = @json_decode($contents, true); 180815440faSAtari911 if (is_array($decoded)) { 181815440faSAtari911 $data = $decoded; 182815440faSAtari911 } 183815440faSAtari911 } 184815440faSAtari911 } 185815440faSAtari911 186815440faSAtari911 // Check if blocked 187815440faSAtari911 if (isset($data['blocked_until']) && $data['blocked_until'] > $now) { 188815440faSAtari911 return [ 189815440faSAtari911 'remaining' => 0, 190815440faSAtari911 'limit' => $limit, 191815440faSAtari911 'reset' => $data['blocked_until'] - $now, 192815440faSAtari911 'blocked' => true 193815440faSAtari911 ]; 194815440faSAtari911 } 195815440faSAtari911 196815440faSAtari911 // Count requests in window 197815440faSAtari911 $windowStart = $now - $window; 198815440faSAtari911 $currentRequests = count(array_filter($data['requests'], function($timestamp) use ($windowStart) { 199815440faSAtari911 return $timestamp > $windowStart; 200815440faSAtari911 })); 201815440faSAtari911 202815440faSAtari911 return [ 203815440faSAtari911 'remaining' => max(0, $limit - $currentRequests), 204815440faSAtari911 'limit' => $limit, 205815440faSAtari911 'reset' => $window, 206815440faSAtari911 'blocked' => false 207815440faSAtari911 ]; 208815440faSAtari911 } 209815440faSAtari911 210815440faSAtari911 /** 211815440faSAtari911 * Reset rate limit for a user/action 212815440faSAtari911 * 213815440faSAtari911 * @param string $action Action type 214815440faSAtari911 * @param string|null $identifier Specific identifier (null for current user) 215815440faSAtari911 */ 216815440faSAtari911 public static function reset($action, $identifier = null) { 217815440faSAtari911 if ($identifier === null) { 218815440faSAtari911 $identifier = self::getIdentifier(); 219815440faSAtari911 } 220815440faSAtari911 221815440faSAtari911 $rateFile = self::getRateFile($identifier, $action); 222815440faSAtari911 if (file_exists($rateFile)) { 223815440faSAtari911 @unlink($rateFile); 224815440faSAtari911 } 225815440faSAtari911 } 226815440faSAtari911 227815440faSAtari911 /** 228815440faSAtari911 * Clean up old rate limit files 229815440faSAtari911 * 230815440faSAtari911 * @param int $maxAge Maximum age in seconds (default 1 hour) 231815440faSAtari911 * @return int Number of files cleaned 232815440faSAtari911 */ 233815440faSAtari911 public static function cleanup($maxAge = 3600) { 234815440faSAtari911 $dir = self::getRateDir(); 235815440faSAtari911 $files = glob($dir . '*.rate'); 236815440faSAtari911 $cleaned = 0; 237815440faSAtari911 $now = time(); 238815440faSAtari911 239815440faSAtari911 foreach ($files as $file) { 240815440faSAtari911 $mtime = filemtime($file); 241815440faSAtari911 if ($mtime !== false && ($now - $mtime) > $maxAge) { 242815440faSAtari911 if (@unlink($file)) { 243815440faSAtari911 $cleaned++; 244815440faSAtari911 } 245815440faSAtari911 } 246815440faSAtari911 } 247815440faSAtari911 248815440faSAtari911 return $cleaned; 249815440faSAtari911 } 250815440faSAtari911 251815440faSAtari911 /** 252815440faSAtari911 * Log rate limit event 253815440faSAtari911 * 254815440faSAtari911 * @param string $identifier User/IP identifier 255815440faSAtari911 * @param string $action Action that was limited 256815440faSAtari911 * @param int $requests Number of requests made 257815440faSAtari911 */ 258815440faSAtari911 private static function logRateLimit($identifier, $action, $requests) { 259815440faSAtari911 if (defined('CALENDAR_DEBUG') && CALENDAR_DEBUG) { 260815440faSAtari911 error_log("[Calendar RateLimiter] Rate limited: $identifier, action: $action, requests: $requests"); 261815440faSAtari911 } 262815440faSAtari911 } 263815440faSAtari911 264815440faSAtari911 /** 265815440faSAtari911 * Add rate limit headers to response 266815440faSAtari911 * 267815440faSAtari911 * @param string $action Action type 268815440faSAtari911 * @param bool $isWrite Whether this is a write action 269815440faSAtari911 */ 270815440faSAtari911 public static function addHeaders($action, $isWrite = false) { 271815440faSAtari911 $info = self::getRemaining($action, $isWrite); 272815440faSAtari911 273815440faSAtari911 header('X-RateLimit-Limit: ' . $info['limit']); 274815440faSAtari911 header('X-RateLimit-Remaining: ' . $info['remaining']); 275815440faSAtari911 header('X-RateLimit-Reset: ' . $info['reset']); 276815440faSAtari911 277815440faSAtari911 if ($info['blocked']) { 278815440faSAtari911 header('Retry-After: ' . $info['reset']); 279815440faSAtari911 } 280815440faSAtari911 } 281815440faSAtari911} 282