]> BookStack Code Mirror - system-cli/blobdiff - scripts/Commands/BackupCommand.php
Added central way to resolve app path, improved ouput formatting
[system-cli] / scripts / Commands / BackupCommand.php
index a607f342a0c55e2e80c6ffecc66bd92176c7b2d2..727e0200c3ef928e830ed8f9567743497833c022 100644 (file)
@@ -2,72 +2,87 @@
 
 namespace Cli\Commands;
 
-use Minicli\Command\CommandCall;
+use Cli\Services\AppLocator;
+use Cli\Services\EnvironmentLoader;
+use Cli\Services\ProgramRunner;
 use RecursiveDirectoryIterator;
 use SplFileInfo;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Process\Exception\ProcessTimedOutException;
-use Symfony\Component\Process\ExecutableFinder;
-use Symfony\Component\Process\Process;
 use ZipArchive;
 
-final class BackupCommand
+final class BackupCommand extends Command
 {
-    public function __construct(
-        protected string $appDir
-    ) {
+    protected function configure(): void
+    {
+        $this->setName('backup');
+        $this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
+        $this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup file.', '');
+        $this->addOption('no-database', null, null, "Skip adding a database dump to the backup");
+        $this->addOption('no-uploads', null, null, "Skip adding uploaded files to the backup");
+        $this->addOption('no-themes', null, null, "Skip adding the themes folder to the backup");
     }
 
     /**
      * @throws CommandError
      */
-    public function handle(CommandCall $input)
+    protected function execute(InputInterface $input, OutputInterface $output): int
     {
+        $appDir = AppLocator::require($input->getOption('app-directory'));
+        $output->writeln("<info>Checking system requirements...</info>");
         $this->ensureRequiredExtensionInstalled();
 
-        $handleDatabase = !$input->hasFlag('no-database');
-        $handleUploads = !$input->hasFlag('no-uploads');
-        $handleThemes = !$input->hasFlag('no-themes');
-        $suggestedOutPath = $input->subcommand;
-        if ($suggestedOutPath === 'default') {
-            $suggestedOutPath = '';
-        }
+        $handleDatabase = !$input->getOption('no-database');
+        $handleUploads = !$input->getOption('no-uploads');
+        $handleThemes = !$input->getOption('no-themes');
+        $suggestedOutPath = $input->getArgument('backup-path');
 
-        $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
+        $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
 
         // Create a new ZIP file
         $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
+        $dumpTempFile = '';
         $zip = new ZipArchive();
         $zip->open($zipTempFile, ZipArchive::CREATE);
 
         // Add default files (.env config file and this CLI)
-        $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . '.env', '.env');
-        $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
+        $zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
+        $zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
 
         if ($handleDatabase) {
-            echo "Dumping the database via mysqldump...\n";
-            $dumpTempFile = $this->createDatabaseDump();
-            echo "Adding database dump to backup archive...\n";
+            $output->writeln("<info>Dumping the database via mysqldump...</info>");
+            $dumpTempFile = $this->createDatabaseDump($appDir);
+            $output->writeln("<info>Adding database dump to backup archive...</info>");
             $zip->addFile($dumpTempFile, 'db.sql');
-            // Delete our temporary DB dump file
-            unlink($dumpTempFile);
         }
 
         if ($handleUploads) {
-            echo "Adding BookStack upload folders to backup archive...\n";
-            $this->addUploadFoldersToZip($zip);
+            $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
+            $this->addUploadFoldersToZip($zip, $appDir);
         }
 
         if ($handleThemes) {
-            echo "Adding BookStack theme folders to backup archive...\n";
-            $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
+            $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
+            $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
         }
 
         // Close off our zip and move it to the required location
         $zip->close();
+        // Delete our temporary DB dump file if exists. Must be done after zip close.
+        if ($dumpTempFile) {
+            unlink($dumpTempFile);
+        }
+        // Move the zip into the target location
         rename($zipTempFile, $zipOutFile);
 
         // Announce end
-        echo "Backup finished.\nOutput ZIP saved to: {$zipOutFile}\n";
+        $output->writeln("<info>Backup finished.</info>");
+        $output->writeln("Output ZIP saved to: {$zipOutFile}");
+
+        return Command::SUCCESS;
     }
 
     /**
@@ -86,9 +101,9 @@ final class BackupCommand
      * a path to a folder, or a path to a file in relative or absolute form.
      * @throws CommandError
      */
-    protected function buildZipFilePath(string $suggestedOutPath): string
+    protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
     {
-        $zipDir = getcwd() ?: $this->appDir;
+        $zipDir = getcwd() ?: $appDir;
         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
 
         if ($suggestedOutPath) {
@@ -115,10 +130,10 @@ final class BackupCommand
      * Add app-relative upload folders to the provided zip archive.
      * Will recursively go through all directories to add all files.
      */
-    protected function addUploadFoldersToZip(ZipArchive $zip): void
+    protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
     {
-        $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'public', 'uploads']), 'public/uploads');
-        $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'storage', 'uploads']), 'storage/uploads');
+        $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
+        $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
     }
 
     /**
@@ -141,16 +156,17 @@ final class BackupCommand
      * Create a database dump and return the path to the dumped SQL output.
      * @throws CommandError
      */
-    protected function createDatabaseDump(): string
+    protected function createDatabaseDump(string $appDir): string
     {
+        $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
         $dbOptions = [
-            'host' => ($_SERVER['DB_HOST'] ?? ''),
-            'username' => ($_SERVER['DB_USERNAME'] ?? ''),
-            'password' => ($_SERVER['DB_PASSWORD'] ?? ''),
-            'database' => ($_SERVER['DB_DATABASE'] ?? ''),
+            'host' => ($envOptions['DB_HOST'] ?? ''),
+            'username' => ($envOptions['DB_USERNAME'] ?? ''),
+            'password' => ($envOptions['DB_PASSWORD'] ?? ''),
+            'database' => ($envOptions['DB_DATABASE'] ?? ''),
         ];
 
-        $port = $_SERVER['DB_PORT'] ?? '';
+        $port = $envOptions['DB_PORT'] ?? '';
         if ($port) {
             $dbOptions['host'] .= ':' . $port;
         }
@@ -161,48 +177,39 @@ final class BackupCommand
             }
         }
 
-        // Create a mysqldump for the BookStack database
-        $executableFinder = new ExecutableFinder();
-        $mysqldumpPath = $executableFinder->find('mysqldump', '/usr/bin/mysqldump');
-
-        if (!is_file($mysqldumpPath)) {
-            throw new CommandError('Could not locate "mysqldump" program');
-        }
-
-        $process = new Process([
-            $mysqldumpPath,
-            '-h', $dbOptions['host'],
-            '-u', $dbOptions['username'],
-            '-p' . $dbOptions['password'],
-            '--single-transaction',
-            '--no-tablespaces',
-            $dbOptions['database'],
-        ]);
-        $process->setTimeout(240);
-        $process->setIdleTimeout(5);
-        $process->start();
-
         $errors = "";
         $hasOutput = false;
-        $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
+        $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
         $dumpTempFileResource = fopen($dumpTempFile, 'w');
+
         try {
-            foreach ($process as $type => $data) {
-                if ($process::OUT === $type) {
+            (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
+                ->withTimeout(240)
+                ->withIdleTimeout(15)
+                ->runWithoutOutputCallbacks([
+                    '-h', $dbOptions['host'],
+                    '-u', $dbOptions['username'],
+                    '-p' . $dbOptions['password'],
+                    '--single-transaction',
+                    '--no-tablespaces',
+                    $dbOptions['database'],
+                ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
                     fwrite($dumpTempFileResource, $data);
                     $hasOutput = true;
-                } else { // $process::ERR === $type
-                    $errors .= $data . "\n";
-                }
-            }
-        } catch (ProcessTimedOutException $timedOutException) {
+                }, function ($error) use (&$errors) {
+                    $errors .= $error . "\n";
+                });
+        } catch (\Exception $exception) {
             fclose($dumpTempFileResource);
             unlink($dumpTempFile);
-            if (!$hasOutput) {
-                throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
-            } else {
-                throw new CommandError("mysqldump operation timed-out after data was received.");
+            if ($exception instanceof ProcessTimedOutException) {
+                if (!$hasOutput) {
+                    throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
+                } else {
+                    throw new CommandError("mysqldump operation timed-out after data was received.");
+                }
             }
+            throw new CommandError($exception->getMessage());
         }
 
         fclose($dumpTempFileResource);