From 9f110f8d03005373ad2466df7ca644fc6b07a869 Mon Sep 17 00:00:00 2001 From: Florent Morselli <florent.morselli@spomky-labs.com> Date: Sun, 7 Apr 2024 21:40:41 +0200 Subject: [PATCH] Add support for custom user agent in screenshot generation (#166) * Add support for custom user agent in screenshot generation This update includes a new service, `ScreenshotSubscriber`, that disables profiler if the user agent matches a specified one. Also, a custom user agent can now be used when using the `CreateScreenshotCommand`, and this setting is configurable in `web_client.php`. Finally, some PHPStan warnings were addressed and the "ext-sockets" package was added to the dev dependencies. --- composer.json | 1 + phpstan-baseline.neon | 15 ++++ src/Command/CreateScreenshotCommand.php | 72 ++++++++++++++++--- src/EventSubscriber/ScreenshotSubscriber.php | 47 ++++++++++++ .../config/definition/web_client.php | 6 ++ src/Resources/config/services.php | 2 + src/SpomkyLabsPwaBundle.php | 2 + 7 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 src/EventSubscriber/ScreenshotSubscriber.php diff --git a/composer.json b/composer.json index bdab78c..03d67db 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "twig/twig": "^3.8" }, "require-dev": { + "ext-sockets": "*", "dbrekelmans/bdi": "^1.1", "infection/infection": "^0.28", "phpstan/extension-installer": "^1.1", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f78b5f7..b640631 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -107,9 +107,24 @@ parameters: - message: "#^Cannot cast mixed to string\\.$#" + count: 2 + path: src/Command/CreateScreenshotCommand.php + + - + message: "#^Method SpomkyLabs\\\\PwaBundle\\\\Command\\\\CreateScreenshotCommand\\:\\:getDefaultArguments\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Command/CreateScreenshotCommand.php + + - + message: "#^Only booleans are allowed in a negated boolean, mixed given\\.$#" count: 1 path: src/Command/CreateScreenshotCommand.php + - + message: "#^Only booleans are allowed in an if condition, mixed given\\.$#" + count: 3 + path: src/Command/CreateScreenshotCommand.php + - message: "#^Parameter \\#1 \\$image of method SpomkyLabs\\\\PwaBundle\\\\ImageProcessor\\\\ImageProcessorInterface\\:\\:getSizes\\(\\) expects string, string\\|false given\\.$#" count: 1 diff --git a/src/Command/CreateScreenshotCommand.php b/src/Command/CreateScreenshotCommand.php index 6a14df5..7d5bdab 100644 --- a/src/Command/CreateScreenshotCommand.php +++ b/src/Command/CreateScreenshotCommand.php @@ -20,6 +20,7 @@ use Symfony\Component\Panther\Client; use Symfony\Component\Yaml\Yaml; use Throwable; +use function assert; use function count; #[AsCommand( @@ -28,8 +29,6 @@ )] final class CreateScreenshotCommand extends Command { - private readonly Client $webClient; - public function __construct( private readonly AssetMapperInterface $assetMapper, private readonly Filesystem $filesystem, @@ -37,13 +36,11 @@ public function __construct( private readonly string $projectDir, private readonly null|ImageProcessorInterface $imageProcessor, #[Autowire('@pwa.web_client')] - null|Client $webClient = null, + private readonly null|Client $webClient = null, + #[Autowire(param: 'spomky_labs_pwa.screenshot_user_agent')] + private readonly null|string $userAgent = null, ) { parent::__construct(); - if ($webClient === null) { - $webClient = Client::createChromeClient(); - } - $this->webClient = $webClient; } public function isEnabled(): bool @@ -95,12 +92,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $width = $input->getOption('width'); $format = $input->getOption('format'); - $client = clone $this->webClient; + $client = $this->getClient(); $crawler = $client->request('GET', $url); $tmpName = $this->filesystem ->tempnam('', 'pwa-'); if ($width !== null && $height !== null) { + if ($width < 0 || $height < 0) { + $io->error('Width and height must be positive integers.'); + return self::FAILURE; + } $client->manage() ->window() ->setSize(new WebDriverDimension((int) $width, (int) $height)); @@ -168,4 +169,59 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } + + private function getAvailablePort(): int + { + $socket = socket_create_listen(0); + assert($socket !== false, 'Unable to create a socket.'); + socket_getsockname($socket, $address, $port); + socket_close($socket); + + return $port; + } + + private function getDefaultArguments(): array + { + $args = []; + + if (! ($_SERVER['PANTHER_NO_HEADLESS'] ?? false)) { + $args[] = '--headless'; + $args[] = '--window-size=1200,1100'; + $args[] = '--disable-gpu'; + } + + if ($_SERVER['PANTHER_DEVTOOLS'] ?? true) { + $args[] = '--auto-open-devtools-for-tabs'; + } + + if ($_SERVER['PANTHER_NO_SANDBOX'] ?? $_SERVER['HAS_JOSH_K_SEAL_OF_APPROVAL'] ?? false) { + $args[] = '--no-sandbox'; + } + + if ($_SERVER['PANTHER_CHROME_ARGUMENTS'] ?? false) { + $arguments = explode(' ', (string) $_SERVER['PANTHER_CHROME_ARGUMENTS']); + $args = array_merge($args, $arguments); + } + + return $args; + } + + private function getClient(): Client + { + if ($this->webClient !== null) { + return clone $this->webClient; + } + $options = [ + 'port' => $this->getAvailablePort(), + 'capabilities' => [ + 'acceptInsecureCerts' => true, + ], + ]; + $arguments = $this->getDefaultArguments(); + if ($this->userAgent !== null) { + $arguments[] = sprintf('--user-agent=%s', $this->userAgent); + } + + return Client::createChromeClient(arguments: $arguments, options: $options); + } } diff --git a/src/EventSubscriber/ScreenshotSubscriber.php b/src/EventSubscriber/ScreenshotSubscriber.php new file mode 100644 index 0000000..c164f08 --- /dev/null +++ b/src/EventSubscriber/ScreenshotSubscriber.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace SpomkyLabs\PwaBundle\EventSubscriber; + +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Profiler\Profiler; + +final readonly class ScreenshotSubscriber implements EventSubscriberInterface +{ + public function __construct( + #[Autowire(service: 'profiler')] + private ?Profiler $profiler = null, + #[Autowire(param: 'spomky_labs_pwa.screenshot_user_agent')] + private null|string $userAgent = null, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + RequestEvent::class => 'onRequest', + ]; + } + + public function onRequest(RequestEvent $event): void + { + if (! $event->isMainRequest() || $this->profiler === null) { + return; + } + + $userAgent = $event->getRequest() + ->headers->get('user-agent'); + if ($userAgent === null) { + return; + } + $userAgentToFind = $this->userAgent ?? 'HeadlessChrome'; + if (! str_contains($userAgent, $userAgentToFind)) { + return; + } + + $this->profiler->disable(); + } +} diff --git a/src/Resources/config/definition/web_client.php b/src/Resources/config/definition/web_client.php index fae787b..f1673d1 100644 --- a/src/Resources/config/definition/web_client.php +++ b/src/Resources/config/definition/web_client.php @@ -11,5 +11,11 @@ ->defaultNull() ->info('The Panther Client for generating screenshots. If not set, the default client will be used.') ->end() + ->scalarNode('user_agent') + ->defaultNull() + ->info( + 'The user agent to use when generating screenshots. If not set, the default user agent will be used. When requesting the current application in an environment other than "prod", the profiler will be disabled.' + ) + ->end() ->end(); }; diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index fb2a613..f11b9ea 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -12,6 +12,7 @@ use SpomkyLabs\PwaBundle\DataCollector\PwaCollector; use SpomkyLabs\PwaBundle\Dto\Manifest; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; +use SpomkyLabs\PwaBundle\EventSubscriber\ScreenshotSubscriber; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use SpomkyLabs\PwaBundle\MatchCallbackHandler\MatchCallbackHandlerInterface; @@ -142,6 +143,7 @@ '$urls' => abstract_arg('urls'), ]) ; + $container->set(ScreenshotSubscriber::class); if ($configurator->env() !== 'prod') { $container->set(PwaCollector::class) diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php index 77e87f4..6dd5843 100644 --- a/src/SpomkyLabsPwaBundle.php +++ b/src/SpomkyLabsPwaBundle.php @@ -37,6 +37,8 @@ public function loadExtension(array $config, ContainerConfigurator $container, C if ($config['web_client'] !== null) { $builder->setAlias('pwa.web_client', $config['web_client']); } + $builder->setParameter('spomky_labs_pwa.screenshot_user_agent', $config['user_agent']); + $serviceWorkerConfig = $config['serviceworker']; $manifestConfig = $config['manifest']; if ($serviceWorkerConfig['enabled'] === true && $manifestConfig['enabled'] === true) {