138 lines
4.3 KiB
PHP
138 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Grav\Plugin\Api\Middleware;
|
|
|
|
use Grav\Common\Config\Config;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
/**
|
|
* File-based token bucket rate limiter.
|
|
* Cloud-safe: each Grav instance has its own cache directory.
|
|
*/
|
|
class RateLimitMiddleware
|
|
{
|
|
public function __construct(
|
|
protected readonly Config $config,
|
|
) {}
|
|
|
|
/**
|
|
* Check rate limit for the current request.
|
|
*
|
|
* @return array{limited: bool, limit: int, remaining: int, reset: int}
|
|
*/
|
|
public function check(ServerRequestInterface $request): array
|
|
{
|
|
$enabled = $this->config->get('plugins.api.rate_limit.enabled', true);
|
|
$limit = (int) $this->config->get('plugins.api.rate_limit.requests', 120);
|
|
$window = (int) $this->config->get('plugins.api.rate_limit.window', 60);
|
|
|
|
if (!$enabled) {
|
|
return [
|
|
'limited' => false,
|
|
'limit' => $limit,
|
|
'remaining' => $limit,
|
|
'reset' => time() + $window,
|
|
];
|
|
}
|
|
|
|
// Path-prefix exclusions. Used to keep high-frequency API
|
|
// surfaces (collab polling, etc.) out of the per-user bucket so a
|
|
// single typing user doesn't trip the global anti-abuse limit.
|
|
// Defaults to excluding the sync plugin's endpoints; operators can
|
|
// override via plugins.api.rate_limit.excluded_paths.
|
|
$excluded = (array) $this->config->get('plugins.api.rate_limit.excluded_paths', ['/sync/']);
|
|
$path = $request->getUri()->getPath();
|
|
foreach ($excluded as $prefix) {
|
|
if (!is_string($prefix) || $prefix === '') continue;
|
|
if (str_contains($path, $prefix)) {
|
|
return [
|
|
'limited' => false,
|
|
'limit' => $limit,
|
|
'remaining' => $limit,
|
|
'reset' => time() + $window,
|
|
];
|
|
}
|
|
}
|
|
|
|
$identifier = $this->getIdentifier($request);
|
|
$storageDir = $this->getStorageDir();
|
|
|
|
if (!is_dir($storageDir)) {
|
|
@mkdir($storageDir, 0775, true);
|
|
}
|
|
|
|
$file = $storageDir . '/' . md5($identifier) . '.json';
|
|
|
|
return $this->checkLimit($file, $limit, $window);
|
|
}
|
|
|
|
protected function getIdentifier(ServerRequestInterface $request): string
|
|
{
|
|
// Use authenticated user if available, otherwise fall back to IP
|
|
$user = $request->getAttribute('api_user');
|
|
if ($user) {
|
|
return 'user:' . $user->username;
|
|
}
|
|
|
|
return 'ip:' . ($request->getServerParams()['REMOTE_ADDR'] ?? 'unknown');
|
|
}
|
|
|
|
protected function checkLimit(string $file, int $limit, int $window): array
|
|
{
|
|
$now = time();
|
|
$data = ['tokens' => $limit, 'last_refill' => $now];
|
|
|
|
// Use file locking for concurrency safety
|
|
$fp = fopen($file, 'c+');
|
|
if (!$fp) {
|
|
// If we can't open the file, allow the request
|
|
return ['limited' => false, 'limit' => $limit, 'remaining' => $limit, 'reset' => $now + $window];
|
|
}
|
|
|
|
flock($fp, LOCK_EX);
|
|
|
|
$contents = stream_get_contents($fp);
|
|
if ($contents) {
|
|
$data = json_decode($contents, true) ?: $data;
|
|
}
|
|
|
|
// Refill tokens based on elapsed time
|
|
$elapsed = $now - ($data['last_refill'] ?? $now);
|
|
$refillRate = $limit / $window;
|
|
$data['tokens'] = min($limit, ($data['tokens'] ?? $limit) + ($elapsed * $refillRate));
|
|
$data['last_refill'] = $now;
|
|
|
|
// Try to consume a token
|
|
$limited = $data['tokens'] < 1;
|
|
if (!$limited) {
|
|
$data['tokens'] -= 1;
|
|
}
|
|
|
|
// Write back
|
|
ftruncate($fp, 0);
|
|
rewind($fp);
|
|
fwrite($fp, json_encode($data));
|
|
fflush($fp);
|
|
flock($fp, LOCK_UN);
|
|
fclose($fp);
|
|
|
|
$remaining = max(0, (int) floor($data['tokens']));
|
|
$reset = $now + (int) ceil(($limit - $data['tokens']) / $refillRate);
|
|
|
|
return [
|
|
'limited' => $limited,
|
|
'limit' => $limit,
|
|
'remaining' => $remaining,
|
|
'reset' => $reset,
|
|
];
|
|
}
|
|
|
|
protected function getStorageDir(): string
|
|
{
|
|
$locator = \Grav\Common\Grav::instance()['locator'];
|
|
return $locator->findResource('cache://api/ratelimit', true, true);
|
|
}
|
|
}
|