]> BookStack Code Mirror - system-cli/blob - src/Commands/BackupCommand.php
Bumped version
[system-cli] / src / Commands / BackupCommand.php
1 <?php declare(strict_types=1);
2
3 namespace Cli\Commands;
4
5 use Cli\Services\AppLocator;
6 use Cli\Services\EnvironmentLoader;
7 use Cli\Services\MySqlRunner;
8 use Cli\Services\Paths;
9 use FilesystemIterator;
10 use RecursiveDirectoryIterator;
11 use SplFileInfo;
12 use Symfony\Component\Console\Command\Command;
13 use Symfony\Component\Console\Input\InputArgument;
14 use Symfony\Component\Console\Input\InputInterface;
15 use Symfony\Component\Console\Input\InputOption;
16 use Symfony\Component\Console\Output\OutputInterface;
17 use ZipArchive;
18
19 final class BackupCommand extends Command
20 {
21     protected function configure(): void
22     {
23         $this->setName('backup');
24         $this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
25         $this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup ZIP file.', '');
26         $this->addOption('no-database', null, InputOption::VALUE_NONE, "Skip adding a database dump to the backup");
27         $this->addOption('no-uploads', null, InputOption::VALUE_NONE, "Skip adding uploaded files to the backup");
28         $this->addOption('no-themes', null, InputOption::VALUE_NONE, "Skip adding the themes folder to the backup");
29         $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
30     }
31
32     /**
33      * @throws CommandError
34      */
35     protected function execute(InputInterface $input, OutputInterface $output): int
36     {
37         $appDir = AppLocator::require($input->getOption('app-directory'));
38         $output->writeln("<info>Checking system requirements...</info>");
39         $this->ensureRequiredExtensionInstalled();
40
41         $handleDatabase = !$input->getOption('no-database');
42         $handleUploads = !$input->getOption('no-uploads');
43         $handleThemes = !$input->getOption('no-themes');
44         $suggestedOutPath = $input->getArgument('backup-path');
45
46         $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
47
48         // Create a new ZIP file
49         $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
50         $dumpTempFile = '';
51         $zip = new ZipArchive();
52         $zip->open($zipTempFile, ZipArchive::OVERWRITE);
53
54         // Add default files (.env config file and this CLI if existing)
55         $zip->addFile(Paths::join($appDir, '.env'), '.env');
56         $cliPath = Paths::join($appDir, 'bookstack-system-cli');
57         if (file_exists($cliPath)) {
58             $zip->addFile($cliPath, 'bookstack-system-cli');
59         }
60
61         if ($handleDatabase) {
62             $output->writeln("<info>Dumping the database via mysqldump...</info>");
63             $dumpTempFile = $this->createDatabaseDump($appDir, $output);
64             $output->writeln("<info>Adding database dump to backup archive...</info>");
65             $zip->addFile($dumpTempFile, 'db.sql');
66         }
67
68         if ($handleUploads) {
69             $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
70             $this->addUploadFoldersToZip($zip, $appDir);
71         }
72
73         if ($handleThemes) {
74             $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
75             $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'themes'), 'themes');
76         }
77
78         $output->writeln("<info>Saving backup archive...</info>");
79         // Close off our zip and move it to the required location
80         $zip->close();
81         // Delete our temporary DB dump file if exists. Must be done after zip close.
82         if ($dumpTempFile) {
83             unlink($dumpTempFile);
84         }
85         // Move the zip into the target location
86         rename($zipTempFile, $zipOutFile);
87
88         // Announce end
89         $output->writeln("<success>Backup finished.</success>");
90         $output->writeln("Output ZIP saved to: {$zipOutFile}");
91
92         return Command::SUCCESS;
93     }
94
95     /**
96      * Ensure the required PHP extensions are installed for this command.
97      * @throws CommandError
98      */
99     protected function ensureRequiredExtensionInstalled(): void
100     {
101         if (!extension_loaded('zip')) {
102             throw new CommandError('The "zip" PHP extension is required to run this command');
103         }
104     }
105
106     /**
107      * Build a full zip path from the given suggestion, which may be empty,
108      * a path to a folder, or a path to a file in relative or absolute form.
109      * Targets the <app>/backups directory by default if existing, otherwise <app>.
110      * @throws CommandError
111      */
112     protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
113     {
114         $zipDir = Paths::join($appDir, 'storage', 'backups');
115         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
116
117         if ($suggestedOutPath) {
118             $suggestedOutPath = Paths::resolve($suggestedOutPath);
119             if (is_dir($suggestedOutPath)) {
120                 $zipDir = realpath($suggestedOutPath);
121             } else if (is_dir(dirname($suggestedOutPath))) {
122                 $zipDir = realpath(dirname($suggestedOutPath));
123                 $zipName = basename($suggestedOutPath);
124             } else {
125                 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
126             }
127         } else {
128             if (!is_dir($zipDir)) {
129                 $zipDir = $appDir;
130             }
131         }
132
133         $fullPath = Paths::join($zipDir, $zipName);
134
135         if (file_exists($fullPath)) {
136             throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
137         } else if (!is_dir($zipDir)) {
138             throw new CommandError("Target ZIP output directory [{$fullPath}] could not be found.");
139         } else if (!is_writable($zipDir)) {
140             throw new CommandError("Target ZIP output directory [{$fullPath}] is not writable.");
141         }
142
143         return $fullPath;
144     }
145
146     /**
147      * Add app-relative upload folders to the provided zip archive.
148      * Will recursively go through all directories to add all files.
149      */
150     protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
151     {
152         $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'public', 'uploads'), 'public/uploads');
153         $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'storage', 'uploads'), 'storage/uploads');
154     }
155
156     /**
157      * Recursively add all contents of the given dirPath to the provided zip file
158      * with a zip location of the targetZipPath.
159      */
160     protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
161     {
162         $dirIter = new RecursiveDirectoryIterator(
163             $dirPath,
164             FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::FOLLOW_SYMLINKS
165         );
166         $fileIter = new \RecursiveIteratorIterator($dirIter);
167         /** @var SplFileInfo $file */
168         foreach ($fileIter as $file) {
169             if (!$file->isDir()) {
170                 $zip->addFile($file->getRealPath(), $targetZipPath . '/' . $fileIter->getSubPathname());
171             }
172         }
173     }
174
175     /**
176      * Create a database dump and return the path to the dumped SQL output.
177      * @throws CommandError
178      */
179     protected function createDatabaseDump(string $appDir, OutputInterface $output): string
180     {
181         $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
182         $mysql = MySqlRunner::fromEnvOptions($envOptions);
183         $mysql->ensureOptionsSet();
184
185         $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
186         try {
187             $warnings = $mysql->runDumpToFile($dumpTempFile);
188             if ($warnings) {
189                 $output->writeln("<warn>Received warnings during mysqldump:\n{$warnings}</warn>");
190             }
191         } catch (\Exception $exception) {
192             unlink($dumpTempFile);
193             throw new CommandError($exception->getMessage());
194         }
195
196         return $dumpTempFile;
197     }
198 }