]> BookStack Code Mirror - system-cli/blob - scripts/Commands/BackupCommand.php
727e0200c3ef928e830ed8f9567743497833c022
[system-cli] / scripts / Commands / BackupCommand.php
1 <?php
2
3 namespace Cli\Commands;
4
5 use Cli\Services\AppLocator;
6 use Cli\Services\EnvironmentLoader;
7 use Cli\Services\ProgramRunner;
8 use RecursiveDirectoryIterator;
9 use SplFileInfo;
10 use Symfony\Component\Console\Command\Command;
11 use Symfony\Component\Console\Input\InputArgument;
12 use Symfony\Component\Console\Input\InputInterface;
13 use Symfony\Component\Console\Output\OutputInterface;
14 use Symfony\Component\Process\Exception\ProcessTimedOutException;
15 use ZipArchive;
16
17 final class BackupCommand extends Command
18 {
19     protected function configure(): void
20     {
21         $this->setName('backup');
22         $this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
23         $this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup file.', '');
24         $this->addOption('no-database', null, null, "Skip adding a database dump to the backup");
25         $this->addOption('no-uploads', null, null, "Skip adding uploaded files to the backup");
26         $this->addOption('no-themes', null, null, "Skip adding the themes folder to the backup");
27     }
28
29     /**
30      * @throws CommandError
31      */
32     protected function execute(InputInterface $input, OutputInterface $output): int
33     {
34         $appDir = AppLocator::require($input->getOption('app-directory'));
35         $output->writeln("<info>Checking system requirements...</info>");
36         $this->ensureRequiredExtensionInstalled();
37
38         $handleDatabase = !$input->getOption('no-database');
39         $handleUploads = !$input->getOption('no-uploads');
40         $handleThemes = !$input->getOption('no-themes');
41         $suggestedOutPath = $input->getArgument('backup-path');
42
43         $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
44
45         // Create a new ZIP file
46         $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
47         $dumpTempFile = '';
48         $zip = new ZipArchive();
49         $zip->open($zipTempFile, ZipArchive::CREATE);
50
51         // Add default files (.env config file and this CLI)
52         $zip->addFile($appDir . DIRECTORY_SEPARATOR . '.env', '.env');
53         $zip->addFile($appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
54
55         if ($handleDatabase) {
56             $output->writeln("<info>Dumping the database via mysqldump...</info>");
57             $dumpTempFile = $this->createDatabaseDump($appDir);
58             $output->writeln("<info>Adding database dump to backup archive...</info>");
59             $zip->addFile($dumpTempFile, 'db.sql');
60         }
61
62         if ($handleUploads) {
63             $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
64             $this->addUploadFoldersToZip($zip, $appDir);
65         }
66
67         if ($handleThemes) {
68             $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
69             $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
70         }
71
72         // Close off our zip and move it to the required location
73         $zip->close();
74         // Delete our temporary DB dump file if exists. Must be done after zip close.
75         if ($dumpTempFile) {
76             unlink($dumpTempFile);
77         }
78         // Move the zip into the target location
79         rename($zipTempFile, $zipOutFile);
80
81         // Announce end
82         $output->writeln("<info>Backup finished.</info>");
83         $output->writeln("Output ZIP saved to: {$zipOutFile}");
84
85         return Command::SUCCESS;
86     }
87
88     /**
89      * Ensure the required PHP extensions are installed for this command.
90      * @throws CommandError
91      */
92     protected function ensureRequiredExtensionInstalled(): void
93     {
94         if (!extension_loaded('zip')) {
95             throw new CommandError('The "zip" PHP extension is required to run this command');
96         }
97     }
98
99     /**
100      * Build a full zip path from the given suggestion, which may be empty,
101      * a path to a folder, or a path to a file in relative or absolute form.
102      * @throws CommandError
103      */
104     protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
105     {
106         $zipDir = getcwd() ?: $appDir;
107         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
108
109         if ($suggestedOutPath) {
110             if (is_dir($suggestedOutPath)) {
111                 $zipDir = realpath($suggestedOutPath);
112             } else if (is_dir(dirname($suggestedOutPath))) {
113                 $zipDir = realpath(dirname($suggestedOutPath));
114                 $zipName = basename($suggestedOutPath);
115             } else {
116                 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
117             }
118         }
119
120         $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
121
122         if (file_exists($fullPath)) {
123             throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
124         }
125
126         return $fullPath;
127     }
128
129     /**
130      * Add app-relative upload folders to the provided zip archive.
131      * Will recursively go through all directories to add all files.
132      */
133     protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
134     {
135         $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
136         $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
137     }
138
139     /**
140      * Recursively add all contents of the given dirPath to the provided zip file
141      * with a zip location of the targetZipPath.
142      */
143     protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
144     {
145         $dirIter = new RecursiveDirectoryIterator($dirPath);
146         $fileIter = new \RecursiveIteratorIterator($dirIter);
147         /** @var SplFileInfo $file */
148         foreach ($fileIter as $file) {
149             if (!$file->isDir()) {
150                 $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
151             }
152         }
153     }
154
155     /**
156      * Create a database dump and return the path to the dumped SQL output.
157      * @throws CommandError
158      */
159     protected function createDatabaseDump(string $appDir): string
160     {
161         $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
162         $dbOptions = [
163             'host' => ($envOptions['DB_HOST'] ?? ''),
164             'username' => ($envOptions['DB_USERNAME'] ?? ''),
165             'password' => ($envOptions['DB_PASSWORD'] ?? ''),
166             'database' => ($envOptions['DB_DATABASE'] ?? ''),
167         ];
168
169         $port = $envOptions['DB_PORT'] ?? '';
170         if ($port) {
171             $dbOptions['host'] .= ':' . $port;
172         }
173
174         foreach ($dbOptions as $name => $option) {
175             if (!$option) {
176                 throw new CommandError("Could not find a value for the database {$name}");
177             }
178         }
179
180         $errors = "";
181         $hasOutput = false;
182         $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
183         $dumpTempFileResource = fopen($dumpTempFile, 'w');
184
185         try {
186             (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
187                 ->withTimeout(240)
188                 ->withIdleTimeout(15)
189                 ->runWithoutOutputCallbacks([
190                     '-h', $dbOptions['host'],
191                     '-u', $dbOptions['username'],
192                     '-p' . $dbOptions['password'],
193                     '--single-transaction',
194                     '--no-tablespaces',
195                     $dbOptions['database'],
196                 ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
197                     fwrite($dumpTempFileResource, $data);
198                     $hasOutput = true;
199                 }, function ($error) use (&$errors) {
200                     $errors .= $error . "\n";
201                 });
202         } catch (\Exception $exception) {
203             fclose($dumpTempFileResource);
204             unlink($dumpTempFile);
205             if ($exception instanceof ProcessTimedOutException) {
206                 if (!$hasOutput) {
207                     throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
208                 } else {
209                     throw new CommandError("mysqldump operation timed-out after data was received.");
210                 }
211             }
212             throw new CommandError($exception->getMessage());
213         }
214
215         fclose($dumpTempFileResource);
216
217         if ($errors) {
218             unlink($dumpTempFile);
219             throw new CommandError("Failed mysqldump with errors:\n" . $errors);
220         }
221
222         return $dumpTempFile;
223     }
224 }