| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960 |
- <?php
- declare(strict_types=1);
- namespace app\miniapi\protection;
- use support\Redis;
- /** All-or-nothing counters with server-side expiry and one denial notice per window. */
- final class RedisQuota
- {
- private const SCRIPT = <<<'LUA'
- local values = {}
- local blocked = 0
- local retry = 0
- for i, key in ipairs(KEYS) do
- local offset = (i - 1) * 3
- local limit = tonumber(ARGV[offset + 1])
- local seconds = tonumber(ARGV[offset + 2])
- local cost = tonumber(ARGV[offset + 3])
- local count = tonumber(redis.call('GET', key) or '0')
- local ttl = redis.call('TTL', key)
- if ttl < 0 then count = 0 end
- values[i] = count
- if count + cost > limit and math.max(1, ttl) > retry then
- blocked = i
- retry = math.max(1, ttl)
- end
- end
- if blocked > 0 then
- local notice = redis.call('SET', KEYS[blocked] .. ':notice', '1', 'EX', retry, 'NX')
- return {blocked, retry, notice and 1 or 0}
- end
- for i, key in ipairs(KEYS) do
- local offset = (i - 1) * 3
- if values[i] == 0 then
- redis.call('SET', key, ARGV[offset + 3], 'EX', ARGV[offset + 2])
- else
- redis.call('INCRBY', key, ARGV[offset + 3])
- end
- end
- return {0, 0, 0}
- LUA;
- /** @param array<array{key:string,limit:int,seconds:int,cost:int,rule:string}> $buckets */
- public function consume(array $buckets): array
- {
- $keys = array_column($buckets, 'key');
- $arguments = [];
- foreach ($buckets as $bucket) {
- if ($bucket['limit'] < 1 || $bucket['seconds'] < 1 || $bucket['cost'] < 1 || $bucket['cost'] > $bucket['limit']) {
- throw new \LogicException('Invalid protection quota configuration');
- }
- array_push($arguments, $bucket['limit'], $bucket['seconds'], $bucket['cost']);
- }
- return Redis::connection(config('mini_protection.connection', 'default'))
- ->eval(self::SCRIPT, count($keys), ...array_merge($keys, $arguments));
- }
- }
|