]> BookStack Code Mirror - system-cli/blobdiff - src/Commands/BackupCommand.php
Bumped version
[system-cli] / src / Commands / BackupCommand.php
index 7e47d4ad1b4366b02e69960801da2d0501c3f60b..47da179ba60c04c0c858c8ee76524978c94928a7 100644 (file)
@@ -1,10 +1,12 @@
-<?php
+<?php declare(strict_types=1);
 
 namespace Cli\Commands;
 
 use Cli\Services\AppLocator;
 use Cli\Services\EnvironmentLoader;
 use Cli\Services\MySqlRunner;
+use Cli\Services\Paths;
+use FilesystemIterator;
 use RecursiveDirectoryIterator;
 use SplFileInfo;
 use Symfony\Component\Console\Command\Command;
@@ -20,10 +22,10 @@ final class BackupCommand extends Command
     {
         $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");
+        $this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup ZIP file.', '');
+        $this->addOption('no-database', null, InputOption::VALUE_NONE, "Skip adding a database dump to the backup");
+        $this->addOption('no-uploads', null, InputOption::VALUE_NONE, "Skip adding uploaded files to the backup");
+        $this->addOption('no-themes', null, InputOption::VALUE_NONE, "Skip adding the themes folder to the backup");
         $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
     }
 
@@ -47,15 +49,18 @@ final class BackupCommand extends Command
         $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
         $dumpTempFile = '';
         $zip = new ZipArchive();
-        $zip->open($zipTempFile, ZipArchive::CREATE);
+        $zip->open($zipTempFile, ZipArchive::OVERWRITE);
 
-        // Add default files (.env config file and this CLI)
-        $zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
-        $zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
+        // Add default files (.env config file and this CLI if existing)
+        $zip->addFile(Paths::join($appDir, '.env'), '.env');
+        $cliPath = Paths::join($appDir, 'bookstack-system-cli');
+        if (file_exists($cliPath)) {
+            $zip->addFile($cliPath, 'bookstack-system-cli');
+        }
 
         if ($handleDatabase) {
             $output->writeln("<info>Dumping the database via mysqldump...</info>");
-            $dumpTempFile = $this->createDatabaseDump($appDir);
+            $dumpTempFile = $this->createDatabaseDump($appDir, $output);
             $output->writeln("<info>Adding database dump to backup archive...</info>");
             $zip->addFile($dumpTempFile, 'db.sql');
         }
@@ -67,9 +72,10 @@ final class BackupCommand extends Command
 
         if ($handleThemes) {
             $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
-            $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
+            $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'themes'), 'themes');
         }
 
+        $output->writeln("<info>Saving backup archive...</info>");
         // 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.
@@ -80,7 +86,7 @@ final class BackupCommand extends Command
         rename($zipTempFile, $zipOutFile);
 
         // Announce end
-        $output->writeln("<info>Backup finished.</info>");
+        $output->writeln("<success>Backup finished.</success>");
         $output->writeln("Output ZIP saved to: {$zipOutFile}");
 
         return Command::SUCCESS;
@@ -100,14 +106,16 @@ final class BackupCommand extends Command
     /**
      * Build a full zip path from the given suggestion, which may be empty,
      * a path to a folder, or a path to a file in relative or absolute form.
+     * Targets the <app>/backups directory by default if existing, otherwise <app>.
      * @throws CommandError
      */
     protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
     {
-        $zipDir = getcwd() ?: $appDir;
+        $zipDir = Paths::join($appDir, 'storage', 'backups');
         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
 
         if ($suggestedOutPath) {
+            $suggestedOutPath = Paths::resolve($suggestedOutPath);
             if (is_dir($suggestedOutPath)) {
                 $zipDir = realpath($suggestedOutPath);
             } else if (is_dir(dirname($suggestedOutPath))) {
@@ -116,12 +124,20 @@ final class BackupCommand extends Command
             } else {
                 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
             }
+        } else {
+            if (!is_dir($zipDir)) {
+                $zipDir = $appDir;
+            }
         }
 
-        $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
+        $fullPath = Paths::join($zipDir, $zipName);
 
         if (file_exists($fullPath)) {
             throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
+        } else if (!is_dir($zipDir)) {
+            throw new CommandError("Target ZIP output directory [{$fullPath}] could not be found.");
+        } else if (!is_writable($zipDir)) {
+            throw new CommandError("Target ZIP output directory [{$fullPath}] is not writable.");
         }
 
         return $fullPath;
@@ -133,8 +149,8 @@ final class BackupCommand extends Command
      */
     protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
     {
-        $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
-        $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
+        $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'public', 'uploads'), 'public/uploads');
+        $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'storage', 'uploads'), 'storage/uploads');
     }
 
     /**
@@ -143,12 +159,15 @@ final class BackupCommand extends Command
      */
     protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
     {
-        $dirIter = new RecursiveDirectoryIterator($dirPath);
+        $dirIter = new RecursiveDirectoryIterator(
+            $dirPath,
+            FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::FOLLOW_SYMLINKS
+        );
         $fileIter = new \RecursiveIteratorIterator($dirIter);
         /** @var SplFileInfo $file */
         foreach ($fileIter as $file) {
             if (!$file->isDir()) {
-                $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
+                $zip->addFile($file->getRealPath(), $targetZipPath . '/' . $fileIter->getSubPathname());
             }
         }
     }
@@ -157,7 +176,7 @@ final class BackupCommand extends Command
      * Create a database dump and return the path to the dumped SQL output.
      * @throws CommandError
      */
-    protected function createDatabaseDump(string $appDir): string
+    protected function createDatabaseDump(string $appDir, OutputInterface $output): string
     {
         $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
         $mysql = MySqlRunner::fromEnvOptions($envOptions);
@@ -165,7 +184,10 @@ final class BackupCommand extends Command
 
         $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
         try {
-            $mysql->runDumpToFile($dumpTempFile);
+            $warnings = $mysql->runDumpToFile($dumpTempFile);
+            if ($warnings) {
+                $output->writeln("<warn>Received warnings during mysqldump:\n{$warnings}</warn>");
+            }
         } catch (\Exception $exception) {
             unlink($dumpTempFile);
             throw new CommandError($exception->getMessage());