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