xref: /plugin/calendar/classes/RateLimiter.php (revision 2866e8271e4daef3b32eacb3a9082d02159b592b)
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