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