diff options
| -rw-r--r-- | app/Cli/Commands/SiteOffline.php | 23 | ||||
| -rw-r--r-- | app/Cli/Commands/SiteOnline.php | 22 | ||||
| -rw-r--r-- | app/Http/Middleware/CheckForMaintenanceMode.php | 18 | ||||
| -rw-r--r-- | app/Http/RequestHandlers/UpgradeWizardStep.php | 28 | ||||
| -rw-r--r-- | app/Services/MaintenanceModeService.php | 95 | ||||
| -rw-r--r-- | app/Services/UpgradeService.php | 20 | ||||
| -rw-r--r-- | app/Webtrees.php | 3 | ||||
| -rw-r--r-- | tests/app/Http/Middleware/CheckForMaintenanceModeTest.php | 25 | ||||
| -rw-r--r-- | tests/app/Http/RequestHandlers/UpgradeWizardStepTest.php | 16 | ||||
| -rw-r--r-- | tests/app/Services/MaintenanceModeServiceTest.php | 115 |
10 files changed, 288 insertions, 77 deletions
diff --git a/app/Cli/Commands/SiteOffline.php b/app/Cli/Commands/SiteOffline.php index be5d01e5bb..bea84691e2 100644 --- a/app/Cli/Commands/SiteOffline.php +++ b/app/Cli/Commands/SiteOffline.php @@ -19,18 +19,21 @@ declare(strict_types=1); namespace Fisharebest\Webtrees\Cli\Commands; -use Fisharebest\Webtrees\Webtrees; +use Fisharebest\Webtrees\Services\MaintenanceModeService; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; -use function file_exists; -use function file_put_contents; - final class SiteOffline extends AbstractCommand { + public function __construct( + private readonly MaintenanceModeService $maintenance_mode_service, + ) { + parent::__construct(); + } + protected function configure(): void { $this @@ -45,22 +48,18 @@ final class SiteOffline extends AbstractCommand $io = new SymfonyStyle(input: $input, output: $output); - $file_exists = file_exists(filename: Webtrees::OFFLINE_FILE); + $file = $this->maintenance_mode_service->file(); try { - file_put_contents(filename: Webtrees::OFFLINE_FILE, data: $message); + $this->maintenance_mode_service->offline($message); } catch (Throwable $ex) { - $io->error(message: 'Failed to write file ' . Webtrees::OFFLINE_FILE); + $io->error(message: 'Failed to write file ' . $file); $io->error(message: $ex->getMessage()); return self::FAILURE; } - if ($file_exists) { - $io->success(message: Webtrees::OFFLINE_FILE . ' updated. Site is offline.'); - } else { - $io->success(message: Webtrees::OFFLINE_FILE . ' created. Site is offline.'); - } + $io->success(message: $file . ' created. Site is offline.'); return self::SUCCESS; } diff --git a/app/Cli/Commands/SiteOnline.php b/app/Cli/Commands/SiteOnline.php index 36c357c6cb..77f4680def 100644 --- a/app/Cli/Commands/SiteOnline.php +++ b/app/Cli/Commands/SiteOnline.php @@ -19,16 +19,20 @@ declare(strict_types=1); namespace Fisharebest\Webtrees\Cli\Commands; -use Fisharebest\Webtrees\Webtrees; +use Fisharebest\Webtrees\Services\MaintenanceModeService; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; -use function file_exists; - final class SiteOnline extends AbstractCommand { + public function __construct( + private readonly MaintenanceModeService $maintenanceModeService, + ) { + parent::__construct(); + } + protected function configure(): void { $this @@ -40,17 +44,17 @@ final class SiteOnline extends AbstractCommand { $io = new SymfonyStyle(input: $input, output: $output); - $file_exists = file_exists(filename: Webtrees::OFFLINE_FILE); + $file = $this->maintenanceModeService->file(); - if (!$file_exists) { - $io->success(message: Webtrees::OFFLINE_FILE . ' does not exist. Site is already online.'); + if (!$this->maintenanceModeService->isOffline()) { + $io->success(message: $file . ' does not exist. Site is already online.'); } try { - unlink(filename: Webtrees::OFFLINE_FILE); - $io->success(message: Webtrees::OFFLINE_FILE . ' deleted. Site is online.'); + $this->maintenanceModeService->online(); + $io->success(message: $file . ' deleted. Site is online.'); } catch (Throwable $ex) { - $io->error(message: 'Unable to delete ' . Webtrees::OFFLINE_FILE . ' - ' . $ex->getMessage()); + $io->error(message: 'Unable to delete ' . $file . ' - ' . $ex->getMessage()); } return self::SUCCESS; diff --git a/app/Http/Middleware/CheckForMaintenanceMode.php b/app/Http/Middleware/CheckForMaintenanceMode.php index e93705979d..d12785a91f 100644 --- a/app/Http/Middleware/CheckForMaintenanceMode.php +++ b/app/Http/Middleware/CheckForMaintenanceMode.php @@ -20,6 +20,7 @@ declare(strict_types=1); namespace Fisharebest\Webtrees\Http\Middleware; use Fig\Http\Message\StatusCodeInterface; +use Fisharebest\Webtrees\Services\MaintenanceModeService; use Fisharebest\Webtrees\Webtrees; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -29,19 +30,18 @@ use Psr\Http\Server\RequestHandlerInterface; /** * Middleware to check whether the site is offline. */ -class CheckForMaintenanceMode implements MiddlewareInterface, StatusCodeInterface +readonly class CheckForMaintenanceMode implements MiddlewareInterface, StatusCodeInterface { - /** - * @param ServerRequestInterface $request - * @param RequestHandlerInterface $handler - * - * @return ResponseInterface - */ + public function __construct( + private MaintenanceModeService $maintenance_mode_service, + ) { + } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - if (file_exists(Webtrees::OFFLINE_FILE) && !is_dir(Webtrees::OFFLINE_FILE)) { + if ($this->maintenance_mode_service->isOffline()) { $html = view('layouts/offline', [ - 'message' => file_get_contents(Webtrees::OFFLINE_FILE), + 'message' => $this->maintenance_mode_service->message(), 'url' => (string) $request->getUri(), ]); diff --git a/app/Http/RequestHandlers/UpgradeWizardStep.php b/app/Http/RequestHandlers/UpgradeWizardStep.php index 922f18b80f..2c6142806b 100644 --- a/app/Http/RequestHandlers/UpgradeWizardStep.php +++ b/app/Http/RequestHandlers/UpgradeWizardStep.php @@ -25,6 +25,7 @@ use Fisharebest\Webtrees\Http\Exceptions\HttpServerErrorException; use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Registry; use Fisharebest\Webtrees\Services\GedcomExportService; +use Fisharebest\Webtrees\Services\MaintenanceModeService; use Fisharebest\Webtrees\Services\TreeService; use Fisharebest\Webtrees\Services\UpgradeService; use Fisharebest\Webtrees\Tree; @@ -49,7 +50,7 @@ use function view; /** * Upgrade to a new version of webtrees. */ -class UpgradeWizardStep implements RequestHandlerInterface +readonly class UpgradeWizardStep implements RequestHandlerInterface { // We make the upgrade in a number of small steps to keep within server time limits. private const string STEP_CHECK = 'Check'; @@ -76,25 +77,12 @@ class UpgradeWizardStep implements RequestHandlerInterface 'vendor', ]; - private GedcomExportService $gedcom_export_service; - - private UpgradeService $upgrade_service; - - private TreeService $tree_service; - - /** - * @param GedcomExportService $gedcom_export_service - * @param TreeService $tree_service - * @param UpgradeService $upgrade_service - */ public function __construct( - GedcomExportService $gedcom_export_service, - TreeService $tree_service, - UpgradeService $upgrade_service + private GedcomExportService $gedcom_export_service, + private MaintenanceModeService $maintenance_mode_service, + private TreeService $tree_service, + private UpgradeService $upgrade_service, ) { - $this->gedcom_export_service = $gedcom_export_service; - $this->tree_service = $tree_service; - $this->upgrade_service = $upgrade_service; } /** @@ -276,9 +264,9 @@ class UpgradeWizardStep implements RequestHandlerInterface $source_filesystem = Registry::filesystem()->root(self::UPGRADE_FOLDER . self::ZIP_FILE_PREFIX); $root_filesystem = Registry::filesystem()->root(); - $this->upgrade_service->startMaintenanceMode(); + $this->maintenance_mode_service->offline(); $this->upgrade_service->moveFiles($source_filesystem, $root_filesystem); - $this->upgrade_service->endMaintenanceMode(); + $this->maintenance_mode_service->online(); // While we have time, clean up any old files. $files_to_keep = $this->upgrade_service->webtreesZipContents($zip_file); diff --git a/app/Services/MaintenanceModeService.php b/app/Services/MaintenanceModeService.php new file mode 100644 index 0000000000..084a85797b --- /dev/null +++ b/app/Services/MaintenanceModeService.php @@ -0,0 +1,95 @@ +<?php + +/** + * webtrees: online genealogy + * Copyright (C) 2025 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Services; + +use Fisharebest\Webtrees\Webtrees; +use InvalidArgumentException; + +use function file_get_contents; +use function file_put_contents; +use function is_dir; +use function is_file; +use function is_link; +use function is_readable; +use function is_string; +use function realpath; +use function rmdir; +use function unlink; + +/** + * Manage the site's online/offline status. + */ +readonly class MaintenanceModeService +{ + private const string OFFLINE_FILE = 'offline.txt'; + + public function __construct(private string $data_dir = Webtrees::DATA_DIR) + { + if (!is_dir($data_dir)) { + throw new InvalidArgumentException($data_dir . ' does not exist'); + } + } + + public function file(): string + { + // Remove any '/../' from the path. + return realpath($this->data_dir) . DIRECTORY_SEPARATOR . self::OFFLINE_FILE; + } + + public function isOffline(): bool + { + $file = $this->file(); + + return is_file($file) || is_link($file) || is_dir($file); + } + + public function message(): string + { + $file = $this->file(); + + if ($this->isOffline() && is_file($file) && is_readable($file)) { + $message = file_get_contents($file); + + if (is_string($message)) { + return $message; + } + } + + return ''; + } + + public function offline(string $message = ''): void + { + $this->online(); + + file_put_contents($this->file(), $message); + } + + public function online(): void + { + $file = $this->file(); + + if (is_dir($file)) { + rmdir($file); + } elseif (is_link($file) || is_file($file)) { + unlink($file); + } + } +} diff --git a/app/Services/UpgradeService.php b/app/Services/UpgradeService.php index 13893216a2..6c44780e70 100644 --- a/app/Services/UpgradeService.php +++ b/app/Services/UpgradeService.php @@ -301,26 +301,6 @@ class UpgradeService } /** - * @return void - */ - public function startMaintenanceMode(): void - { - $message = I18N::translate('This website is being upgraded. Try again in a few minutes.'); - - file_put_contents(Webtrees::OFFLINE_FILE, $message); - } - - /** - * @return void - */ - public function endMaintenanceMode(): void - { - if (file_exists(Webtrees::OFFLINE_FILE)) { - unlink(Webtrees::OFFLINE_FILE); - } - } - - /** * Check with the webtrees.net server for the latest version of webtrees. * Fetching the remote file can be slow, so check infrequently, and cache the result. * Pass the current versions of webtrees, PHP and database, as the response diff --git a/app/Webtrees.php b/app/Webtrees.php index 5a0ec56b71..ffde03b866 100644 --- a/app/Webtrees.php +++ b/app/Webtrees.php @@ -109,9 +109,6 @@ class Webtrees // Location of the file containing the database connection details. public const string CONFIG_FILE = self::DATA_DIR . 'config.ini.php'; - // Location of the file that triggers maintenance mode. - public const string OFFLINE_FILE = self::DATA_DIR . 'offline.txt'; - // Location of our modules. public const string MODULES_PATH = 'modules_v4/'; public const string MODULES_DIR = self::ROOT_DIR . self::MODULES_PATH; diff --git a/tests/app/Http/Middleware/CheckForMaintenanceModeTest.php b/tests/app/Http/Middleware/CheckForMaintenanceModeTest.php index c2d45d7c56..a047f552ac 100644 --- a/tests/app/Http/Middleware/CheckForMaintenanceModeTest.php +++ b/tests/app/Http/Middleware/CheckForMaintenanceModeTest.php @@ -20,6 +20,7 @@ declare(strict_types=1); namespace Fisharebest\Webtrees\Http\Middleware; use Fig\Http\Message\StatusCodeInterface; +use Fisharebest\Webtrees\Services\MaintenanceModeService; use Fisharebest\Webtrees\TestCase; use PHPUnit\Framework\Attributes\CoversClass; use Psr\Http\Server\RequestHandlerInterface; @@ -29,13 +30,33 @@ use function response; #[CoversClass(CheckForMaintenanceMode::class)] class CheckForMaintenanceModeTest extends TestCase { - public function testMiddleware(): void + public function testSiteIsOffline(): void { $handler = $this->createMock(RequestHandlerInterface::class); $handler->method('handle')->willReturn(response()); + $maintenance_mode_service = $this->createMock(MaintenanceModeService::class); + $maintenance_mode_service->method('isOffline')->willReturn(true); + $maintenance_mode_service->method('message')->willReturn('XYZZY'); + + $request = self::createRequest(); + $middleware = new CheckForMaintenanceMode($maintenance_mode_service); + $response = $middleware->process($request, $handler); + + self::assertSame(StatusCodeInterface::STATUS_SERVICE_UNAVAILABLE, $response->getStatusCode()); + self::assertStringContainsString('XYZZY', $response->getBody()->getContents()); + } + + public function testSiteIsOnline(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('handle')->willReturn(response()); + + $maintenance_mode_service = $this->createMock(MaintenanceModeService::class); + $maintenance_mode_service->method('isOffline')->willReturn(false); + $request = self::createRequest(); - $middleware = new CheckForMaintenanceMode(); + $middleware = new CheckForMaintenanceMode($maintenance_mode_service); $response = $middleware->process($request, $handler); self::assertSame(StatusCodeInterface::STATUS_NO_CONTENT, $response->getStatusCode()); diff --git a/tests/app/Http/RequestHandlers/UpgradeWizardStepTest.php b/tests/app/Http/RequestHandlers/UpgradeWizardStepTest.php index 4b9c747a5a..030a7135fa 100644 --- a/tests/app/Http/RequestHandlers/UpgradeWizardStepTest.php +++ b/tests/app/Http/RequestHandlers/UpgradeWizardStepTest.php @@ -26,6 +26,7 @@ use Fisharebest\Webtrees\Auth; use Fisharebest\Webtrees\Http\Exceptions\HttpServerErrorException; use Fisharebest\Webtrees\Services\GedcomExportService; use Fisharebest\Webtrees\Services\GedcomImportService; +use Fisharebest\Webtrees\Services\MaintenanceModeService; use Fisharebest\Webtrees\Services\PhpService; use Fisharebest\Webtrees\Services\TimeoutService; use Fisharebest\Webtrees\Services\TreeService; @@ -45,8 +46,9 @@ class UpgradeWizardStepTest extends TestCase { $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), - new UpgradeService(new TimeoutService(php_service: new PhpService())) + new UpgradeService(new TimeoutService(php_service: new PhpService())), ); $request = self::createRequest(RequestMethodInterface::METHOD_POST, ['step' => 'Invalid']); @@ -62,6 +64,7 @@ class UpgradeWizardStepTest extends TestCase $mock_upgrade_service->method('latestVersion')->willReturn('999.999.999'); $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), $mock_upgrade_service ); @@ -80,6 +83,7 @@ class UpgradeWizardStepTest extends TestCase $mock_upgrade_service->method('latestVersion')->willReturn(''); $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), $mock_upgrade_service ); @@ -96,6 +100,7 @@ class UpgradeWizardStepTest extends TestCase $mock_upgrade_service->method('latestVersion')->willReturn('0.0.0'); $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), $mock_upgrade_service ); @@ -108,6 +113,7 @@ class UpgradeWizardStepTest extends TestCase { $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), new UpgradeService(new TimeoutService(php_service: new PhpService())) ); @@ -122,6 +128,7 @@ class UpgradeWizardStepTest extends TestCase { $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), new UpgradeService(new TimeoutService(php_service: new PhpService())) ); @@ -143,6 +150,7 @@ class UpgradeWizardStepTest extends TestCase $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), new UpgradeService(new TimeoutService(php_service: new PhpService())) ); @@ -162,6 +170,7 @@ class UpgradeWizardStepTest extends TestCase $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), $tree_service, new UpgradeService(new TimeoutService(php_service: new PhpService())) ); @@ -182,9 +191,10 @@ class UpgradeWizardStepTest extends TestCase $this->expectException(HttpServerErrorException::class); $mock_upgrade_service = $this->createMock(UpgradeService::class); - $mock_upgrade_service->method('downloadFile')->will(self::throwException(new Exception())); + $mock_upgrade_service->method('downloadFile')->will($this->throwException(new Exception())); $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), $mock_upgrade_service ); @@ -199,6 +209,7 @@ class UpgradeWizardStepTest extends TestCase $mock_upgrade_service->method('downloadFile')->willReturn(123456); $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), $mock_upgrade_service ); @@ -215,6 +226,7 @@ class UpgradeWizardStepTest extends TestCase $mock_upgrade_service->method('webtreesZipContents')->willReturn(new Collection()); $handler = new UpgradeWizardStep( new GedcomExportService(new Psr17Factory(), new Psr17Factory()), + new MaintenanceModeService(__DIR__ . '/../../../data/'), new TreeService(new GedcomImportService()), $mock_upgrade_service ); diff --git a/tests/app/Services/MaintenanceModeServiceTest.php b/tests/app/Services/MaintenanceModeServiceTest.php new file mode 100644 index 0000000000..6e11385e59 --- /dev/null +++ b/tests/app/Services/MaintenanceModeServiceTest.php @@ -0,0 +1,115 @@ +<?php + +/** + * webtrees: online genealogy + * Copyright (C) 2025 webtrees development team + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +declare(strict_types=1); + +namespace Fisharebest\Webtrees\Services; + +use Fisharebest\Webtrees\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\CoversClass; + +use function file_put_contents; + +#[CoversClass(MaintenanceModeService::class)] +class MaintenanceModeServiceTest extends TestCase +{ + private const string TEST_DATA_DIR = __DIR__ . '/../../data/'; + + public function testInvalidFolder(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(__DIR__ . '/non-existent does not exist'); + + new MaintenanceModeService(__DIR__ . '/non-existent'); + } + + public function testOfflineAndOnline(): void + { + $service = new MaintenanceModeService(self::TEST_DATA_DIR); + + $this->assertFalse($service->isOffline()); + + $service->offline(); + + $this->assertTrue($service->isOffline()); + + $service->online(); + + $this->assertFalse($service->isOffline()); + } + + public function testMessage(): void + { + $service = new MaintenanceModeService(self::TEST_DATA_DIR); + + $service->offline('foo bar'); + + $this->assertSame('foo bar', $service->message()); + + $service->online(); + } + + public function testOfflineFileIsAFolder(): void + { + $service = new MaintenanceModeService(self::TEST_DATA_DIR); + + mkdir(self::TEST_DATA_DIR . 'offline.txt', 0777, true); + + $this->assertTrue($service->isOffline()); + $this->assertSame('', $service->message()); + + $service->online(); + + $this->assertFalse($service->isOffline()); + } + + public function testOfflineFileIsUnreadable(): void + { + $service = new MaintenanceModeService(self::TEST_DATA_DIR); + + file_put_contents(self::TEST_DATA_DIR . 'offline.txt', 'foo'); + chmod(self::TEST_DATA_DIR . 'offline.txt', 0); + + $this->assertTrue($service->isOffline()); + $this->assertSame('', $service->message()); + + $service->online(); + + $this->assertFalse($service->isOffline()); + } + + public function testOfflineFileIsSymbolicLink(): void + { + $service = new MaintenanceModeService(self::TEST_DATA_DIR); + + file_put_contents(self::TEST_DATA_DIR . 'foo', 'foo'); + symlink(self::TEST_DATA_DIR . 'foo', self::TEST_DATA_DIR . 'offline.txt'); + + $this->assertTrue($service->isOffline()); + $this->assertSame('foo', $service->message()); + + unlink(self::TEST_DATA_DIR . 'foo'); + + $this->assertTrue($service->isOffline()); + $this->assertSame('', $service->message()); + + $service->online(); + + $this->assertFalse($service->isOffline()); + } +} |
