Update: 2026-05-06 05:11:51
This commit is contained in:
60
app/Core/Cache.php
Normal file
60
app/Core/Cache.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/**
|
||||
* Redis Cache Wrapper
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Cache
|
||||
{
|
||||
private static ?\Predis\Client $client = null;
|
||||
|
||||
public static function getInstance(): ?\Predis\Client
|
||||
{
|
||||
if (self::$client === null) {
|
||||
$host = env('REDIS_HOST', '127.0.0.1');
|
||||
$port = (int)env('REDIS_PORT', 6379);
|
||||
$pass = env('REDIS_PASSWORD', null);
|
||||
|
||||
try {
|
||||
self::$client = new \Predis\Client([
|
||||
'scheme' => 'tcp',
|
||||
'host' => $host,
|
||||
'port' => $port,
|
||||
'password' => $pass,
|
||||
]);
|
||||
self::$client->connect();
|
||||
} catch (\Exception $e) {
|
||||
error_log("Redis Connection Error: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return self::$client;
|
||||
}
|
||||
|
||||
public static function set(string $key, $value, int $ttl = 3600): bool
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
|
||||
$redis->setex($key, $ttl, serialize($value));
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function get(string $key)
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
|
||||
$data = $redis->get($key);
|
||||
return $data ? unserialize($data) : null;
|
||||
}
|
||||
|
||||
public static function delete(string $key): void
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if ($redis) $redis->del([$key]);
|
||||
}
|
||||
}
|
||||
@@ -15,54 +15,61 @@ final class RateLimitMiddleware
|
||||
*/
|
||||
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$key = 'rl:' . md5($ip);
|
||||
|
||||
// 1. Try Redis first
|
||||
$redis = \App\Core\Cache::getInstance();
|
||||
if ($redis) {
|
||||
try {
|
||||
$count = $redis->get($key);
|
||||
if ($count && (int)$count >= $maxRequests) {
|
||||
header('Retry-After: ' . $timeWindow);
|
||||
json_error('Too Many Requests. Please slow down.', 429);
|
||||
}
|
||||
|
||||
if (!$count) {
|
||||
$redis->setex($key, $timeWindow, 1);
|
||||
} else {
|
||||
$redis->incr($key);
|
||||
}
|
||||
return; // Success with Redis
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to file-based if Redis fails
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: File-based rate limiter (original logic)
|
||||
$cacheDir = STORAGE_PATH . '/cache';
|
||||
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
|
||||
if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
||||
|
||||
if (!is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0755, true);
|
||||
}
|
||||
|
||||
// M2 Fix: Use exclusive file lock to prevent race condition
|
||||
$fp = fopen($cacheFile, 'c+');
|
||||
if ($fp === false) {
|
||||
// If we can't open the file, fail open (don't block all users)
|
||||
return;
|
||||
}
|
||||
if ($fp === false) return;
|
||||
|
||||
try {
|
||||
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
|
||||
|
||||
$now = time();
|
||||
$content = stream_get_contents($fp);
|
||||
flock($fp, LOCK_EX);
|
||||
$now = time();
|
||||
$content = stream_get_contents($fp);
|
||||
$requests = [];
|
||||
|
||||
if (!empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
// Keep only requests within the time window
|
||||
$requests = array_values(
|
||||
array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow))
|
||||
);
|
||||
$requests = array_values(array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow)));
|
||||
}
|
||||
}
|
||||
|
||||
if (count($requests) >= $maxRequests) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
header('Retry-After: ' . $timeWindow);
|
||||
json_error('Too Many Requests. Please slow down.', 429);
|
||||
}
|
||||
|
||||
// Record this request
|
||||
$requests[] = $now;
|
||||
|
||||
// Write updated data back
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($requests));
|
||||
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
@@ -15,54 +15,61 @@ final class RateLimitMiddleware
|
||||
*/
|
||||
public static function check(int $maxRequests = 60, int $timeWindow = 60): void
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
$key = 'rl:' . md5($ip);
|
||||
|
||||
// 1. Try Redis first
|
||||
$redis = \App\Core\Cache::getInstance();
|
||||
if ($redis) {
|
||||
try {
|
||||
$count = $redis->get($key);
|
||||
if ($count && (int)$count >= $maxRequests) {
|
||||
header('Retry-After: ' . $timeWindow);
|
||||
json_error('Too Many Requests. Please slow down.', 429);
|
||||
}
|
||||
|
||||
if (!$count) {
|
||||
$redis->setex($key, $timeWindow, 1);
|
||||
} else {
|
||||
$redis->incr($key);
|
||||
}
|
||||
return; // Success with Redis
|
||||
} catch (\Exception $e) {
|
||||
// Fallback to file-based if Redis fails
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: File-based rate limiter (original logic)
|
||||
$cacheDir = STORAGE_PATH . '/cache';
|
||||
$cacheFile = $cacheDir . '/rl_' . md5($ip) . '.json';
|
||||
if (!is_dir($cacheDir)) mkdir($cacheDir, 0755, true);
|
||||
|
||||
if (!is_dir($cacheDir)) {
|
||||
mkdir($cacheDir, 0755, true);
|
||||
}
|
||||
|
||||
// M2 Fix: Use exclusive file lock to prevent race condition
|
||||
$fp = fopen($cacheFile, 'c+');
|
||||
if ($fp === false) {
|
||||
// If we can't open the file, fail open (don't block all users)
|
||||
return;
|
||||
}
|
||||
if ($fp === false) return;
|
||||
|
||||
try {
|
||||
flock($fp, LOCK_EX); // Exclusive lock — blocks until acquired
|
||||
|
||||
$now = time();
|
||||
$content = stream_get_contents($fp);
|
||||
flock($fp, LOCK_EX);
|
||||
$now = time();
|
||||
$content = stream_get_contents($fp);
|
||||
$requests = [];
|
||||
|
||||
if (!empty($content)) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
// Keep only requests within the time window
|
||||
$requests = array_values(
|
||||
array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow))
|
||||
);
|
||||
$requests = array_values(array_filter($decoded, fn($ts) => $ts > ($now - $timeWindow)));
|
||||
}
|
||||
}
|
||||
|
||||
if (count($requests) >= $maxRequests) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
header('Retry-After: ' . $timeWindow);
|
||||
json_error('Too Many Requests. Please slow down.', 429);
|
||||
}
|
||||
|
||||
// Record this request
|
||||
$requests[] = $now;
|
||||
|
||||
// Write updated data back
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($requests));
|
||||
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
Reference in New Issue
Block a user