3 namespace Cli\Commands;
5 use Cli\Services\AppLocator;
6 use Cli\Services\EnvironmentLoader;
7 use Cli\Services\ProgramRunner;
8 use RecursiveDirectoryIterator;
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;
17 final class BackupCommand extends Command
19 protected function configure(): void
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");
30 * @throws CommandError
32 protected function execute(InputInterface $input, OutputInterface $output): int
34 $appDir = AppLocator::require($input->getOption('app-directory'));
35 $output->writeln("<info>Checking system requirements...</info>");
36 $this->ensureRequiredExtensionInstalled();
38 $handleDatabase = !$input->getOption('no-database');
39 $handleUploads = !$input->getOption('no-uploads');
40 $handleThemes = !$input->getOption('no-themes');
41 $suggestedOutPath = $input->getArgument('backup-path');
43 $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
45 // Create a new ZIP file
46 $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
48 $zip = new ZipArchive();
49 $zip->open($zipTempFile, ZipArchive::CREATE);
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');
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');
63 $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
64 $this->addUploadFoldersToZip($zip, $appDir);
68 $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
69 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'themes']), 'themes');
72 // Close off our zip and move it to the required location
74 // Delete our temporary DB dump file if exists. Must be done after zip close.
76 unlink($dumpTempFile);
78 // Move the zip into the target location
79 rename($zipTempFile, $zipOutFile);
82 $output->writeln("<info>Backup finished.</info>");
83 $output->writeln("Output ZIP saved to: {$zipOutFile}");
85 return Command::SUCCESS;
89 * Ensure the required PHP extensions are installed for this command.
90 * @throws CommandError
92 protected function ensureRequiredExtensionInstalled(): void
94 if (!extension_loaded('zip')) {
95 throw new CommandError('The "zip" PHP extension is required to run this command');
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
104 protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
106 $zipDir = getcwd() ?: $appDir;
107 $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
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);
116 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
120 $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
122 if (file_exists($fullPath)) {
123 throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
130 * Add app-relative upload folders to the provided zip archive.
131 * Will recursively go through all directories to add all files.
133 protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
135 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'public', 'uploads']), 'public/uploads');
136 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$appDir, 'storage', 'uploads']), 'storage/uploads');
140 * Recursively add all contents of the given dirPath to the provided zip file
141 * with a zip location of the targetZipPath.
143 protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
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());
156 * Create a database dump and return the path to the dumped SQL output.
157 * @throws CommandError
159 protected function createDatabaseDump(string $appDir): string
161 $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
163 'host' => ($envOptions['DB_HOST'] ?? ''),
164 'username' => ($envOptions['DB_USERNAME'] ?? ''),
165 'password' => ($envOptions['DB_PASSWORD'] ?? ''),
166 'database' => ($envOptions['DB_DATABASE'] ?? ''),
169 $port = $envOptions['DB_PORT'] ?? '';
171 $dbOptions['host'] .= ':' . $port;
174 foreach ($dbOptions as $name => $option) {
176 throw new CommandError("Could not find a value for the database {$name}");
182 $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
183 $dumpTempFileResource = fopen($dumpTempFile, 'w');
186 (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
188 ->withIdleTimeout(15)
189 ->runWithoutOutputCallbacks([
190 '-h', $dbOptions['host'],
191 '-u', $dbOptions['username'],
192 '-p' . $dbOptions['password'],
193 '--single-transaction',
195 $dbOptions['database'],
196 ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
197 fwrite($dumpTempFileResource, $data);
199 }, function ($error) use (&$errors) {
200 $errors .= $error . "\n";
202 } catch (\Exception $exception) {
203 fclose($dumpTempFileResource);
204 unlink($dumpTempFile);
205 if ($exception instanceof ProcessTimedOutException) {
207 throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
209 throw new CommandError("mysqldump operation timed-out after data was received.");
212 throw new CommandError($exception->getMessage());
215 fclose($dumpTempFileResource);
218 unlink($dumpTempFile);
219 throw new CommandError("Failed mysqldump with errors:\n" . $errors);
222 return $dumpTempFile;