3 namespace Cli\Commands;
5 use Cli\Services\EnvironmentLoader;
6 use Cli\Services\ProgramRunner;
7 use RecursiveDirectoryIterator;
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;
16 final class BackupCommand extends Command
18 public function __construct(
19 protected string $appDir
21 parent::__construct();
24 protected function configure(): void
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");
35 * @throws CommandError
37 protected function execute(InputInterface $input, OutputInterface $output): int
39 $this->ensureRequiredExtensionInstalled();
41 $handleDatabase = !$input->getOption('no-database');
42 $handleUploads = !$input->getOption('no-uploads');
43 $handleThemes = !$input->getOption('no-themes');
44 $suggestedOutPath = $input->getArgument('backup-path');
46 $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
48 // Create a new ZIP file
49 $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
51 $zip = new ZipArchive();
52 $zip->open($zipTempFile, ZipArchive::CREATE);
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');
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');
66 $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
67 $this->addUploadFoldersToZip($zip);
71 $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
72 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
75 // Close off our zip and move it to the required location
77 // Delete our temporary DB dump file if exists. Must be done after zip close.
79 unlink($dumpTempFile);
81 // Move the zip into the target location
82 rename($zipTempFile, $zipOutFile);
85 $output->writeln("<info>Backup finished.</info>");
86 $output->writeln("Output ZIP saved to: {$zipOutFile}");
88 return Command::SUCCESS;
92 * Ensure the required PHP extensions are installed for this command.
93 * @throws CommandError
95 protected function ensureRequiredExtensionInstalled(): void
97 if (!extension_loaded('zip')) {
98 throw new CommandError('The "zip" PHP extension is required to run this command');
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
107 protected function buildZipFilePath(string $suggestedOutPath): string
109 $zipDir = getcwd() ?: $this->appDir;
110 $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
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);
119 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
123 $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
125 if (file_exists($fullPath)) {
126 throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
133 * Add app-relative upload folders to the provided zip archive.
134 * Will recursively go through all directories to add all files.
136 protected function addUploadFoldersToZip(ZipArchive $zip): void
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');
143 * Recursively add all contents of the given dirPath to the provided zip file
144 * with a zip location of the targetZipPath.
146 protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
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());
159 * Create a database dump and return the path to the dumped SQL output.
160 * @throws CommandError
162 protected function createDatabaseDump(): string
164 $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($this->appDir);
166 'host' => ($envOptions['DB_HOST'] ?? ''),
167 'username' => ($envOptions['DB_USERNAME'] ?? ''),
168 'password' => ($envOptions['DB_PASSWORD'] ?? ''),
169 'database' => ($envOptions['DB_DATABASE'] ?? ''),
172 $port = $envOptions['DB_PORT'] ?? '';
174 $dbOptions['host'] .= ':' . $port;
177 foreach ($dbOptions as $name => $option) {
179 throw new CommandError("Could not find a value for the database {$name}");
185 $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
186 $dumpTempFileResource = fopen($dumpTempFile, 'w');
189 (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
191 ->withIdleTimeout(15)
192 ->runWithoutOutputCallbacks([
193 '-h', $dbOptions['host'],
194 '-u', $dbOptions['username'],
195 '-p' . $dbOptions['password'],
196 '--single-transaction',
198 $dbOptions['database'],
199 ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
200 fwrite($dumpTempFileResource, $data);
202 }, function ($error) use (&$errors) {
203 $errors .= $error . "\n";
205 } catch (\Exception $exception) {
206 fclose($dumpTempFileResource);
207 unlink($dumpTempFile);
208 if ($exception instanceof ProcessTimedOutException) {
210 throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
212 throw new CommandError("mysqldump operation timed-out after data was received.");
215 throw new CommandError($exception->getMessage());
218 fclose($dumpTempFileResource);
221 unlink($dumpTempFile);
222 throw new CommandError("Failed mysqldump with errors:\n" . $errors);
225 return $dumpTempFile;