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