]> BookStack Code Mirror - system-cli/blobdiff - src/Commands/RestoreCommand.php
Added path check/validation for provided restore file path
[system-cli] / src / Commands / RestoreCommand.php
index a7c8ac5ff618097d40dfb399b8bb3f48eb1d46ed..3fb91540dc8328696ba269741b6714579a5d3deb 100644 (file)
@@ -1,17 +1,18 @@
-<?php
+<?php declare(strict_types=1);
 
 namespace Cli\Commands;
 
 use Cli\Services\AppLocator;
 use Cli\Services\ArtisanRunner;
 use Cli\Services\BackupZip;
+use Cli\Services\Directories;
 use Cli\Services\EnvironmentLoader;
 use Cli\Services\InteractiveConsole;
 use Cli\Services\MySqlRunner;
+use Cli\Services\Paths;
 use Cli\Services\ProgramRunner;
 use Cli\Services\RequirementsValidator;
-use RecursiveDirectoryIterator;
-use RecursiveIteratorIterator;
+use Exception;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
@@ -30,23 +31,30 @@ class RestoreCommand extends Command
 
     /**
      * @throws CommandError
+     * @throws Exception
      */
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
         $interactions = new InteractiveConsole($this->getHelper('question'), $input, $output);
 
-        $output->writeln("<info>Warning!</info>");
-        $output->writeln("<info>- A restore operation will overwrite and remove files & content from an existing instance.</info>");
-        $output->writeln("<info>- Any existing tables within the configured database will be dropped.</info>");
-        $output->writeln("<info>- You should only restore into an instance of the same or newer BookStack version.</info>");
-        $output->writeln("<info>- This command won't handle, restore or address any server configuration.</info>");
+        $output->writeln("<warn>Warning!</warn>");
+        $output->writeln("<warn>- A restore operation will overwrite and remove files & content from an existing instance.</warn>");
+        $output->writeln("<warn>- Any existing tables within the configured database will be dropped.</warn>");
+        $output->writeln("<warn>- You should only restore into an instance of the same or newer BookStack version.</warn>");
+        $output->writeln("<warn>- This command won't handle, restore or address any server configuration.</warn>");
 
         $appDir = AppLocator::require($input->getOption('app-directory'));
         $output->writeln("<info>Checking system requirements...</info>");
         RequirementsValidator::validate();
         (new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound();
 
-        $zipPath = realpath($input->getArgument('backup-zip'));
+        $providedZipPath = $input->getArgument('backup-zip');
+        $zipPath = realpath($providedZipPath);
+        if (!$zipPath || !file_exists($zipPath)) {
+            $pathToDisplay = $zipPath ?: $providedZipPath;
+            throw new CommandError("Could not find ZIP file for restoration at provided path [{$pathToDisplay}].");
+        }
+
         $zip = new BackupZip($zipPath);
         $contents = $zip->getContentsOverview();
 
@@ -60,11 +68,11 @@ class RestoreCommand extends Command
         }
 
         if (!$hasContent) {
-            throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restore-able content.");
+            throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restorable content.");
         }
 
         $output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
-        $output->writeln("<info>Existing content may be overwritten.</info>");
+        $output->writeln("<warn>Existing content will be overwritten.</warn>");
 
         if (!$interactions->confirm("Do you want to continue?")) {
             $output->writeln("<info>Stopping restore operation.</info>");
@@ -72,7 +80,7 @@ class RestoreCommand extends Command
         }
 
         $output->writeln("<info>Extracting ZIP into temporary directory...</info>");
-        $extractDir = $appDir . DIRECTORY_SEPARATOR . 'restore-temp-' . time();
+        $extractDir = Paths::join($appDir, 'restore-temp-' . time());
         if (!mkdir($extractDir)) {
             throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
         }
@@ -102,9 +110,9 @@ class RestoreCommand extends Command
         }
 
         if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
-            $output->writeln("<info>App URL change made, Updating database with URL change...</info>");
+            $output->writeln("<info>App URL change made, updating database with URL change...</info>");
             $artisan->run([
-                'bookstack:update-url',
+                'bookstack:update-url', '--force',
                 $envChanges['old_url'], $envChanges['new_url'],
             ]);
         }
@@ -115,9 +123,11 @@ class RestoreCommand extends Command
         $artisan->run(['view:clear']);
 
         $output->writeln("<info>Cleaning up extract directory...</info>");
-        $this->deleteDirectoryAndContents($extractDir);
+        Directories::delete($extractDir);
 
-        $output->writeln("<info>\nRestore operation complete!</info>");
+        $output->writeln("<success>\nRestore operation complete!</success>");
+        $output->writeln("<info>You may need to fix file/folder permissions so that the webserver has</info>");
+        $output->writeln("<info>the required read/write access to the necessary directories & files.</info>");
 
         return Command::SUCCESS;
     }
@@ -126,8 +136,8 @@ class RestoreCommand extends Command
     {
         $oldEnv = EnvironmentLoader::load($extractDir);
         $currentEnv = EnvironmentLoader::load($appDir);
-        $envContents = file_get_contents($extractDir . DIRECTORY_SEPARATOR . '.env');
-        $appEnvPath = $appDir . DIRECTORY_SEPARATOR . '.env';
+        $envContents = file_get_contents(Paths::join($extractDir, '.env'));
+        $appEnvPath = Paths::real(Paths::join($appDir, '.env'));
 
         $mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
         $mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
@@ -142,7 +152,7 @@ class RestoreCommand extends Command
             $currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
                 return str_starts_with($line, 'DB_');
             }));
-            $oldEnvLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
+            $oldEnvLines = array_values(array_filter(explode("\n", $envContents), function (string $line) {
                 return !str_starts_with($line, 'DB_');
             }));
             $envContents = implode("\n", [
@@ -161,44 +171,36 @@ class RestoreCommand extends Command
         ];
 
         if ($oldUrl !== $newUrl) {
-            $output->writeln("Found different APP_URL values:");
-            $changedUrl = $interactions->choice('Which would you like to use?', array_filter([$oldUrl, $newUrl]));
-            $envContents = preg_replace('/^APP_URL=.*?$/', 'APP_URL="' . $changedUrl . '"', $envContents);
+            $question = 'Found different APP_URL values, which would you like to use?';
+            $changedUrl = $interactions->choice($question, array_filter([$oldUrl, $newUrl]));
+            $envContents = preg_replace('/^APP_URL=.*?$/m', 'APP_URL="' . $changedUrl . '"', $envContents);
             $returnData['new_url'] = $changedUrl;
         }
 
-        file_put_contents($appDir . DIRECTORY_SEPARATOR . '.env', $envContents);
+        file_put_contents($appEnvPath, $envContents);
 
         return $returnData;
     }
 
     protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
     {
-        $fullAppFolderPath = $appDir . DIRECTORY_SEPARATOR . $folderSubPath;
-        $this->deleteDirectoryAndContents($fullAppFolderPath);
-        rename($extractDir . DIRECTORY_SEPARATOR . $folderSubPath, $fullAppFolderPath);
-    }
-
-    protected function deleteDirectoryAndContents(string $dir)
-    {
-        $files = new RecursiveIteratorIterator(
-            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
-            RecursiveIteratorIterator::CHILD_FIRST
-        );
-
-        foreach ($files as $fileinfo) {
-            $path = $fileinfo->getRealPath();
-            $fileinfo->isDir() ? rmdir($path) : unlink($path);
-        }
-
-        rmdir($dir);
+        $fullAppFolderPath = Paths::real(Paths::join($appDir, $folderSubPath));
+        Directories::delete($fullAppFolderPath);
+        Directories::move(Paths::join($extractDir, $folderSubPath), $fullAppFolderPath);
     }
 
     protected function restoreDatabase(string $appDir, string $extractDir): void
     {
-        $dbDump = $extractDir . DIRECTORY_SEPARATOR . 'db.sql';
+        $dbDump = Paths::join($extractDir, 'db.sql');
         $currentEnv = EnvironmentLoader::load($appDir);
         $mysql = MySqlRunner::fromEnvOptions($currentEnv);
+
+        // Drop existing tables
+        $dropSqlTempFile = tempnam(sys_get_temp_dir(), 'bs-cli-restore');
+        file_put_contents($dropSqlTempFile, $mysql->dropTablesSql());
+        $mysql->importSqlFile($dropSqlTempFile);
+
+        // Import MySQL dump
         $mysql->importSqlFile($dbDump);
     }
 }