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