Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Controller/DataProviderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use SwagMigrationAssistant\DataProvider\Provider\ProviderRegistryInterface;
use SwagMigrationAssistant\DataProvider\Service\EnvironmentServiceInterface;
use SwagMigrationAssistant\Exception\MigrationException;
use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
Expand All @@ -50,6 +51,7 @@ public function __construct(
private readonly EntityRepository $mediaRepository,
private readonly MediaService $mediaService,
private readonly FilesystemOperator $privateFilesystem,
private readonly MigrationApiRateLimiter $rateLimiter,
) {
}

Expand Down Expand Up @@ -134,6 +136,8 @@ public function getTable(Request $request, Context $context): JsonResponse
)]
public function generateDocument(Request $request, Context $context): JsonResponse
{
$this->rateLimiter->ensureDownloadAccepted($request);

$identifier = (string) $request->query->get('identifier');

if ($identifier === '') {
Expand Down Expand Up @@ -161,6 +165,8 @@ public function generateDocument(Request $request, Context $context): JsonRespon
)]
public function downloadPrivateFile(Request $request, Context $context): StreamedResponse|RedirectResponse
{
$this->rateLimiter->ensureDownloadAccepted($request);

$identifier = (string) $request->query->get('identifier');

if ($identifier === '') {
Expand Down
12 changes: 12 additions & 0 deletions src/Controller/HistoryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use SwagMigrationAssistant\Exception\MigrationException;
use SwagMigrationAssistant\Migration\History\HistoryServiceInterface;
use SwagMigrationAssistant\Migration\History\LogGroupingService;
use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
Expand All @@ -34,6 +35,7 @@ public function __construct(
private readonly HistoryServiceInterface $historyService,
private readonly LogGroupingService $logGroupingService,
private readonly int $maxLimit,
private readonly MigrationApiRateLimiter $rateLimiter,
) {
}

Expand All @@ -45,6 +47,8 @@ public function __construct(
)]
public function getGroupedLogsOfRun(Request $request, Context $context): JsonResponse
{
$this->rateLimiter->ensureLogAccessAccepted($request);

$runUuid = $request->query->getAlnum('runUuid');

if ($runUuid === '') {
Expand Down Expand Up @@ -75,6 +79,8 @@ public function getGroupedLogsOfRun(Request $request, Context $context): JsonRes
)]
public function downloadLogsOfRun(Request $request, Context $context): StreamedResponse
{
$this->rateLimiter->ensureDownloadAccepted($request);

$runUuid = $request->request->getAlnum('runUuid');

if ($runUuid === '') {
Expand Down Expand Up @@ -106,6 +112,8 @@ public function downloadLogsOfRun(Request $request, Context $context): StreamedR
)]
public function getLogGroups(Request $request, Context $context): JsonResponse
{
$this->rateLimiter->ensureLogAccessAccepted($request);

$runId = $request->query->getAlnum('runId');

if ($runId === '') {
Expand Down Expand Up @@ -157,6 +165,8 @@ public function getLogGroups(Request $request, Context $context): JsonResponse
)]
public function getUnresolvedLogsBatchInformation(Request $request): JsonResponse
{
$this->rateLimiter->ensureLogAccessAccepted($request);

$runId = $request->request->getAlnum('runId');

if ($runId === '') {
Expand Down Expand Up @@ -205,6 +215,8 @@ public function getUnresolvedLogsBatchInformation(Request $request): JsonRespons
)]
public function getLogEntityIdsWithoutFix(Request $request): JsonResponse
{
$this->rateLimiter->ensureLogAccessAccepted($request);

$runId = $request->request->getAlnum('runId');

if ($runId === '') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php declare(strict_types=1);
/*
* (c) shopware AG <info@shopware.com>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace SwagMigrationAssistant\DependencyInjection\CompilerPass;

use Psr\Clock\ClockInterface;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\RateLimiter\RateLimiter;
use Shopware\Core\Framework\RateLimiter\RateLimiterFactory;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\RateLimiter\Storage\CacheStorage;

#[Package('fundamentals@after-sales')]
class MigrationRateLimiterCompilerPass implements CompilerPassInterface
{
/**
* @var array<string, array{enabled: bool, policy: string, limit: int, interval: string}>
*/
private const RATE_LIMITERS = [
MigrationApiRateLimiter::LOG_ACCESS => [
'enabled' => true,
'policy' => 'sliding_window',
'limit' => 30,
'interval' => '60 seconds',
],
MigrationApiRateLimiter::DOWNLOAD => [
'enabled' => true,
'policy' => 'sliding_window',
'limit' => 10,
'interval' => '60 seconds',
],
];

public function process(ContainerBuilder $container): void
{
$rateLimiter = $container->getDefinition(RateLimiter::class);

foreach (self::RATE_LIMITERS as $name => $config) {
$limiterFactory = new Definition(RateLimiterFactory::class);
$limiterFactory->addArgument($config + ['id' => $name]);

$cacheStorage = new Definition(CacheStorage::class);
$cacheStorage->addArgument(new Reference('cache.rate_limiter'));

$limiterFactory->addArgument($cacheStorage);
$limiterFactory->addArgument(new Reference(SystemConfigService::class));
$limiterFactory->addArgument(new Reference(ClockInterface::class));
$limiterFactory->addArgument(new Reference('lock.factory'));

$rateLimiter->addMethodCall('registerLimiterFactory', [$name, $limiterFactory]);
}
}
}
2 changes: 2 additions & 0 deletions src/DependencyInjection/dataProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
use SwagMigrationAssistant\DataProvider\Provider\Data\UserProvider;
use SwagMigrationAssistant\DataProvider\Provider\ProviderRegistry;
use SwagMigrationAssistant\DataProvider\Service\EnvironmentService;
use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter;

return static function (ContainerConfigurator $container): void {
$services = $container->services();
Expand All @@ -85,6 +86,7 @@
service('media.repository'),
service(MediaService::class),
service('shopware.filesystem.private'),
service(MigrationApiRateLimiter::class),
])
->call('setContainer', [service('service_container')]);

Expand Down
6 changes: 6 additions & 0 deletions src/DependencyInjection/migration.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriter;
use Shopware\Core\Framework\RateLimiter\RateLimiter;
use Shopware\Core\Framework\Store\Services\TrackingEventClient;
use Shopware\Storefront\Theme\ThemeService;
use SwagMigrationAssistant\Controller\ErrorResolutionController;
Expand Down Expand Up @@ -93,6 +94,7 @@
use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService;
use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService;
use SwagMigrationAssistant\Migration\Writer\WriterRegistry;
use SwagMigrationAssistant\RateLimiter\MigrationApiRateLimiter;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

return static function (ContainerConfigurator $container): void {
Expand Down Expand Up @@ -359,6 +361,9 @@
service(MigrationFingerprintService::class),
]);

$services->set(MigrationApiRateLimiter::class)
->args([service(RateLimiter::class)]);

$services->set(StatusController::class)
->public()
->args([
Expand All @@ -380,6 +385,7 @@
service(HistoryService::class),
service(LogGroupingService::class),
'%shopware.api.max_limit%',
service(MigrationApiRateLimiter::class),
])
->call('setContainer', [service('service_container')]);

Expand Down
43 changes: 43 additions & 0 deletions src/RateLimiter/MigrationApiRateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php declare(strict_types=1);
/*
* (c) shopware AG <info@shopware.com>
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace SwagMigrationAssistant\RateLimiter;

use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\RateLimiter\RateLimiter;
use Shopware\Core\PlatformRequest;
use Symfony\Component\HttpFoundation\Request;

#[Package('fundamentals@after-sales')]
class MigrationApiRateLimiter
{
final public const LOG_ACCESS = 'swag_migration_log_access';

final public const DOWNLOAD = 'swag_migration_download';

public function __construct(
private readonly RateLimiter $rateLimiter,
) {
}

public function ensureLogAccessAccepted(Request $request): void
{
$this->rateLimiter->ensureAccepted(self::LOG_ACCESS, $this->resolveKey($request));
}

public function ensureDownloadAccepted(Request $request): void
{
$this->rateLimiter->ensureAccepted(self::DOWNLOAD, $this->resolveKey($request));
}
Comment on lines +27 to +35

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this per limit, one method a platform thing? I would rather go with a single method that takes request and limiter key


private function resolveKey(Request $request): string
{
return $request->attributes->getString(PlatformRequest::ATTRIBUTE_OAUTH_ACCESS_TOKEN_ID)
?: $request->getClientIp()
?: 'unknown';
}
}
3 changes: 3 additions & 0 deletions src/SwagMigrationAssistant.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Shopware\Core\Framework\Plugin\Context\InstallContext;
use Shopware\Core\Framework\Plugin\Context\UninstallContext;
use Shopware\Core\Framework\Uuid\Uuid;
use SwagMigrationAssistant\DependencyInjection\CompilerPass\MigrationRateLimiterCompilerPass;
use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionDefinition;
use SwagMigrationAssistant\Migration\Data\SwagMigrationDataDefinition;
use SwagMigrationAssistant\Migration\ErrorResolution\Entity\SwagMigrationFixDefinition;
Expand Down Expand Up @@ -65,6 +66,8 @@ public function build(ContainerBuilder $container): void
$phpLoader->load('profile.php');
$phpLoader->load('subscriber.php');
$phpLoader->load('writer.php');

$container->addCompilerPass(new MigrationRateLimiterCompilerPass());
}

public function rebuildContainer(): bool
Expand Down
46 changes: 46 additions & 0 deletions tests/Migration/Controller/HistoryControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\RateLimiter\Exception\RateLimitExceededException;
use Shopware\Core\Framework\Routing\RoutingException;
use Shopware\Core\Framework\Test\RateLimiter\DisableRateLimiterCompilerPass;
use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\PlatformRequest;
use SwagMigrationAssistant\Controller\HistoryController;
use SwagMigrationAssistant\Exception\MigrationException;
use SwagMigrationAssistant\Migration\History\HistoryService;
Expand Down Expand Up @@ -56,8 +59,20 @@ class HistoryControllerTest extends TestCase

private string $connectionId;

public static function setUpBeforeClass(): void
{
DisableRateLimiterCompilerPass::disableNoLimit();
}

public static function tearDownAfterClass(): void
{
DisableRateLimiterCompilerPass::enableNoLimit();
}

protected function setUp(): void
{
static::getContainer()->get('cache.rate_limiter')->clear();

$this->context = Context::createDefaultContext();
$this->runUuid = Uuid::randomHex();
$this->historyService = static::getContainer()->get(HistoryService::class);
Expand Down Expand Up @@ -116,6 +131,11 @@ protected function setUp(): void
], $this->context);
}

protected function tearDown(): void
{
static::getContainer()->get('cache.rate_limiter')->clear();
}

public function testGetGroupedLogsOfRunWithoutUuid(): void
{
$request = new Request();
Expand Down Expand Up @@ -157,6 +177,32 @@ public function testDownloadLogsOfRun(): void
static::assertSame('text/plain', $response->headers->get('Content-type'));
}

public function testGetGroupedLogsOfRunIsRateLimited(): void
{
$request = new Request(['runUuid' => $this->runUuid]);
$request->attributes->set(PlatformRequest::ATTRIBUTE_OAUTH_ACCESS_TOKEN_ID, 'rate-limited-token');

for ($i = 0; $i < 30; ++$i) {
$this->controller->getGroupedLogsOfRun($request, $this->context);
}

$this->expectException(RateLimitExceededException::class);
$this->controller->getGroupedLogsOfRun($request, $this->context);
}

public function testDownloadLogsOfRunIsRateLimited(): void
{
$request = new Request([], ['runUuid' => $this->runUuid]);
$request->attributes->set(PlatformRequest::ATTRIBUTE_OAUTH_ACCESS_TOKEN_ID, 'rate-limited-download-token');

for ($i = 0; $i < 10; ++$i) {
$this->controller->downloadLogsOfRun($request, $this->context);
}

$this->expectException(RateLimitExceededException::class);
$this->controller->downloadLogsOfRun($request, $this->context);
}

public function testGetLogChunk(): void
{
$result = $this->invokeMethod($this->historyService, 'getLogChunk', [$this->runUuid, 0, $this->context]);
Expand Down
Loading
Loading