10000 feature #58769 [ErrorHandler] Add a command to dump static error page… · symfony/symfony@9d84727 · GitHub
[go: up one dir, main page]

Skip to content

Commit 9d84727

Browse files
committed
feature #58769 [ErrorHandler] Add a command to dump static error pages (pyrech)
This PR was squashed before being merged into the 7.3 branch. Discussion ---------- [ErrorHandler] Add a command to dump static error pages | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT <!-- Replace this notice by a description of your feature/bugfix. This will help reviewers and should be a good start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Bug fixes must be submitted against the lowest maintained branch where they apply (lowest branches are regularly merged to upper ones so they get the fixes too). - Features and deprecations must be submitted against the latest branch. - For new features, provide some code snippets to help understand usage. - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry - Never break backward compatibility (see https://symfony.com/bc). --> When a web server cannot handle a request or trigger an error without calling the PHP application, it will return instead its default error pages, ignoring completly the error templates defined by the application (Symfony's default "Oops" error page or overriden one in user's app). Take for example the case of the simple `/%` url : it will trigger an error on almost all web servers (nginx, apache, cloudflare, etc): - [https://symfony.com/%](https://symfony.com/%) - [https://api-platform.com/%](https://api-platform.com/%) - [https://www.cloudflare.com/%](https://www.cloudflare.com/%) - [https://www.clever-cloud.com/%](https://www.clever-cloud.com/%) In all these cases, web servers returned their default error page. To avoid that, in some of our projects, we created a Symfony command to dump the application error pages in static HTML files that the web server can render when it encounters an internal error. The idea is to dump these pages at deploy time so there is nothing else to do at runtime. Here is a sample on how we configured our nginx to use our beautiful error pages: ``` error_page 400 /error_pages/400.html; error_page 401 /error_pages/401.html; # ... error_page 510 /error_pages/510.html; error_page 511 /error_pages/511.html; location ^~ /error_pages/ { root /path/to/your/symfony/var/cache/error_pages; internal; # allows this location block to not be triggered when a user manually call these /error_pages/.* urls } ``` (Kudos to `@xavierlacot` for all the hard work and researches on this topic 💛) We propose to add this command directly to Symfony so everybody can make use of it. ### Usage ```bash bin/console error:dump var/cache/prod/error_page [--force] [400 401 ... 511] ``` Commits ------- 37fe14b [ErrorHandler] Add a command to dump static error pages
2 parents 8b9ed36 + 37fe14b commit 9d84727

File tree

7 files changed

+219
-2
lines changed

7 files changed

+219
-2
lines changed

composer.json

+1
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157
"symfony/phpunit-bridge": "^6.4|^7.0",
158158
"symfony/runtime": "self.version",
159159
"symfony/security-acl": "~2.8|~3.0",
160+
"symfony/webpack-encore-bundle": "^1.0|^2.0",
160161
"twig/cssinliner-extra": "^2.12|^3",
161162
"twig/inky-extra": "^2.12|^3",
162163
"twig/markdown-extra": "^2.12|^3",

src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php

+10
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
use Symfony\Component\Console\EventListener\ErrorListener;
4545
use Symfony\Component\Console\Messenger\RunCommandMessageHandler;
4646
use Symfony\Component\Dotenv\Command\DebugCommand as DotenvDebugCommand;
47+
use Symfony\Component\ErrorHandler\Command\ErrorDumpCommand;
4748
use Symfony\Component\Messenger\Command\ConsumeMessagesCommand;
4849
use Symfony\Component\Messenger\Command\DebugCommand as MessengerDebugCommand;
4950
use Symfony\Component\Messenger\Command\FailedMessagesRemoveCommand;
@@ -59,6 +60,7 @@
5960
use Symfony\Component\Translation\Command\TranslationPushCommand;
6061
use Symfony\Component\Translation\Command\XliffLintCommand;
6162
use Symfony\Component\Validator\Command\DebugCommand as ValidatorDebugCommand;
63+
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
6264

6365
return static function (ContainerConfigurator $container) {
6466
$container->services()
@@ -385,6 +387,14 @@
385387
])
386388
->tag('console.command')
387389

390+
->set('console.command.error_dumper', ErrorDumpCommand::class)
391+
->args([
392+
service('filesystem'),
393+
service('error_renderer.html'),
394+
service(EntrypointLookupInterface::class)->nullOnInvalid(),
395+
])
396+
->tag('console.command')
397+
388398
->set('console.messenger.application', Application::class)
389399
->share(false)
390400
->call('setAutoExit', [false])

src/Symfony/Bundle/FrameworkBundle/composer.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"symfony/config": "^7.3",
2424
"symfony/dependency-injection": "^7. 2364 2",
2525
"symfony/deprecation-contracts": "^2.5|^3",
26-
"symfony/error-handler": "^6.4|^7.0",
26+
"symfony/error-handler": "^7.3",
2727
"symfony/event-dispatcher": "^6.4|^7.0",
2828
"symfony/http-foundation": "^7.3",
2929
"symfony/http-kernel": "^7.2",

src/Symfony/Component/ErrorHandler/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add `error:dump` command
8+
49
7.1
510
---
611

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\ErrorHandler\Command;
13+
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputArgument;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Input\InputOption;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface;
22+
use Symfony\Component\Filesystem\Filesystem;
23+
use Symfony\Component\HttpFoundation\Response;
24+
use Symfony\Component\HttpKernel\Exception\HttpException;
25+
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
26+
27+
/**
28+
* Dump error pages to plain HTML files that can be directly served by a web server.
29+
*
30+
* @author Loïck Piera <pyrech@gmail.com>
31+
*/
32+
#[AsCommand(
33+
name: 'error:dump',
34+
description: 'Dump error pages to plain HTML files that can be directly served by a web server',
35+
)]
36+
final class ErrorDumpCommand extends Command
37+
{
38+
public function __construct(
39+
private readonly Filesystem $filesystem,
40+
private readonly ErrorRendererInterface $errorRenderer,
41+
private readonly ?EntrypointLookupInterface $entrypointLookup = null,
42+
) {
43+
parent::__construct();
44+
}
45+
46+
protected function configure(): void
47+
{
48+
$this
49+
->addArgument('path', InputArgument::REQUIRED, 'Path where to dump the error pages in')
50+
->addArgument('status-codes', InputArgument::IS_ARRAY, 'Status codes to dump error pages for, all of them by default')
51+
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force directory removal before dumping new error pages')
52+
;
53+
}
54+
55+
protected function execute(InputInterface $input, OutputInterface $output): int
56+
{
57+
$path = $input->getArgument('path');
58+
59+
$io = new SymfonyStyle($input, $output);
60+
$io->title('Dumping error pages');
61+
62+
$this->dump($io, $path, $input->getArgument('status-codes'), (bool) $input->getOption('force'));
63+
$io->success(\sprintf('Error pages have been dumped in "%s".', $path));
64+
65+
return Command::SUCCESS;
66+
}
67+
68+
private function dump(SymfonyStyle $io, string $path, array $statusCodes, bool $force = false): void
69+
{
70+
if (!$statusCodes) {
71+
$statusCodes = array_filter(array_keys(Response::$statusTexts), fn ($statusCode) => $statusCode >= 400);
72+
}
73+
74+
if ($force || ($this->filesystem->exists($path) && $io->confirm(\sprintf('The "%s" directory already exists. Do you want to remove it before dumping the error pages?', $path), false))) {
75+
$this->filesystem->remove($path);
76+
}
77+
78+
foreach ($statusCodes as $statusCode) {
79+
// Avoid assets to be included only on the first dumped page
80+
$this->entrypointLookup?->reset();
81+
82+
$this->filesystem->dumpFile($path.\DIRECTORY_SEPARATOR.$statusCode.'.html', $this->errorRenderer->render(new HttpException((int) $statusCode))->getAsString());
83+
}
84+
}
85+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\ErrorHandler\Tests\Command;
13+
14+
use PHPUnit\Framework\MockObject\MockObject;
15+
use Symfony\Bundle\FrameworkBundle\Console\Application;
16+
use Symfony\Bundle\TwigBundle\Tests\TestCase;
17+
use Symfony\Component\Console\Tester\CommandTester;
18+
use Symfony\Component\DependencyInjection\Container;
19+
use Symfony\Component\ErrorHandler\Command\ErrorDumpCommand;
20+
use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface;
21+
use Symfony\Component\ErrorHandler\Exception\FlattenException;
22+
use Symfony\Component\Filesystem\Filesystem;
23+
use Symfony\Component\HttpKernel\Exception\HttpException;
24+
use Symfony\Component\HttpKernel\KernelInterface;
25+
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
26+
27+
class ErrorDumpCommandTest extends TestCase
28+
{
29+
private string $tmpDir = '';
30+
31+
protected function setUp(): void
32+
{
33+
$this->tmpDir = sys_get_temp_dir().'/error_pages';
34+
35+
$fs = new Filesystem();
36+
$fs->remove($this->tmpDir);
37+
}
38+
39+
public function testDumpPages()
40+
{
41+
$tester = $this->getCommandTester($this->getKernel(), []);
42+
$tester->execute([
43+
'path' => $this->tmpDir,
44+
]);
45+
46+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html');
47+
$this->assertStringContainsString('Error 404', file_get_contents($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html'));
48+
}
49+
50+
public function testDumpPagesOnlyForGivenStatusCodes()
51+
{
52+
$fs = new Filesystem();
53+
$fs->mkdir($this->tmpDir);
54+
$fs->touch($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
55+
56+
$tester = $this->getCommandTester($this->getKernel());
57+
$tester->execute([
58+
'path' => $this->tmpDir,
59+
'status-codes' => ['400', '500'],
60+
]);
61+
62+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
63+
$this->assertFileDoesNotExist($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html');
64+
65+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'400.html');
66+
$this->assertStringContainsString('Error 400', file_get_contents($this->tmpDir.\DIRECTORY_SEPARATOR.'400.html'));
67+
}
68+
69+
public function testForceRemovalPages()
70+
{
71+
$fs = new Filesystem();
72+
$fs->mkdir($this->tmpDir);
73+
$fs->touch($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
74+
75+
$tester = $this->getCommandTester($this->getKernel());
76+
$tester->execute([
77+
'path' => $this->tmpDir,
78+
'--force' => true,
79+
]);
80+
81+
$this->assertFileDoesNotExist($this->tmpDir.\DIRECTORY_SEPARATOR.'test.html');
82+
$this->assertFileExists($this->tmpDir.\DIRECTORY_SEPARATOR.'404.html');
83+
}
84+
85+
private function getKernel(): MockObject&KernelInterface
86+
{
87+
return $this->createMock(KernelInterface::class);
88+
}
89+
90+
private function getCommandTester(KernelInterface $kernel): CommandTester
91+
{
92+
$errorRenderer = $this->createStub(ErrorRendererInterface::class);
93+
$errorRenderer
94+
->method('render')
95+
->willReturnCallback(function (HttpException $e) {
96+
$exception = FlattenException::createFromThrowable($e);
97+
$exception->setAsString(\sprintf('<html><body>Error %s</body></html>', $e->getStatusCode()));
98+
99+
return $exception;
100+
})
101+
;
102+
103+
$entrypointLookup = $this->createMock(EntrypointLookupInterface::class);
104+
105+
$application = new Application($kernel);
106+
$application->add(new ErrorDumpCommand(
107+
new Filesystem(),
108+
$errorRenderer,
109+
$entrypointLookup,
110+
));
111+
112+
return new CommandTester($application->find('error:dump'));
113+
}
114+
}

src/Symfony/Component/ErrorHandler/composer.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
"symfony/var-dumper": "^6.4|^7.0"
2222
},
2323
"require-dev": {
24+
"symfony/console": "^6.4|^7.0",
2425
"symfony/http-kernel": "^6.4|^7.0",
2526
"symfony/serializer": "^6.4|^7.0",
26-
"symfony/deprecation-contracts": "^2.5|^3"
27+
"symfony/deprecation-contracts": "^2.5|^3",
28+
"symfony/webpack-encore-bundle": "^1.0|^2.0"
2729
},
2830
"conflict": {
2931
"symfony/deprecation-contracts": "<2.5",

0 commit comments

Comments
 (0)
0