HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/be.naniguide.com/app/Library/RollingRateTracker.php
<?php

namespace App\Library;

use App\Library\Exception\RateLimitExceeded;
use Carbon\Carbon;
use Exception;

class RollingRateTracker implements RateTrackerInterface
{
    protected const STATE_VERSION = 1;
    protected const SUPPORTED_UNITS = ['minute', 'hour', 'day'];

    protected $filepath;
    protected $limits;
    protected $options;
    protected $retentionPeriod;
    protected $separator = ':';
    protected $blockFormat = 'YmdHi';

    public function __construct(string $filepath, $limits = [], array $options = [])
    {
        $this->filepath = $filepath;
        $this->limits = $limits;
        $this->options = $options;
        $this->retentionPeriod = $options['retention_period'] ?? null;

        $this->validateLimits();
        $this->validateRetentionPeriod();
        $this->createStorageFile();
    }

    public function test(Carbon $now = null, $failedCallback = null)
    {
        $lock = new Lockable($this->filepath);
        $lock->getSharedLock(function ($fopen) use ($now, $failedCallback) {
            $now = $now ?: Carbon::now();
            $state = $this->readState($fopen);

            $this->doTest($now, $state, $failedCallback);
        }, $timeout = 15);
    }

    public function count(Carbon $now = null, $failedCallback = null)
    {
        $lock = new Lockable($this->filepath);
        $lock->getExclusiveLock(function ($fopen) use ($now, $failedCallback) {
            $now = $now ?: Carbon::now();
            $state = $this->readState($fopen);
            $this->pruneStateByRetention($state, $now);

            $this->doTest($now, $state, $failedCallback);

            $block = $this->makeBlock($now);
            $state['minutes'][$block] = ($state['minutes'][$block] ?? 0) + 1;

            $this->writeState($state, $fopen);
        }, $timeout = 15);
    }

    public function rollback()
    {
        $lock = new Lockable($this->filepath);
        $lock->getExclusiveLock(function ($fopen) {
            $state = $this->readState($fopen);

            if (empty($state['minutes'])) {
                throw new Exception('Cannot rollback! There is no previous count!');
            }

            ksort($state['minutes']);
            $lastBlock = array_key_last($state['minutes']);
            $count = $state['minutes'][$lastBlock];

            if ($count <= 1) {
                unset($state['minutes'][$lastBlock]);
            } else {
                $state['minutes'][$lastBlock] = $count - 1;
            }

            $this->writeState($state, $fopen);
        }, $timeout = 15);
    }

    public function getRecords(Carbon $fromDatetime = null, Carbon $toDatetime = null)
    {
        $records = [];
        $fromDatetime = $fromDatetime ?: Carbon::createFromTimestamp(0);
        $toDatetime = $toDatetime ?: Carbon::now();

        $fromBlock = $this->makeBlock($fromDatetime);
        $toBlock = $this->makeBlock($toDatetime);

        $lock = new Lockable($this->filepath);
        $lock->getSharedLock(function ($fopen) use (&$records, $fromBlock, $toBlock) {
            $state = $this->readState($fopen);
            $this->assertRangeWithinRetention($state, $fromBlock);

            foreach ($state['minutes'] as $block => $count) {
                $block = (string) $block;
                if ($block >= $fromBlock && $block <= $toBlock) {
                    $records[] = [$block, $count];
                }
            }
        }, $timeout = 15);

        usort($records, function ($left, $right) {
            return strcmp($left[0], $right[0]);
        });

        return $records;
    }

    public function getCreditsUsed(Carbon $fromDatetime = null, Carbon $toDatetime = null)
    {
        $records = $this->getRecords($fromDatetime, $toDatetime);
        $counts = array_map(function ($record) {
            return $record[1];
        }, $records);

        return array_sum($counts);
    }

    public function getLockFilePath()
    {
        return $this->filepath;
    }

    public function getRateLimits()
    {
        return $this->limits;
    }

    public function cleanup(string $period = null)
    {
        $lock = new Lockable($this->filepath);
        $lock->getExclusiveLock(function ($fopen) use ($period) {
            if (is_null($period)) {
                $this->writeState($this->defaultState(), $fopen);
                return;
            }

            $state = $this->readState($fopen);
            $fromBlock = $this->makeBlock(now()->subtract($period));

            $state['minutes'] = array_filter(
                $state['minutes'],
                function ($count, $block) use ($fromBlock) {
                    return $block >= $fromBlock;
                },
                ARRAY_FILTER_USE_BOTH
            );

            $this->writeState($state, $fopen);
        }, $timeout = 15);
    }

    public function getLimitsDescription()
    {
        $descriptions = [];

        foreach ($this->getRateLimits() as $limit) {
            $descriptions[] = $limit->getDescription();
        }

        return implode(', ', $descriptions);
    }

    public function buildRecord($block, $count)
    {
        return "{$block}{$this->separator}{$count}";
    }

    public function makeBlock($now)
    {
        $now = $now ?: Carbon::now();

        return $now->format($this->blockFormat);
    }

    private function createStorageFile()
    {
        if (!file_exists($this->filepath)) {
            touch($this->filepath);
        }
    }

    private function validateLimits()
    {
        foreach ($this->limits as $limit) {
            if (!in_array($limit->getPeriodUnit(), self::SUPPORTED_UNITS, true)) {
                throw new Exception('RollingRateTracker only supports minute/hour/day rate limits');
            }
        }
    }

    private function validateRetentionPeriod()
    {
        if (is_null($this->retentionPeriod)) {
            return;
        }

        $retentionSeconds = $this->periodToSeconds($this->retentionPeriod);

        foreach ($this->limits as $limit) {
            $limitPeriod = sprintf('%s %s', $limit->getPeriodValue(), $limit->getPeriodUnit());
            if ($retentionSeconds < $this->periodToSeconds($limitPeriod)) {
                throw new Exception(sprintf(
                    'RollingRateTracker retention_period "%s" is shorter than the configured rate limit window "%s". retention_period must be at least as long as the longest rate limit window.',
                    $this->retentionPeriod,
                    $limitPeriod
                ));
            }
        }
    }

    private function doTest(Carbon $now, array $state, $failedCallback)
    {
        foreach ($this->limits as $limit) {
            $period = sprintf('%s %s', $limit->getPeriodValue(), $limit->getPeriodUnit());
            $fromDatetime = $now->copy()->subtract($period);
            $creditsUsed = $this->getCreditsUsedFromState($state, $fromDatetime, $now);

            if ($creditsUsed >= $limit->getAmount()) {
                $msg = sprintf("%s exceeded! %s/%s used", $limit->getDescription(), $creditsUsed, $limit->getAmount());

                if ($failedCallback) {
                    $failedCallback($msg);
                }

                throw new RateLimitExceeded($msg);
            }
        }
    }

    private function getCreditsUsedFromState(array $state, Carbon $fromDatetime, Carbon $toDatetime)
    {
        $fromBlock = $this->makeBlock($fromDatetime);
        $toBlock = $this->makeBlock($toDatetime);
        $total = 0;

        foreach ($state['minutes'] as $block => $count) {
            $block = (string) $block;
            if ($block >= $fromBlock && $block <= $toBlock) {
                $total += $count;
            }
        }

        return $total;
    }

    private function readState($fopen)
    {
        rewind($fopen);
        $contents = stream_get_contents($fopen);

        if ($contents === false || trim($contents) === '') {
            return $this->defaultState();
        }

        $state = json_decode($contents, true);
        if (!is_array($state) || !isset($state['minutes']) || !is_array($state['minutes'])) {
            throw new Exception("Invalid rolling rate tracker state at {$this->filepath}");
        }

        ksort($state['minutes']);

        return $state;
    }

    private function pruneStateByRetention(array &$state, Carbon $now)
    {
        if (is_null($this->retentionPeriod)) {
            return;
        }

        $fromBlock = $this->makeBlock($now->copy()->subtract($this->retentionPeriod));
        $state['minutes'] = array_filter(
            $state['minutes'],
            function ($count, $block) use ($fromBlock) {
                $block = (string) $block;
                return $block >= $fromBlock;
            },
            ARRAY_FILTER_USE_BOTH
        );
    }

    private function assertRangeWithinRetention(array $state, string $fromBlock)
    {
        if (is_null($this->retentionPeriod) || empty($state['minutes'])) {
            return;
        }

        ksort($state['minutes']);
        $latestBlock = (string) array_key_last($state['minutes']);
        $earliestRetainedBlock = $this->makeBlock(
            $this->parseBlockToCarbon($latestBlock)->sub($this->retentionPeriod)
        );

        if ($fromBlock < $earliestRetainedBlock) {
            throw new Exception(sprintf(
                'RollingRateTracker query start "%s" is older than the retained data window. With retention_period "%s" and latest retained block "%s", the earliest reliable block is "%s".',
                $fromBlock,
                $this->retentionPeriod,
                $latestBlock,
                $earliestRetainedBlock
            ));
        }
    }

    private function writeState(array $state, $fopen)
    {
        ksort($state['minutes']);

        $payload = json_encode($state);
        if ($payload === false) {
            throw new Exception("Cannot encode rolling rate tracker state at {$this->filepath}");
        }

        ftruncate($fopen, 0);
        rewind($fopen);
        fwrite($fopen, $payload);
    }

    private function defaultState()
    {
        return [
            'version' => self::STATE_VERSION,
            'minutes' => [],
        ];
    }

    private function periodToSeconds(string $period)
    {
        if (!preg_match('/^\s*(\d+)\s+(minute|minutes|hour|hours|day|days)\s*$/', $period, $matches)) {
            throw new Exception('RollingRateTracker period must use minute/hour/day units, for example "90 minute" or "2 day"');
        }

        $value = (int) $matches[1];
        $unit = rtrim($matches[2], 's');

        return match ($unit) {
            'minute' => $value * 60,
            'hour' => $value * 3600,
            'day' => $value * 86400,
            default => throw new Exception('RollingRateTracker period must use minute/hour/day units'),
        };
    }

    private function parseBlockToCarbon(string $block)
    {
        $datetime = Carbon::createFromFormat($this->blockFormat, $block);
        if ($datetime === false) {
            throw new Exception("Invalid rolling rate tracker block \"{$block}\"");
        }

        return $datetime;
    }
}