diff --git a/.env.actions b/.env.actions index 1959ef76..4f005a0a 100644 --- a/.env.actions +++ b/.env.actions @@ -30,6 +30,8 @@ DB_NAME=cashtrack DB_USER=cashtrack DB_PASSWORD=secret +REDIS_CONNECTION=cache:6379 + S3_REGION=us-east-1 S3_ENDPOINT= S3_KEY= diff --git a/.env.build b/.env.build index 73a75ee0..20f2d6ac 100644 --- a/.env.build +++ b/.env.build @@ -1 +1 @@ -RELEASE_VERSION=0.1.25 +RELEASE_VERSION=1.2.2-dev diff --git a/.env.sample b/.env.sample index fa51f195..74f5e880 100644 --- a/.env.sample +++ b/.env.sample @@ -45,6 +45,8 @@ DB_NAME=cashtrack DB_USER=cashtrack DB_PASSWORD=secret +REDIS_CONNECTION=localhost:6379 + FIREBASE_DATABASE_URI= FIREBASE_STORAGE_BUCKET= FIREBASE_PROJECT_ID= diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d679250d..c7f3d6ca 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -13,7 +13,7 @@ jobs: container: shivammathur/node:latest steps: - name: Checkout Repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -21,15 +21,15 @@ jobs: runner: self-hosted with: php-version: '8.2' - extensions: zip xsl dom exif intl pcntl bcmath sockets mbstring pdo_mysql mysqli + extensions: zip, xsl, dom, exif, intl, pcntl, bcmath, sockets, mbstring, pdo_mysql, mysqli, redis tools: composer - name: Get Composer Cache Directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Prepare Cache For Composer - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -47,7 +47,7 @@ jobs: container: shivammathur/node:latest steps: - name: Checkout Repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -55,15 +55,15 @@ jobs: runner: self-hosted with: php-version: '8.2' - extensions: zip xsl dom exif intl pcntl bcmath sockets mbstring pdo_mysql mysqli + extensions: zip, xsl, dom, exif, intl, pcntl, bcmath, sockets, mbstring, pdo_mysql, mysqli, redis tools: composer - name: Get Composer Cache Directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Prepare Cache For Composer - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}" @@ -84,6 +84,10 @@ jobs: packages: write services: + cache: + image: cashtrack/redis:latest + ports: + - 6379 database: image: cashtrack/mysql:latest env: @@ -97,7 +101,7 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -106,15 +110,15 @@ jobs: with: php-version: '8.2' coverage: pcov - extensions: zip xsl dom exif intl pcntl bcmath sockets mbstring pdo_mysql mysqli + extensions: zip, xsl, dom, exif, intl, pcntl, bcmath, sockets, mbstring, pdo_mysql, mysqli, redis tools: composer, phpunit - name: Get Composer Cache Directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Prepare Cache For Composer - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db8b80cb..01c61e0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository if: github.event_name != 'pull_request' - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Login against a Docker registry except on PR # https://github.com/docker/login-action @@ -71,7 +71,7 @@ jobs: steps: - name: Checkout infra repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: repository: ${{ env.INFRA_REPO }} ref: ${{ env.INFRA_REPO_REF }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 8654fc52..5d1c2e58 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -11,10 +11,10 @@ jobs: runs-on: [self-hosted, Linux, x64] steps: - name: Checkout Repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Prepare Cache For Vulnerability Database - uses: actions/cache@v2 + uses: actions/cache@v3 id: vulnerability-db-cache with: path: ~/.vulnerability-db/cache diff --git a/.rr.yaml b/.rr.yaml index 3ddafc86..57584001 100644 --- a/.rr.yaml +++ b/.rr.yaml @@ -10,18 +10,19 @@ server: http: address: 0.0.0.0:8080 middleware: - - gzip - http_metrics + - gzip pool: num_workers: 1 supervisor: max_worker_memory: 100 kv: - local: - driver: memory + redis: + driver: redis config: - interval: 60 + addrs: + - ${REDIS_CONNECTION} logs: mode: production diff --git a/Dockerfile b/Dockerfile index 32755e59..3f24ad46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM php:8.2.4-alpine3.17 as backend RUN --mount=type=bind,from=mlocati/php-extension-installer:1.5,source=/usr/bin/install-php-extensions,target=/usr/local/bin/install-php-extensions \ - install-php-extensions opcache zip xsl dom exif intl pcntl bcmath sockets mbstring pdo_mysql mysqli && \ + install-php-extensions opcache zip xsl dom exif intl pcntl bcmath sockets mbstring pdo_mysql mysqli redis && \ apk del --no-cache ${PHPIZE_DEPS} ${BUILD_DEPENDS} COPY --from=ghcr.io/roadrunner-server/roadrunner:2.12.3 /usr/bin/rr /usr/bin/rr diff --git a/app/config/redis.php b/app/config/redis.php new file mode 100644 index 00000000..1c0990d7 --- /dev/null +++ b/app/config/redis.php @@ -0,0 +1,36 @@ + env('REDIS_CONNECTION', 'localhost:6379'), + + /** + * Value in seconds (default is 0 meaning it will use default_socket_timeout) + */ + 'timeout' => 2.0, + + /** + * Value in milliseconds + */ + 'retry_interval' => 2, + + /** + * Value in seconds (default is 0 meaning it will use default_socket_timeout) + */ + 'retry_timeout' => 2.0, + + /** + * Prepend to any key on a connection level + */ + 'prefix' => 'CT:', + + /** + * The number of retries, meaning if you set this option to n, there will be a maximum n+1 attempts overall. + */ + 'max_retries' => 5, +]; diff --git a/app/locale/en/messages.php b/app/locale/en/messages.php index 72523d90..d2008540 100644 --- a/app/locale/en/messages.php +++ b/app/locale/en/messages.php @@ -11,6 +11,7 @@ 'error_nick_name_claimed' => 'Nick name already claimed.', 'error_value_is_not_unique' => 'Value should be unique.', 'error_profile_not_confirmed' => 'You are not allowed to perform this action as your profile is not confirmed.', + 'error_rate_limit_reached' => 'Too many requests. Please try again later.', 'email_confirmation_confirm_failure' => 'Unable to confirm your email.', 'email_confirmation_ok' => 'Your email has been confirmed.', diff --git a/app/locale/uk/messages.php b/app/locale/uk/messages.php index 69ff77cc..8f2bea81 100644 --- a/app/locale/uk/messages.php +++ b/app/locale/uk/messages.php @@ -11,6 +11,7 @@ 'error_nick_name_claimed' => 'Нікнейм вже зайнято.', 'error_value_is_not_unique' => 'Значення має бути унікальним.', 'error_profile_not_confirmed' => 'Вам не дозволено здійснити бажану операцію, так як Ваш профіль не підтверджено.', + 'error_rate_limit_reached' => 'Забагато запитів. Будь ласка, спробуйте ще раз пізніше.', 'email_confirmation_confirm_failure' => 'Неможливо підтвердити ваш email.', 'email_confirmation_ok' => 'Ваш email було підтверджено.', diff --git a/app/src/App.php b/app/src/App.php index 0d1957bc..8a80b60b 100644 --- a/app/src/App.php +++ b/app/src/App.php @@ -46,12 +46,12 @@ class App extends Kernel */ protected const LOAD = [ // Logging and exceptions handling + RoadRunnerBridge\LoggerBootloader::class, Bootloader\LoggingBootloader::class, Monolog\MonologBootloader::class, Bootloader\ExceptionHandlerBootloader::class, // RoadRunner - RoadRunnerBridge\LoggerBootloader::class, RoadRunnerBridge\QueueBootloader::class, RoadRunnerBridge\HttpBootloader::class, RoadRunnerBridge\CacheBootloader::class, @@ -144,6 +144,7 @@ class App extends Kernel * Application specific services and extensions. */ protected const APP = [ + Bootloader\RedisBootloader::class, Auth\AuthBootloader::class, Bootloader\RoutesBootloader::class, Bootloader\UserBootloader::class, diff --git a/app/src/Bootloader/RedisBootloader.php b/app/src/Bootloader/RedisBootloader.php new file mode 100644 index 00000000..689c8129 --- /dev/null +++ b/app/src/Bootloader/RedisBootloader.php @@ -0,0 +1,65 @@ +bindSingleton(Redis::class, fn (): Redis => $this->resolve()); + } + + protected function resolve(): Redis + { + $redis = new Redis(); + + if ($this->config->getHost() === '') { + return $redis; + } + + $uri = "{$this->config->getHost()}:{$this->config->getPort()}"; + + $this->logger->info("Resolving a connection to a Redis instance [{$uri}]"); + + $status = $redis->pconnect( + $this->config->getHost(), + $this->config->getPort(), + $this->config->getTimeout(), + null, + $this->config->getRetryInterval(), + $this->config->getRetryTimeout(), + ); + + if (! $status) { + $this->logger->emergency("Connection to a Redis instance failed [{$uri}]: {$redis->getLastError()}"); + + throw new \RedisException("Unable to connect to Redis"); + } + + if (! $redis->ping()) { + $this->logger->emergency("PING to a Redis instance is not successful [{$uri}]: {$redis->getLastError()}"); + + throw new \RedisException("Unable to connect to Redis"); + } + + $redis->setOption(Redis::OPT_PREFIX, $this->config->getPrefix()); + $redis->setOption(Redis::OPT_MAX_RETRIES, $this->config->getMaxRetries()); + $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY); + + $this->logger->info("Connection to a Redis instance has been established [{$uri}]"); + + return $redis; + } +} diff --git a/app/src/Bootloader/RoutesBootloader.php b/app/src/Bootloader/RoutesBootloader.php index aca0904d..553ca976 100644 --- a/app/src/Bootloader/RoutesBootloader.php +++ b/app/src/Bootloader/RoutesBootloader.php @@ -4,12 +4,15 @@ namespace App\Bootloader; +use App\Auth\AuthMiddleware; use App\Middleware\LocaleSelectorMiddleware; +use App\Middleware\RateLimitMiddleware; use App\Middleware\UserLocaleSelectorMiddleware; use App\Request\JsonErrorsRenderer; -use Spiral\Bootloader\Http\RoutesBootloader as BaseRoutesBootloader; -use App\Auth\AuthMiddleware; +use App\Service\RateLimit\RateLimitInterface; +use App\Service\RateLimit\RedisRateLimit; use Spiral\Auth\Middleware\AuthMiddleware as InitAuthMiddleware; +use Spiral\Bootloader\Http\RoutesBootloader as BaseRoutesBootloader; use Spiral\Debug\StateCollector\HttpCollector; use Spiral\Filter\ValidationHandlerMiddleware; use Spiral\Filters\ErrorsRendererInterface; @@ -24,6 +27,10 @@ final class RoutesBootloader extends BaseRoutesBootloader ErrorsRendererInterface::class => JsonErrorsRenderer::class, ]; + protected const BINDINGS = [ + RateLimitInterface::class => RedisRateLimit::class, + ]; + protected const DEPENDENCIES = [ AnnotatedRoutesBootloader::class, ]; @@ -45,8 +52,12 @@ protected function middlewareGroups(): array return [ 'auth' => [ AuthMiddleware::class, + RateLimitMiddleware::class, UserLocaleSelectorMiddleware::class, ], + 'web' => [ + RateLimitMiddleware::class, + ], ]; } diff --git a/app/src/Bootloader/S3Bootloader.php b/app/src/Bootloader/S3Bootloader.php index 324ec967..0517feb8 100644 --- a/app/src/Bootloader/S3Bootloader.php +++ b/app/src/Bootloader/S3Bootloader.php @@ -15,25 +15,11 @@ */ class S3Bootloader extends Bootloader { - /** - * @var \App\Config\S3Config - */ - private $config; - - /** - * FirebaseBootloader constructor. - * - * @param \App\Config\S3Config $config - */ - public function __construct(S3Config $config) - { - $this->config = $config; + public function __construct( + private S3Config $config, + ) { } - /** - * @param \Spiral\Core\Container $container - * @return void - */ public function boot(Container $container): void { $container->bind(S3ClientInterface::class, function (): S3ClientInterface { diff --git a/app/src/Config/RedisConfig.php b/app/src/Config/RedisConfig.php new file mode 100644 index 00000000..6ec3d730 --- /dev/null +++ b/app/src/Config/RedisConfig.php @@ -0,0 +1,56 @@ + '', + 'timeout' => '', + 'retry_interval' => '', + 'retry_timeout' => '', + 'prefix' => '', + 'max_retries' => '', + ]; + + public function getHost(): string + { + return (string) parse_url($this->config['connection'], PHP_URL_HOST); + } + + public function getPort(): int + { + return (int) parse_url($this->config['connection'], PHP_URL_PORT); + } + + public function getTimeout(): float + { + return (float) $this->config['timeout']; + } + + public function getRetryInterval(): int + { + return (int) $this->config['retry_interval']; + } + + public function getRetryTimeout(): float + { + return (float) $this->config['retry_timeout']; + } + + public function getPrefix(): string + { + return (string) $this->config['prefix']; + } + + public function getMaxRetries(): int + { + return (int) $this->config['max_retries']; + } +} diff --git a/app/src/Middleware/CorsMiddleware.php b/app/src/Middleware/CorsMiddleware.php index 3e53752d..089723a5 100644 --- a/app/src/Middleware/CorsMiddleware.php +++ b/app/src/Middleware/CorsMiddleware.php @@ -12,24 +12,10 @@ class CorsMiddleware implements MiddlewareInterface { - /** - * @var \App\Service\Cors\CorsInterface - */ - protected $service; - - /** - * @param \App\Service\Cors\CorsInterface $service - */ - public function __construct(CorsInterface $service) + public function __construct(protected CorsInterface $service) { - $this->service = $service; } - /** - * @param ServerRequestInterface $request - * @param RequestHandlerInterface $handler - * @return ResponseInterface - */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if ($this->service->isPreflightRequest($request)) { diff --git a/app/src/Middleware/RateLimitMiddleware.php b/app/src/Middleware/RateLimitMiddleware.php new file mode 100644 index 00000000..7685874d --- /dev/null +++ b/app/src/Middleware/RateLimitMiddleware.php @@ -0,0 +1,76 @@ +getHeaderLine(AuthMiddleware::HEADER_USER_ID); + $clientIp = $this->fetchIp($request); + + $rule = $this->ruleFactory->getRule($userId, $clientIp); + + try { + $hit = $this->rateLimit->hit($rule); + } catch (RateLimitReachedException $exception) { + return $this->tooManyRequests($exception->getHit()); + } + + $response = $handler->handle($request); + + return $response->withAddedHeader('X-RateLimit-Limit', (string) $hit->getLimit()) + ->withAddedHeader('X-RateLimit-Remaining', (string) $hit->getRemaining()); + } + + protected function fetchIp(ServerRequestInterface $request): string + { + foreach (self::IP_HEADERS as $header) { + $ip = $request->getHeader($header)[0] ?? ''; + + if ($ip !== '') { + return $ip; + } + } + + return ''; + } + + private function tooManyRequests(RateLimitHitInterface $hit): ResponseInterface + { + return (new JsonResponse([ + 'message' => $this->say('error_rate_limit_reached'), + ], 429)) + ->withAddedHeader('X-RateLimit-Limit', (string) $hit->getLimit()) + ->withAddedHeader('X-RateLimit-Remaining', (string) $hit->getRemaining()) + ->withAddedHeader('Retry-After', (string) $hit->getRetryAfter()); + } +} diff --git a/app/src/Repository/ChargeRepository.php b/app/src/Repository/ChargeRepository.php index 8e4b80ab..328608f4 100644 --- a/app/src/Repository/ChargeRepository.php +++ b/app/src/Repository/ChargeRepository.php @@ -9,9 +9,19 @@ use Cycle\ORM\Select\Repository; use Cycle\Database\Injection\Parameter; +/** + * @extends Repository<\App\Database\Charge> + */ class ChargeRepository extends Repository { + /** + * @use Paginator<\App\Database\Charge> + */ use Paginator; + + /** + * @use Filter<\App\Database\Charge> + */ use Filter; /** @@ -61,7 +71,7 @@ public function findByWalletIdWithPagination(int $walletId) ->orderBy('created_at', 'DESC'); $this->injectFilter($query); - $query = $this->injectPaginator($query); + $this->injectPaginator($query); return $query->fetchAll(); } @@ -80,7 +90,7 @@ public function findByTagIdWithPagination(int $tagId): array ->orderBy('created_at', 'DESC'); $this->injectFilter($query); - $query = $this->injectPaginator($query); + $this->injectPaginator($query); /** @var \App\Database\Charge[] $charges */ $charges = $query->fetchAll(); @@ -103,7 +113,7 @@ public function findByWalletIdAndTagIdWithPagination(int $walletId, int $tagId = ->orderBy('created_at', 'DESC'); $this->injectFilter($query); - $query = $this->injectPaginator($query); + $this->injectPaginator($query); return $query->fetchAll(); } diff --git a/app/src/Repository/CurrencyExchangeRepository.php b/app/src/Repository/CurrencyExchangeRepository.php index e99250ee..06356f59 100644 --- a/app/src/Repository/CurrencyExchangeRepository.php +++ b/app/src/Repository/CurrencyExchangeRepository.php @@ -6,6 +6,9 @@ use Cycle\ORM\Select\Repository; +/** + * @extends Repository<\App\Database\CurrencyExchange> + */ class CurrencyExchangeRepository extends Repository { } diff --git a/app/src/Repository/CurrencyRepository.php b/app/src/Repository/CurrencyRepository.php index 3ff90e52..54eade7f 100644 --- a/app/src/Repository/CurrencyRepository.php +++ b/app/src/Repository/CurrencyRepository.php @@ -8,6 +8,9 @@ use Cycle\ORM\Select\Repository; use Cycle\Database\Injection\Parameter; +/** + * @extends Repository<\App\Database\Currency> + */ class CurrencyRepository extends Repository { const FEATURED_USD = 'USD'; diff --git a/app/src/Repository/EmailConfirmationRepository.php b/app/src/Repository/EmailConfirmationRepository.php index efad3b31..35c2b0c7 100644 --- a/app/src/Repository/EmailConfirmationRepository.php +++ b/app/src/Repository/EmailConfirmationRepository.php @@ -6,6 +6,9 @@ use Cycle\ORM\Select\Repository; +/** + * @extends Repository<\App\Database\EmailConfirmation> + */ class EmailConfirmationRepository extends Repository { /** diff --git a/app/src/Repository/ForgotPasswordRequestRepository.php b/app/src/Repository/ForgotPasswordRequestRepository.php index 7234f13e..c493271c 100644 --- a/app/src/Repository/ForgotPasswordRequestRepository.php +++ b/app/src/Repository/ForgotPasswordRequestRepository.php @@ -6,6 +6,9 @@ use Cycle\ORM\Select\Repository; +/** + * @extends Repository<\App\Database\ForgotPasswordRequest> + */ class ForgotPasswordRequestRepository extends Repository { /** diff --git a/app/src/Repository/Paginator.php b/app/src/Repository/Paginator.php index de7834f5..e91b511e 100644 --- a/app/src/Repository/Paginator.php +++ b/app/src/Repository/Paginator.php @@ -7,6 +7,9 @@ use App\Service\Pagination\PaginatorInterface; use Cycle\ORM\Select; +/** + * @template T of object + */ trait Paginator { /** @@ -38,15 +41,13 @@ public function getPaginationState(): array } /** - * @param \Cycle\ORM\Select $query - * @return \Cycle\ORM\Select + * @param \Cycle\ORM\Select $query + * @return void */ - private function injectPaginator(Select $query): Select + private function injectPaginator(Select $query): void { if ($this->paginator instanceof PaginatorInterface) { $this->paginator = $this->paginator->paginate($query); } - - return $query; } } diff --git a/app/src/Repository/TagRepository.php b/app/src/Repository/TagRepository.php index c7243ba4..d739cf5e 100644 --- a/app/src/Repository/TagRepository.php +++ b/app/src/Repository/TagRepository.php @@ -10,6 +10,9 @@ use Cycle\ORM\Select\Repository; use Cycle\Database\Injection\Parameter; +/** + * @extends Repository<\App\Database\Tag> + */ class TagRepository extends Repository { /** diff --git a/app/src/Repository/UserRepository.php b/app/src/Repository/UserRepository.php index cfd78c0c..31883294 100644 --- a/app/src/Repository/UserRepository.php +++ b/app/src/Repository/UserRepository.php @@ -12,8 +12,15 @@ use Spiral\Auth\TokenInterface; use Cycle\Database\Query\SelectQuery; +/** + * @extends Repository<\App\Database\User> + */ class UserRepository extends Repository implements ActorProviderInterface { + /** + * @param \Cycle\ORM\Select $select + * @param \App\Database\Encrypter\EncrypterInterface $encrypter + */ public function __construct( Select $select, private readonly EncrypterInterface $encrypter, diff --git a/app/src/Repository/WalletRepository.php b/app/src/Repository/WalletRepository.php index d3f8d44a..de569c75 100644 --- a/app/src/Repository/WalletRepository.php +++ b/app/src/Repository/WalletRepository.php @@ -5,10 +5,12 @@ namespace App\Repository; use App\Database\Currency; -use Cycle\Database\Injection\Parameter; use Cycle\ORM\Select; use Cycle\ORM\Select\Repository; +/** + * @extends Repository<\App\Database\Wallet> + */ class WalletRepository extends Repository { /** diff --git a/app/src/Service/Filter/Filter.php b/app/src/Service/Filter/Filter.php index 76219ea5..d9b59909 100644 --- a/app/src/Service/Filter/Filter.php +++ b/app/src/Service/Filter/Filter.php @@ -7,6 +7,9 @@ use Cycle\Database\Query\SelectQuery; use Cycle\ORM\Select; +/** + * @template T of object + */ trait Filter { /** @@ -55,6 +58,10 @@ protected function filterColumnsMapping(): array ]; } + /** + * @param \Cycle\ORM\Select|\Cycle\Database\Query\SelectQuery $query + * @return void + */ protected function injectFilter(Select|SelectQuery $query): void { if (! count($this->filter)) { diff --git a/app/src/Service/RateLimit/GuestRule.php b/app/src/Service/RateLimit/GuestRule.php new file mode 100644 index 00000000..d755c349 --- /dev/null +++ b/app/src/Service/RateLimit/GuestRule.php @@ -0,0 +1,32 @@ +clientIp; + } + + public function with(string $clientIp = ''): static + { + $this->clientIp = $clientIp; + + return $this; + } +} diff --git a/app/src/Service/RateLimit/RateLimitHit.php b/app/src/Service/RateLimit/RateLimitHit.php new file mode 100644 index 00000000..723a7404 --- /dev/null +++ b/app/src/Service/RateLimit/RateLimitHit.php @@ -0,0 +1,44 @@ +counter >= $this->rule->limit(); + } + + public function getLimit(): int + { + return $this->rule->limit(); + } + + public function getRemaining(): int + { + if ($this->rule->limit() > $this->counter) { + return $this->rule->limit() - $this->counter; + } + + return 0; + } + + public function getRetryAfter(): int + { + return $this->ttl; + } + + public function getRule(): RuleInterface + { + return $this->rule; + } +} diff --git a/app/src/Service/RateLimit/RateLimitHitInterface.php b/app/src/Service/RateLimit/RateLimitHitInterface.php new file mode 100644 index 00000000..e462131a --- /dev/null +++ b/app/src/Service/RateLimit/RateLimitHitInterface.php @@ -0,0 +1,18 @@ +hit; + } +} diff --git a/app/src/Service/RateLimit/RedisRateLimit.php b/app/src/Service/RateLimit/RedisRateLimit.php new file mode 100644 index 00000000..05a38a64 --- /dev/null +++ b/app/src/Service/RateLimit/RedisRateLimit.php @@ -0,0 +1,58 @@ +redis->isConnected()) { + return new RateLimitHit($rule); + } + + $key = static::PREFIX . $rule->key(); + + $counter = $this->redis->incr($key); + if (! is_int($counter)) { + throw new \RuntimeException( + "Unable to increment rate limit counter: {$this->redis->getLastError()}" + ); + } + + $ttl = $this->redis->ttl($key); + if (! is_int($ttl)) { + throw new \RuntimeException( + "Unable to retrieve rate limit counter time to live: {$this->redis->getLastError()}" + ); + } + + if ($ttl === -1) { + if ($this->redis->expire($key, $rule->ttl()) === false) { + throw new \RuntimeException( + "Unable to set expiration for rate limit counter: {$this->redis->getLastError()}" + ); + } + + $ttl = $rule->ttl(); + } + + $hit = new RateLimitHit($rule, $counter, $ttl); + + if ($hit->isReached()) { + throw new RateLimitReachedException($hit); + } + + return $hit; + } +} diff --git a/app/src/Service/RateLimit/Rule.php b/app/src/Service/RateLimit/Rule.php new file mode 100644 index 00000000..1ea9761e --- /dev/null +++ b/app/src/Service/RateLimit/Rule.php @@ -0,0 +1,48 @@ +limit; + } + + public function ttl(): int + { + return $this->ttl; + } + + public function withLimit(int $limit): static + { + $self = clone $this; + $self->limit = $limit; + return $self; + } + + public function withTtl(int $ttl): static + { + $self = clone $this; + $self->ttl = $ttl; + return $self; + } +} diff --git a/app/src/Service/RateLimit/RuleFactory.php b/app/src/Service/RateLimit/RuleFactory.php new file mode 100644 index 00000000..c5b94a93 --- /dev/null +++ b/app/src/Service/RateLimit/RuleFactory.php @@ -0,0 +1,17 @@ +with($clientIp); + } + + return (new UserRule())->with($userId, $clientIp); + } +} diff --git a/app/src/Service/RateLimit/RuleInterface.php b/app/src/Service/RateLimit/RuleInterface.php new file mode 100644 index 00000000..6e9a4875 --- /dev/null +++ b/app/src/Service/RateLimit/RuleInterface.php @@ -0,0 +1,18 @@ +userId; + + if ($this->clientIp !== '') { + $key .= "-{$this->clientIp}"; + } + + return $key; + } + + public function with(string $userId = '', string $clientIp = ''): static + { + $this->userId = $userId; + $this->clientIp = $clientIp; + + return $this; + } +} diff --git a/app/src/Service/Statistics/ChargeAmountData.php b/app/src/Service/Statistics/ChargeAmountData.php index 82dbe9cf..68aaee12 100644 --- a/app/src/Service/Statistics/ChargeAmountData.php +++ b/app/src/Service/Statistics/ChargeAmountData.php @@ -7,6 +7,9 @@ class ChargeAmountData { + /** + * @use Filter<\App\Database\Charge> + */ use Filter; protected array $income = []; diff --git a/app/src/Service/Statistics/ChargeAmountGraph.php b/app/src/Service/Statistics/ChargeAmountGraph.php index 3b2554dc..23167e31 100644 --- a/app/src/Service/Statistics/ChargeAmountGraph.php +++ b/app/src/Service/Statistics/ChargeAmountGraph.php @@ -15,6 +15,9 @@ class ChargeAmountGraph { + /** + * @use Filter<\App\Database\Charge> + */ use Filter; protected Group $grouping = Group::ByMonth; diff --git a/app/src/Service/WalletService.php b/app/src/Service/WalletService.php index 22ef64cf..d81040c1 100644 --- a/app/src/Service/WalletService.php +++ b/app/src/Service/WalletService.php @@ -139,7 +139,7 @@ public function unArchive(Wallet $wallet): Wallet */ public function share(Wallet $wallet, User $user, User $sharer): Wallet { - /** @psalm-suppress DocblockTypeContradiction */ + /** @psalm-suppress InvalidArgument */ if ($wallet->users->contains($user) === true) { return $wallet; } @@ -160,6 +160,7 @@ public function share(Wallet $wallet, User $user, User $sharer): Wallet */ public function revoke(Wallet $wallet, User $user): Wallet { + /** @psalm-suppress InvalidArgument */ if ($wallet->users->contains($user) === false) { return $wallet; } diff --git a/composer.json b/composer.json index 18a82d52..d7ced2a0 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "ext-openssl": "*", "ext-pdo": "*", "ext-sockets": "*", + "ext-redis": "*", "aws/aws-sdk-php": "^3.190", "cycle/entity-behavior": "^1.1", "cycle/entity-behavior-uuid": "^1.0", @@ -44,7 +45,7 @@ "squizlabs/php_codesniffer": "3.*", "spiral/testing": "^2.3", "symfony/var-dumper": "^6.2", - "vimeo/psalm": "^4.30" + "vimeo/psalm": "^5.9.0" }, "scripts": { "post-create-project-cmd": [ diff --git a/composer.lock b/composer.lock index c410aa1b..13430f83 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a5bd8091c3fbe206849c20401431813", + "content-hash": "5f39afab528b29c9de32beaf1929fd52", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.268.16", + "version": "3.269.7", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "b59134c9ca64dcb9de6f7dbbcb9d5a75ed665a98" + "reference": "f34afba20e3ad782ed91332a8306b91b06a1de5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b59134c9ca64dcb9de6f7dbbcb9d5a75ed665a98", - "reference": "b59134c9ca64dcb9de6f7dbbcb9d5a75ed665a98", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f34afba20e3ad782ed91332a8306b91b06a1de5c", + "reference": "f34afba20e3ad782ed91332a8306b91b06a1de5c", "shasum": "" }, "require": { @@ -83,7 +83,8 @@ "guzzlehttp/promises": "^1.4.0", "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", "mtdowling/jmespath.php": "^2.6", - "php": ">=5.5" + "php": ">=5.5", + "psr/http-message": "^1.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -100,7 +101,6 @@ "paragonie/random_compat": ">= 2", "phpunit/phpunit": "^4.8.35 || ^5.6.3 || ^9.5", "psr/cache": "^1.0", - "psr/http-message": "^1.0", "psr/simple-cache": "^1.0", "sebastian/comparator": "^1.2.3 || ^4.0", "yoast/phpunit-polyfills": "^1.0" @@ -151,9 +151,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.268.16" + "source": "https://github.com/aws/aws-sdk-php/tree/3.269.7" }, - "time": "2023-04-21T21:37:05+00:00" + "time": "2023-05-05T19:51:13+00:00" }, { "name": "beste/clock", @@ -822,16 +822,16 @@ }, { "name": "cycle/orm", - "version": "v2.3.0", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/cycle/orm.git", - "reference": "7c142c0ebfdf954931bfbaa2cf08f53c486ce7bb" + "reference": "86c1adccbd41dbae7a2157a1476093c7a96d54e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cycle/orm/zipball/7c142c0ebfdf954931bfbaa2cf08f53c486ce7bb", - "reference": "7c142c0ebfdf954931bfbaa2cf08f53c486ce7bb", + "url": "https://api.github.com/repos/cycle/orm/zipball/86c1adccbd41dbae7a2157a1476093c7a96d54e8", + "reference": "86c1adccbd41dbae7a2157a1476093c7a96d54e8", "shasum": "" }, "require": { @@ -863,7 +863,7 @@ "description": "PHP DataMapper ORM and Data Modelling Engine", "support": { "issues": "https://github.com/cycle/orm/issues", - "source": "https://github.com/cycle/orm/tree/v2.3.0" + "source": "https://github.com/cycle/orm/tree/v2.3.1" }, "funding": [ { @@ -871,7 +871,7 @@ "type": "github" } ], - "time": "2023-04-03T15:53:00+00:00" + "time": "2023-05-01T11:08:56+00:00" }, { "name": "cycle/schema-builder", @@ -1741,32 +1741,32 @@ }, { "name": "google/auth", - "version": "v1.26.0", + "version": "v1.27.0", "source": { "type": "git", "url": "https://github.com/googleapis/google-auth-library-php.git", - "reference": "f1f0d0319e2e7750ebfaa523c78819792a9ed9f7" + "reference": "81a5d40937fb4afc26e80b6fa25b3f164a5763d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/f1f0d0319e2e7750ebfaa523c78819792a9ed9f7", - "reference": "f1f0d0319e2e7750ebfaa523c78819792a9ed9f7", + "url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/81a5d40937fb4afc26e80b6fa25b3f164a5763d2", + "reference": "81a5d40937fb4afc26e80b6fa25b3f164a5763d2", "shasum": "" }, "require": { - "firebase/php-jwt": "^5.5||^6.0", + "firebase/php-jwt": "^6.0", "guzzlehttp/guzzle": "^6.2.1|^7.0", - "guzzlehttp/psr7": "^1.7|^2.0", - "php": "^7.1||^8.0", - "psr/cache": "^1.0|^2.0|^3.0", - "psr/http-message": "^1.0" + "guzzlehttp/psr7": "^2.4.5", + "php": "^7.4||^8.0", + "psr/cache": "^1.0||^2.0||^3.0", + "psr/http-message": "^1.1||^2.0" }, "require-dev": { - "guzzlehttp/promises": "0.1.1|^1.3", + "guzzlehttp/promises": "^1.3", "kelvinmo/simplejwt": "0.7.0", - "phpseclib/phpseclib": "^2.0.31||^3.0", - "phpspec/prophecy-phpunit": "^1.1||^2.0", - "phpunit/phpunit": "^7.5||^9.0.0", + "phpseclib/phpseclib": "^3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.0.0", "sebastian/comparator": ">=1.2.3", "squizlabs/php_codesniffer": "^3.5" }, @@ -1793,22 +1793,22 @@ "support": { "docs": "https://googleapis.github.io/google-auth-library-php/main/", "issues": "https://github.com/googleapis/google-auth-library-php/issues", - "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.26.0" + "source": "https://github.com/googleapis/google-auth-library-php/tree/v1.27.0" }, - "time": "2023-04-05T15:11:57+00:00" + "time": "2023-05-02T21:53:58+00:00" }, { "name": "google/cloud-core", - "version": "v1.50.0", + "version": "v1.51.2", "source": { "type": "git", "url": "https://github.com/googleapis/google-cloud-php-core.git", - "reference": "2cb01dbf61e5818cc4a19f1289839223d074018e" + "reference": "85dc48d62143f4bbfaa34c24da95003371de7b79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-cloud-php-core/zipball/2cb01dbf61e5818cc4a19f1289839223d074018e", - "reference": "2cb01dbf61e5818cc4a19f1289839223d074018e", + "url": "https://api.github.com/repos/googleapis/google-cloud-php-core/zipball/85dc48d62143f4bbfaa34c24da95003371de7b79", + "reference": "85dc48d62143f4bbfaa34c24da95003371de7b79", "shasum": "" }, "require": { @@ -1858,28 +1858,29 @@ ], "description": "Google Cloud PHP shared dependency, providing functionality useful to all components.", "support": { - "source": "https://github.com/googleapis/google-cloud-php-core/tree/v1.50.0" + "source": "https://github.com/googleapis/google-cloud-php-core/tree/v1.51.2" }, - "time": "2023-04-21T22:21:40+00:00" + "time": "2023-05-05T23:01:42+00:00" }, { "name": "google/cloud-storage", - "version": "v1.30.3", + "version": "v1.31.2", "source": { "type": "git", "url": "https://github.com/googleapis/google-cloud-php-storage.git", - "reference": "43f885c04a22473440e29538dfa2f57d1f2bf83f" + "reference": "7fe96d56856cda550b21779bb95a066b264852da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-cloud-php-storage/zipball/43f885c04a22473440e29538dfa2f57d1f2bf83f", - "reference": "43f885c04a22473440e29538dfa2f57d1f2bf83f", + "url": "https://api.github.com/repos/googleapis/google-cloud-php-storage/zipball/7fe96d56856cda550b21779bb95a066b264852da", + "reference": "7fe96d56856cda550b21779bb95a066b264852da", "shasum": "" }, "require": { - "google/cloud-core": "^1.43", + "google/cloud-core": "^1.51.1", "google/crc32": "^0.2.0", - "php": ">=7.4" + "php": ">=7.4", + "ramsey/uuid": "^4.2.3" }, "require-dev": { "erusev/parsedown": "^1.6", @@ -1914,9 +1915,9 @@ ], "description": "Cloud Storage Client for PHP", "support": { - "source": "https://github.com/googleapis/google-cloud-php-storage/tree/v1.30.3" + "source": "https://github.com/googleapis/google-cloud-php-storage/tree/v1.31.2" }, - "time": "2023-04-21T22:21:40+00:00" + "time": "2023-05-05T23:01:42+00:00" }, { "name": "google/common-protos", @@ -2017,16 +2018,16 @@ }, { "name": "google/protobuf", - "version": "v3.22.3", + "version": "v3.22.4", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "0edeee0cc2e2991706e16ab60c7d224e5c0241ba" + "reference": "e0768e89d3e336da07c369a6ffc81fae2b38a009" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/0edeee0cc2e2991706e16ab60c7d224e5c0241ba", - "reference": "0edeee0cc2e2991706e16ab60c7d224e5c0241ba", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/e0768e89d3e336da07c369a6ffc81fae2b38a009", + "reference": "e0768e89d3e336da07c369a6ffc81fae2b38a009", "shasum": "" }, "require": { @@ -2055,9 +2056,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v3.22.3" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v3.22.4" }, - "time": "2023-04-12T23:38:34+00:00" + "time": "2023-05-04T18:02:56+00:00" }, { "name": "graham-campbell/result-type", @@ -2495,7 +2496,7 @@ }, { "name": "illuminate/collections", - "version": "v9.52.6", + "version": "v9.52.7", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", @@ -2550,7 +2551,7 @@ }, { "name": "illuminate/conditionable", - "version": "v9.52.6", + "version": "v9.52.7", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -2596,7 +2597,7 @@ }, { "name": "illuminate/contracts", - "version": "v9.52.6", + "version": "v9.52.7", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", @@ -2644,7 +2645,7 @@ }, { "name": "illuminate/macroable", - "version": "v9.52.6", + "version": "v9.52.7", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -3620,16 +3621,16 @@ }, { "name": "nette/php-generator", - "version": "v4.0.6", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/nette/php-generator.git", - "reference": "0f1275bb8d39b3eb92b57c22a51fe693f1f145a5" + "reference": "de1843fbb692125e307937c85d43937d0dc0c1d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/php-generator/zipball/0f1275bb8d39b3eb92b57c22a51fe693f1f145a5", - "reference": "0f1275bb8d39b3eb92b57c22a51fe693f1f145a5", + "url": "https://api.github.com/repos/nette/php-generator/zipball/de1843fbb692125e307937c85d43937d0dc0c1d4", + "reference": "de1843fbb692125e307937c85d43937d0dc0c1d4", "shasum": "" }, "require": { @@ -3683,9 +3684,9 @@ ], "support": { "issues": "https://github.com/nette/php-generator/issues", - "source": "https://github.com/nette/php-generator/tree/v4.0.6" + "source": "https://github.com/nette/php-generator/tree/v4.0.7" }, - "time": "2023-03-13T17:38:30+00:00" + "time": "2023-04-26T15:09:53+00:00" }, { "name": "nette/utils", @@ -3832,21 +3833,20 @@ }, { "name": "nyholm/psr7", - "version": "1.7.0", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/Nyholm/psr7.git", - "reference": "ed7cf98f6562831dbc3c962406b5e49dc8179c8c" + "reference": "3cb4d163b58589e47b35103e8e5e6a6a475b47be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Nyholm/psr7/zipball/ed7cf98f6562831dbc3c962406b5e49dc8179c8c", - "reference": "ed7cf98f6562831dbc3c962406b5e49dc8179c8c", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/3cb4d163b58589e47b35103e8e5e6a6a475b47be", + "reference": "3cb4d163b58589e47b35103e8e5e6a6a475b47be", "shasum": "" }, "require": { "php": ">=7.2", - "php-http/message-factory": "^1.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0" }, @@ -3857,14 +3857,15 @@ }, "require-dev": { "http-interop/http-factory-tests": "^0.9", - "php-http/psr7-integration-tests": "^1.0@dev", - "phpunit/phpunit": "^7.5 || 8.5 || 9.4", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", "symfony/error-handler": "^4.4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.7-dev" + "dev-master": "1.8-dev" } }, "autoload": { @@ -3894,7 +3895,7 @@ ], "support": { "issues": "https://github.com/Nyholm/psr7/issues", - "source": "https://github.com/Nyholm/psr7/tree/1.7.0" + "source": "https://github.com/Nyholm/psr7/tree/1.8.0" }, "funding": [ { @@ -3906,7 +3907,7 @@ "type": "github" } ], - "time": "2023-04-20T08:38:48+00:00" + "time": "2023-05-02T11:26:24+00:00" }, { "name": "opis/closure", @@ -4953,16 +4954,16 @@ }, { "name": "riverline/multipart-parser", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/Riverline/multipart-parser.git", - "reference": "68e5499c54e455830bd3cfa4e5abe8c5d71360c8" + "reference": "2418bdfc2eab01e39bcffee808b1a365c166292a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Riverline/multipart-parser/zipball/68e5499c54e455830bd3cfa4e5abe8c5d71360c8", - "reference": "68e5499c54e455830bd3cfa4e5abe8c5d71360c8", + "url": "https://api.github.com/repos/Riverline/multipart-parser/zipball/2418bdfc2eab01e39bcffee808b1a365c166292a", + "reference": "2418bdfc2eab01e39bcffee808b1a365c166292a", "shasum": "" }, "require": { @@ -5003,9 +5004,9 @@ ], "support": { "issues": "https://github.com/Riverline/multipart-parser/issues", - "source": "https://github.com/Riverline/multipart-parser/tree/2.1.0" + "source": "https://github.com/Riverline/multipart-parser/tree/2.1.1" }, - "time": "2023-04-12T14:30:23+00:00" + "time": "2023-04-28T18:53:59+00:00" }, { "name": "rize/uri-template", @@ -6738,16 +6739,16 @@ }, { "name": "symfony/console", - "version": "v6.2.8", + "version": "v6.2.10", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "3582d68a64a86ec25240aaa521ec8bc2342b369b" + "reference": "12288d9f4500f84a4d02254d4aa968b15488476f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3582d68a64a86ec25240aaa521ec8bc2342b369b", - "reference": "3582d68a64a86ec25240aaa521ec8bc2342b369b", + "url": "https://api.github.com/repos/symfony/console/zipball/12288d9f4500f84a4d02254d4aa968b15488476f", + "reference": "12288d9f4500f84a4d02254d4aa968b15488476f", "shasum": "" }, "require": { @@ -6814,7 +6815,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.2.8" + "source": "https://github.com/symfony/console/tree/v6.2.10" }, "funding": [ { @@ -6830,7 +6831,7 @@ "type": "tidelift" } ], - "time": "2023-03-29T21:42:15+00:00" + "time": "2023-04-28T13:37:43+00:00" }, { "name": "symfony/deprecation-contracts", @@ -7127,16 +7128,16 @@ }, { "name": "symfony/http-client", - "version": "v6.2.9", + "version": "v6.2.10", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "7daf5d24c21a683164688b95bb73b7a4bd3b32fc" + "reference": "3f5545a91c8e79dedd1a06c4b04e1682c80c42f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/7daf5d24c21a683164688b95bb73b7a4bd3b32fc", - "reference": "7daf5d24c21a683164688b95bb73b7a4bd3b32fc", + "url": "https://api.github.com/repos/symfony/http-client/zipball/3f5545a91c8e79dedd1a06c4b04e1682c80c42f9", + "reference": "3f5545a91c8e79dedd1a06c4b04e1682c80c42f9", "shasum": "" }, "require": { @@ -7195,7 +7196,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.2.9" + "source": "https://github.com/symfony/http-client/tree/v6.2.10" }, "funding": [ { @@ -7211,7 +7212,7 @@ "type": "tidelift" } ], - "time": "2023-04-11T16:03:19+00:00" + "time": "2023-04-20T13:12:48+00:00" }, { "name": "symfony/http-client-contracts", @@ -7375,16 +7376,16 @@ }, { "name": "symfony/mime", - "version": "v6.2.7", + "version": "v6.2.10", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "62e341f80699badb0ad70b31149c8df89a2d778e" + "reference": "b6c137fc53a9f7c4c951cd3f362b3734c7a97723" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/62e341f80699badb0ad70b31149c8df89a2d778e", - "reference": "62e341f80699badb0ad70b31149c8df89a2d778e", + "url": "https://api.github.com/repos/symfony/mime/zipball/b6c137fc53a9f7c4c951cd3f362b3734c7a97723", + "reference": "b6c137fc53a9f7c4c951cd3f362b3734c7a97723", "shasum": "" }, "require": { @@ -7438,7 +7439,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.2.7" + "source": "https://github.com/symfony/mime/tree/v6.2.10" }, "funding": [ { @@ -7454,7 +7455,7 @@ "type": "tidelift" } ], - "time": "2023-02-24T10:42:00+00:00" + "time": "2023-04-19T09:54:16+00:00" }, { "name": "symfony/polyfill-ctype", @@ -8117,16 +8118,16 @@ }, { "name": "symfony/process", - "version": "v6.2.8", + "version": "v6.2.10", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "75ed64103df4f6615e15a7fe38b8111099f47416" + "reference": "b34cdbc9c5e75d45a3703e63a48ad07aafa8bf2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/75ed64103df4f6615e15a7fe38b8111099f47416", - "reference": "75ed64103df4f6615e15a7fe38b8111099f47416", + "url": "https://api.github.com/repos/symfony/process/zipball/b34cdbc9c5e75d45a3703e63a48ad07aafa8bf2e", + "reference": "b34cdbc9c5e75d45a3703e63a48ad07aafa8bf2e", "shasum": "" }, "require": { @@ -8158,7 +8159,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.2.8" + "source": "https://github.com/symfony/process/tree/v6.2.10" }, "funding": [ { @@ -8174,7 +8175,7 @@ "type": "tidelift" } ], - "time": "2023-03-09T16:20:02+00:00" + "time": "2023-04-18T13:56:57+00:00" }, { "name": "symfony/service-contracts", @@ -8528,16 +8529,16 @@ }, { "name": "symfony/yaml", - "version": "v6.2.7", + "version": "v6.2.10", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "e8e6a1d59e050525f27a1f530aa9703423cb7f57" + "reference": "61916f3861b1e9705b18cfde723921a71dd1559d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/e8e6a1d59e050525f27a1f530aa9703423cb7f57", - "reference": "e8e6a1d59e050525f27a1f530aa9703423cb7f57", + "url": "https://api.github.com/repos/symfony/yaml/zipball/61916f3861b1e9705b18cfde723921a71dd1559d", + "reference": "61916f3861b1e9705b18cfde723921a71dd1559d", "shasum": "" }, "require": { @@ -8582,7 +8583,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.2.7" + "source": "https://github.com/symfony/yaml/tree/v6.2.10" }, "funding": [ { @@ -8598,7 +8599,7 @@ "type": "tidelift" } ], - "time": "2023-02-16T09:57:23+00:00" + "time": "2023-04-28T13:25:36+00:00" }, { "name": "vlucas/phpdotenv", @@ -9212,79 +9213,6 @@ ], "time": "2021-03-30T17:13:30+00:00" }, - { - "name": "composer/package-versions-deprecated", - "version": "1.11.99.5", - "source": { - "type": "git", - "url": "https://github.com/composer/package-versions-deprecated.git", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/package-versions-deprecated/zipball/b4f54f74ef3453349c24a845d22392cd31e65f1d", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.1.0 || ^2.0", - "php": "^7 || ^8" - }, - "replace": { - "ocramius/package-versions": "1.11.99" - }, - "require-dev": { - "composer/composer": "^1.9.3 || ^2.0@dev", - "ext-zip": "^1.13", - "phpunit/phpunit": "^6.5 || ^7" - }, - "type": "composer-plugin", - "extra": { - "class": "PackageVersions\\Installer", - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "PackageVersions\\": "src/PackageVersions" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" - } - ], - "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", - "support": { - "issues": "https://github.com/composer/package-versions-deprecated/issues", - "source": "https://github.com/composer/package-versions-deprecated/tree/1.11.99.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2022-01-17T14:14:24+00:00" - }, { "name": "composer/pcre", "version": "3.1.0", @@ -9560,6 +9488,67 @@ }, "time": "2022-03-02T22:36:06+00:00" }, + { + "name": "fidry/cpu-core-counter", + "version": "0.5.1", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "b58e5a3933e541dc286cc91fc4f3898bbc6f1623" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/b58e5a3933e541dc286cc91fc4f3898bbc6f1623", + "reference": "b58e5a3933e541dc286cc91fc4f3898bbc6f1623", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^1.9.2", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.2.2", + "phpstan/phpstan-strict-rules": "^1.4.4", + "phpunit/phpunit": "^9.5.26 || ^8.5.31", + "theofidry/php-cs-fixer-config": "^1.0", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/0.5.1" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2022-12-24T12:35:10+00:00" + }, { "name": "hamcrest/hamcrest-php", "version": "v2.0.1", @@ -9734,59 +9723,6 @@ }, "time": "2023-04-09T17:37:40+00:00" }, - { - "name": "openlss/lib-array2xml", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/nullivex/lib-array2xml.git", - "reference": "a91f18a8dfc69ffabe5f9b068bc39bb202c81d90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nullivex/lib-array2xml/zipball/a91f18a8dfc69ffabe5f9b068bc39bb202c81d90", - "reference": "a91f18a8dfc69ffabe5f9b068bc39bb202c81d90", - "shasum": "" - }, - "require": { - "php": ">=5.3.2" - }, - "type": "library", - "autoload": { - "psr-0": { - "LSS": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Bryan Tong", - "email": "bryan@nullivex.com", - "homepage": "https://www.nullivex.com" - }, - { - "name": "Tony Butler", - "email": "spudz76@gmail.com", - "homepage": "https://www.nullivex.com" - } - ], - "description": "Array2XML conversion library credit to lalit.org", - "homepage": "https://www.nullivex.com", - "keywords": [ - "array", - "array conversion", - "xml", - "xml conversion" - ], - "support": { - "issues": "https://github.com/nullivex/lib-array2xml/issues", - "source": "https://github.com/nullivex/lib-array2xml/tree/master" - }, - "time": "2019-03-29T20:06:56+00:00" - }, { "name": "phar-io/manifest", "version": "2.0.3", @@ -10068,16 +10004,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.20.1", + "version": "1.20.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "57f6787f0bb6431905a18aa7caea25dcd2bd59e0" + "reference": "7d568c87a9df9c5f7e8b5f075fc469aa8cb0a4cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/57f6787f0bb6431905a18aa7caea25dcd2bd59e0", - "reference": "57f6787f0bb6431905a18aa7caea25dcd2bd59e0", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/7d568c87a9df9c5f7e8b5f075fc469aa8cb0a4cd", + "reference": "7d568c87a9df9c5f7e8b5f075fc469aa8cb0a4cd", "shasum": "" }, "require": { @@ -10107,9 +10043,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.20.1" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.20.4" }, - "time": "2023-04-22T09:05:52+00:00" + "time": "2023-05-02T09:19:37+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10887,16 +10823,16 @@ }, { "name": "sebastian/diff", - "version": "4.0.4", + "version": "4.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d" + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d", - "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131", "shasum": "" }, "require": { @@ -10941,7 +10877,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4" + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.5" }, "funding": [ { @@ -10949,7 +10885,7 @@ "type": "github" } ], - "time": "2020-10-26T13:10:38+00:00" + "time": "2023-05-07T05:35:17+00:00" }, { "name": "sebastian/environment", @@ -11551,6 +11487,69 @@ ], "time": "2020-09-28T06:39:44+00:00" }, + { + "name": "spatie/array-to-xml", + "version": "3.1.5", + "source": { + "type": "git", + "url": "https://github.com/spatie/array-to-xml.git", + "reference": "13f76acef5362d15c71ae1ac6350cc3df5e25e43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/array-to-xml/zipball/13f76acef5362d15c71ae1ac6350cc3df5e25e43", + "reference": "13f76acef5362d15c71ae1ac6350cc3df5e25e43", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": "^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "pestphp/pest": "^1.21", + "spatie/pest-plugin-snapshots": "^1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ArrayToXml\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://freek.dev", + "role": "Developer" + } + ], + "description": "Convert an array to xml", + "homepage": "https://github.com/spatie/array-to-xml", + "keywords": [ + "array", + "convert", + "xml" + ], + "support": { + "source": "https://github.com/spatie/array-to-xml/tree/3.1.5" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2022-12-24T13:43:51+00:00" + }, { "name": "spiral/testing", "version": "2.3.0", @@ -11709,18 +11708,81 @@ }, "time": "2023-02-22T23:07:41+00:00" }, + { + "name": "symfony/filesystem", + "version": "v6.2.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "fd588debf7d1bc16a2c84b4b3b71145d9946b894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/fd588debf7d1bc16a2c84b4b3b71145d9946b894", + "reference": "fd588debf7d1bc16a2c84b4b3b71145d9946b894", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.2.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-04-18T13:46:08+00:00" + }, { "name": "symfony/var-dumper", - "version": "v6.2.8", + "version": "v6.2.10", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "d37ab6787be2db993747b6218fcc96e8e3bb4bd0" + "reference": "41a750a23412ca76fdbbf5096943b4134272c1ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/d37ab6787be2db993747b6218fcc96e8e3bb4bd0", - "reference": "d37ab6787be2db993747b6218fcc96e8e3bb4bd0", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41a750a23412ca76fdbbf5096943b4134272c1ab", + "reference": "41a750a23412ca76fdbbf5096943b4134272c1ab", "shasum": "" }, "require": { @@ -11779,7 +11841,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.2.8" + "source": "https://github.com/symfony/var-dumper/tree/v6.2.10" }, "funding": [ { @@ -11795,7 +11857,7 @@ "type": "tidelift" } ], - "time": "2023-03-29T21:42:15+00:00" + "time": "2023-04-18T13:46:08+00:00" }, { "name": "theseer/tokenizer", @@ -11849,24 +11911,24 @@ }, { "name": "vimeo/psalm", - "version": "4.30.0", + "version": "5.11.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "d0bc6e25d89f649e4f36a534f330f8bb4643dd69" + "reference": "c9b192ab8400fdaf04b2b13d110575adc879aa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/d0bc6e25d89f649e4f36a534f330f8bb4643dd69", - "reference": "d0bc6e25d89f649e4f36a534f330f8bb4643dd69", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/c9b192ab8400fdaf04b2b13d110575adc879aa90", + "reference": "c9b192ab8400fdaf04b2b13d110575adc879aa90", "shasum": "" }, "require": { "amphp/amp": "^2.4.2", "amphp/byte-stream": "^1.5", - "composer/package-versions-deprecated": "^1.8.0", + "composer-runtime-api": "^2", "composer/semver": "^1.4 || ^2.0 || ^3.0", - "composer/xdebug-handler": "^1.1 || ^2.0 || ^3.0", + "composer/xdebug-handler": "^2.0 || ^3.0", "dnoegel/php-xdg-base-dir": "^0.1.1", "ext-ctype": "*", "ext-dom": "*", @@ -11875,35 +11937,35 @@ "ext-mbstring": "*", "ext-simplexml": "*", "ext-tokenizer": "*", - "felixfbecker/advanced-json-rpc": "^3.0.3", - "felixfbecker/language-server-protocol": "^1.5", + "felixfbecker/advanced-json-rpc": "^3.1", + "felixfbecker/language-server-protocol": "^1.5.2", + "fidry/cpu-core-counter": "^0.4.1 || ^0.5.1", "netresearch/jsonmapper": "^1.0 || ^2.0 || ^3.0 || ^4.0", - "nikic/php-parser": "^4.13", - "openlss/lib-array2xml": "^1.0", - "php": "^7.1|^8", - "sebastian/diff": "^3.0 || ^4.0", - "symfony/console": "^3.4.17 || ^4.1.6 || ^5.0 || ^6.0", - "symfony/polyfill-php80": "^1.25", - "webmozart/path-util": "^2.3" + "nikic/php-parser": "^4.14", + "php": "^7.4 || ~8.0.0 || ~8.1.0 || ~8.2.0", + "sebastian/diff": "^4.0 || ^5.0", + "spatie/array-to-xml": "^2.17.0 || ^3.0", + "symfony/console": "^4.1.6 || ^5.0 || ^6.0", + "symfony/filesystem": "^5.4 || ^6.0" }, "provide": { "psalm/psalm": "self.version" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2", - "brianium/paratest": "^4.0||^6.0", + "amphp/phpunit-util": "^2.0", + "bamarni/composer-bin-plugin": "^1.4", + "brianium/paratest": "^6.9", "ext-curl": "*", + "mockery/mockery": "^1.5", + "nunomaduro/mock-final-classes": "^1.1", "php-parallel-lint/php-parallel-lint": "^1.2", - "phpdocumentor/reflection-docblock": "^5", - "phpmyadmin/sql-parser": "5.1.0||dev-master", - "phpspec/prophecy": ">=1.9.0", - "phpstan/phpdoc-parser": "1.2.* || 1.6.4", - "phpunit/phpunit": "^9.0", - "psalm/plugin-phpunit": "^0.16", - "slevomat/coding-standard": "^7.0", - "squizlabs/php_codesniffer": "^3.5", - "symfony/process": "^4.3 || ^5.0 || ^6.0", - "weirdan/prophecy-shim": "^1.0 || ^2.0" + "phpstan/phpdoc-parser": "^1.6", + "phpunit/phpunit": "^9.6", + "psalm/plugin-mockery": "^1.1", + "psalm/plugin-phpunit": "^0.18", + "slevomat/coding-standard": "^8.4", + "squizlabs/php_codesniffer": "^3.6", + "symfony/process": "^4.4 || ^5.0 || ^6.0" }, "suggest": { "ext-curl": "In order to send data to shepherd", @@ -11919,17 +11981,14 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.x-dev", + "dev-master": "5.x-dev", + "dev-4.x": "4.x-dev", "dev-3.x": "3.x-dev", "dev-2.x": "2.x-dev", "dev-1.x": "1.x-dev" } }, "autoload": { - "files": [ - "src/functions.php", - "src/spl_object_id.php" - ], "psr-4": { "Psalm\\": "src/Psalm/" } @@ -11947,64 +12006,14 @@ "keywords": [ "code", "inspection", - "php" + "php", + "static analysis" ], "support": { "issues": "https://github.com/vimeo/psalm/issues", - "source": "https://github.com/vimeo/psalm/tree/4.30.0" - }, - "time": "2022-11-06T20:37:08+00:00" - }, - { - "name": "webmozart/path-util", - "version": "2.3.0", - "source": { - "type": "git", - "url": "https://github.com/webmozart/path-util.git", - "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozart/path-util/zipball/d939f7edc24c9a1bb9c0dee5cb05d8e859490725", - "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "webmozart/assert": "~1.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.6", - "sebastian/version": "^1.0.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\PathUtil\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "A robust cross-platform utility for normalizing, comparing and modifying file paths.", - "support": { - "issues": "https://github.com/webmozart/path-util/issues", - "source": "https://github.com/webmozart/path-util/tree/2.3.0" + "source": "https://github.com/vimeo/psalm/tree/5.11.0" }, - "abandoned": "symfony/filesystem", - "time": "2015-12-17T08:42:14+00:00" + "time": "2023-05-04T21:35:44+00:00" } ], "aliases": [], @@ -12017,7 +12026,8 @@ "ext-mbstring": "*", "ext-openssl": "*", "ext-pdo": "*", - "ext-sockets": "*" + "ext-sockets": "*", + "ext-redis": "*" }, "platform-dev": [], "plugin-api-version": "2.3.0" diff --git a/psalm.xml b/psalm.xml index 69a14c3e..0f18e475 100644 --- a/psalm.xml +++ b/psalm.xml @@ -5,7 +5,16 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" + findUnusedCode="false" + findUnusedBaselineEntry="true" > + + + + + + + diff --git a/tests/Feature/Controller/Auth/LoginControllerTest.php b/tests/Feature/Controller/Auth/LoginControllerTest.php index 118bd421..99d310bc 100644 --- a/tests/Feature/Controller/Auth/LoginControllerTest.php +++ b/tests/Feature/Controller/Auth/LoginControllerTest.php @@ -12,9 +12,6 @@ class LoginControllerTest extends TestCase implements DatabaseTransaction { - /** - * @var \Tests\Factories\UserFactory - */ protected UserFactory $userFactory; protected function setUp(): void diff --git a/tests/Feature/Middleware/RateLimitMiddlewareTest.php b/tests/Feature/Middleware/RateLimitMiddlewareTest.php new file mode 100644 index 00000000..286c624f --- /dev/null +++ b/tests/Feature/Middleware/RateLimitMiddlewareTest.php @@ -0,0 +1,82 @@ + 'localhost', + ]; + + public function setUp(): void + { + $this->beforeBooting(static function (ConfiguratorInterface $config): void { + $config->modify('redis', new Set('prefix', 'CT:testing:')); + }); + + parent::setUp(); + } + + public function tearDown(): void + { + /** @var \Redis $redis */ + $redis = $this->getContainer()->get(\Redis::class); + $redis->del($redis->keys('CT:testing:*')); + + parent::tearDown(); + } + + public function testHandleGuest(): void + { + $ip = long2ip(Fixtures::integer()); + $ip2 = long2ip(Fixtures::integer()); + $ip3 = long2ip(Fixtures::integer()); + + $rateLimit = $this->getContainer()->get(RateLimitInterface::class); + + $ruleFactory = $this->getMockBuilder(RuleFactory::class)->getMock(); + $ruleFactory->method('getRule')->with('123', $ip)->willReturn($rule = new GuestRule(5)); + + $middleware = new RateLimitMiddleware($rateLimit, $ruleFactory); + + $request = $this->getMockBuilder(ServerRequestInterface::class)->getMock(); + $request->method('getHeaderLine')->with('X-Internal-UserId')->willReturn('123'); + $request->method('getHeader')->willReturnMap([ + ['Cf-Original-Connecting-IP', [$ip]], + ['X-Real-IP', [$ip2]], + ['X-Forwarded-For', [$ip3]], + ]); + + $handler = $this->getMockBuilder(RequestHandlerInterface::class)->getMock(); + $handler->method('handle')->willReturn(new JsonResponse([], 201)); + + for ($i = $rule->limit() - 1; $i !== 0; $i--) { + $response = $middleware->process($request, $handler); + + $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals((string) $rule->limit(), $response->getHeaderLine('X-RateLimit-Limit')); + $this->assertEquals((string) $i, $response->getHeaderLine('X-RateLimit-Remaining')); + } + + $response = $middleware->process($request, $handler); + + $this->assertEquals(429, $response->getStatusCode()); + $this->assertEquals((string) $rule->limit(), $response->getHeaderLine('X-RateLimit-Limit')); + $this->assertEquals('0', $response->getHeaderLine('X-RateLimit-Remaining')); + $this->assertEquals($rule->ttl(), $response->getHeaderLine('Retry-After')); + } +} diff --git a/tests/Feature/Service/RateLimit/RateLimitHitTest.php b/tests/Feature/Service/RateLimit/RateLimitHitTest.php new file mode 100644 index 00000000..a94824c0 --- /dev/null +++ b/tests/Feature/Service/RateLimit/RateLimitHitTest.php @@ -0,0 +1,19 @@ +assertEquals($rule, $hit->getRule()); + } +} diff --git a/tests/Feature/Service/RateLimit/RedisRateLimitTest.php b/tests/Feature/Service/RateLimit/RedisRateLimitTest.php new file mode 100644 index 00000000..6eeb59a0 --- /dev/null +++ b/tests/Feature/Service/RateLimit/RedisRateLimitTest.php @@ -0,0 +1,57 @@ +getMockBuilder(\Redis::class)->onlyMethods(['isConnected', 'incr', 'getLastError'])->getMock(); + $redis->method('isConnected')->willReturn(true); + $redis->method('incr')->willReturn(false); + $redis->method('getLastError')->willReturn('unknown error'); + + $rateLimit = new RedisRateLimit($redis); + + $this->expectException(\RuntimeException::class); + + $rateLimit->hit(new GuestRule()); + } + + public function testTtlException() + { + $redis = $this->getMockBuilder(\Redis::class)->onlyMethods(['isConnected', 'incr', 'getLastError', 'ttl'])->getMock(); + $redis->method('isConnected')->willReturn(true); + $redis->method('incr')->willReturn(1); + $redis->method('getLastError')->willReturn('unknown error'); + $redis->method('ttl')->willReturn('unknown error'); + + $rateLimit = new RedisRateLimit($redis); + + $this->expectException(\RuntimeException::class); + + $rateLimit->hit(new GuestRule()); + } + + public function testExpireException() + { + $redis = $this->getMockBuilder(\Redis::class)->onlyMethods(['isConnected', 'incr', 'getLastError', 'ttl', 'expire'])->getMock(); + $redis->method('isConnected')->willReturn(true); + $redis->method('incr')->willReturn(1); + $redis->method('getLastError')->willReturn('unknown error'); + $redis->method('ttl')->willReturn(-1); + $redis->method('expire')->willReturn(false); + + $rateLimit = new RedisRateLimit($redis); + + $this->expectException(\RuntimeException::class); + + $rateLimit->hit(new GuestRule()); + } +} diff --git a/tests/Feature/Service/RateLimit/RuleTest.php b/tests/Feature/Service/RateLimit/RuleTest.php new file mode 100644 index 00000000..d1bccf8a --- /dev/null +++ b/tests/Feature/Service/RateLimit/RuleTest.php @@ -0,0 +1,34 @@ +assertEquals('', (new Rule())->key()); + } + + public function testWithLimit() + { + $rule = new Rule(limit: 100); + $other = $rule->withLimit(101); + + $this->assertNotEquals($rule, $other); + $this->assertNotEquals($rule->limit(), $other->limit()); + } + + public function testWithTtl() + { + $rule = new Rule(ttl: 100); + $other = $rule->withTtl(101); + + $this->assertNotEquals($rule, $other); + $this->assertNotEquals($rule->ttl(), $other->ttl()); + } +} diff --git a/tests/Feature/Service/RateLimit/UserRuleTest.php b/tests/Feature/Service/RateLimit/UserRuleTest.php new file mode 100644 index 00000000..12fe164e --- /dev/null +++ b/tests/Feature/Service/RateLimit/UserRuleTest.php @@ -0,0 +1,18 @@ +assertEquals('user:', (new UserRule())->key()); + $this->assertEquals('user:123', (new UserRule())->with('123')->key()); + $this->assertEquals('user:123-1.1.1.1', (new UserRule())->with('123', '1.1.1.1')->key()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index f1fa67df..5a873870 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,9 +4,7 @@ namespace Tests; -use Aws\S3\S3ClientInterface; use Cycle\Database\DatabaseInterface; -use Spiral\Boot\FinalizerInterface; use Spiral\Config\ConfiguratorInterface; use Spiral\Config\Patch\Set; use Spiral\Core\Container; @@ -28,6 +26,10 @@ abstract class TestCase extends BaseTestCase use InteractsWithMock; use AssertHelpers; + public const ENV = [ + 'REDIS_CONNECTION' => '', + ]; + protected function setUp(): void { $this->beforeBooting(static function (ConfiguratorInterface $config): void { @@ -83,7 +85,6 @@ protected function tearDown(): void parent::tearDown(); $container = $this->getContainer(); - unset($this->app); if ($container instanceof Container) { $container->destruct();