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