1 <?php declare(strict_types=1);
3 namespace Cli\Commands;
5 use Cli\Services\AppLocator;
6 use Cli\Services\EnvironmentLoader;
7 use Cli\Services\MySqlRunner;
8 use Cli\Services\Paths;
9 use FilesystemIterator;
10 use RecursiveDirectoryIterator;
12 use Symfony\Component\Console\Command\Command;
13 use Symfony\Component\Console\Input\InputArgument;
14 use Symfony\Component\Console\Input\InputInterface;
15 use Symfony\Component\Console\Input\InputOption;
16 use Symfony\Component\Console\Output\OutputInterface;
19 final class BackupCommand extends Command
21 protected function configure(): void
23 $this->setName('backup');
24 $this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
25 $this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup ZIP file.', '');
26 $this->addOption('no-database', null, InputOption::VALUE_NONE, "Skip adding a database dump to the backup");
27 $this->addOption('no-uploads', null, InputOption::VALUE_NONE, "Skip adding uploaded files to the backup");
28 $this->addOption('no-themes', null, InputOption::VALUE_NONE, "Skip adding the themes folder to the backup");
29 $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
33 * @throws CommandError
35 protected function execute(InputInterface $input, OutputInterface $output): int
37 $appDir = AppLocator::require($input->getOption('app-directory'));
38 $output->writeln("<info>Checking system requirements...</info>");
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, $appDir);
48 // Create a new ZIP file
49 $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
51 $zip = new ZipArchive();
52 $zip->open($zipTempFile, ZipArchive::OVERWRITE);
54 // Add default files (.env config file and this CLI if existing)
55 $zip->addFile(Paths::join($appDir, '.env'), '.env');
56 $cliPath = Paths::join($appDir, 'bookstack-system-cli');
57 if (file_exists($cliPath)) {
58 $zip->addFile($cliPath, 'bookstack-system-cli');
61 if ($handleDatabase) {
62 $output->writeln("<info>Dumping the database via mysqldump...</info>");
63 $dumpTempFile = $this->createDatabaseDump($appDir, $output);
64 $output->writeln("<info>Adding database dump to backup archive...</info>");
65 $zip->addFile($dumpTempFile, 'db.sql');
69 $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
70 $this->addUploadFoldersToZip($zip, $appDir);
74 $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
75 $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'themes'), 'themes');
78 $output->writeln("<info>Saving backup archive...</info>");
79 // Close off our zip and move it to the required location
81 // Delete our temporary DB dump file if exists. Must be done after zip close.
83 unlink($dumpTempFile);
85 // Move the zip into the target location
86 rename($zipTempFile, $zipOutFile);
89 $output->writeln("<success>Backup finished.</success>");
90 $output->writeln("Output ZIP saved to: {$zipOutFile}");
92 return Command::SUCCESS;
96 * Ensure the required PHP extensions are installed for this command.
97 * @throws CommandError
99 protected function ensureRequiredExtensionInstalled(): void
101 if (!extension_loaded('zip')) {
102 throw new CommandError('The "zip" PHP extension is required to run this command');
107 * Build a full zip path from the given suggestion, which may be empty,
108 * a path to a folder, or a path to a file in relative or absolute form.
109 * Targets the <app>/backups directory by default if existing, otherwise <app>.
110 * @throws CommandError
112 protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
114 $zipDir = Paths::join($appDir, 'storage', 'backups');
115 $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
117 if ($suggestedOutPath) {
118 $suggestedOutPath = Paths::resolve($suggestedOutPath);
119 if (is_dir($suggestedOutPath)) {
120 $zipDir = realpath($suggestedOutPath);
121 } else if (is_dir(dirname($suggestedOutPath))) {
122 $zipDir = realpath(dirname($suggestedOutPath));
123 $zipName = basename($suggestedOutPath);
125 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
128 if (!is_dir($zipDir)) {
133 $fullPath = Paths::join($zipDir, $zipName);
135 if (file_exists($fullPath)) {
136 throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
137 } else if (!is_dir($zipDir)) {
138 throw new CommandError("Target ZIP output directory [{$fullPath}] could not be found.");
139 } else if (!is_writable($zipDir)) {
140 throw new CommandError("Target ZIP output directory [{$fullPath}] is not writable.");
147 * Add app-relative upload folders to the provided zip archive.
148 * Will recursively go through all directories to add all files.
150 protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
152 $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'public', 'uploads'), 'public/uploads');
153 $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'storage', 'uploads'), 'storage/uploads');
157 * Recursively add all contents of the given dirPath to the provided zip file
158 * with a zip location of the targetZipPath.
160 protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
162 $dirIter = new RecursiveDirectoryIterator(
164 FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::FOLLOW_SYMLINKS
166 $fileIter = new \RecursiveIteratorIterator($dirIter);
167 /** @var SplFileInfo $file */
168 foreach ($fileIter as $file) {
169 if (!$file->isDir()) {
170 $zip->addFile($file->getRealPath(), $targetZipPath . '/' . $fileIter->getSubPathname());
176 * Create a database dump and return the path to the dumped SQL output.
177 * @throws CommandError
179 protected function createDatabaseDump(string $appDir, OutputInterface $output): string
181 $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
182 $mysql = MySqlRunner::fromEnvOptions($envOptions);
183 $mysql->ensureOptionsSet();
185 $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
187 $warnings = $mysql->runDumpToFile($dumpTempFile);
189 $output->writeln("<warn>Received warnings during mysqldump:\n{$warnings}</warn>");
191 } catch (\Exception $exception) {
192 unlink($dumpTempFile);
193 throw new CommandError($exception->getMessage());
196 return $dumpTempFile;