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