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 RecursiveDirectoryIterator;
11 use Symfony\Component\Console\Command\Command;
12 use Symfony\Component\Console\Input\InputArgument;
13 use Symfony\Component\Console\Input\InputInterface;
14 use Symfony\Component\Console\Input\InputOption;
15 use Symfony\Component\Console\Output\OutputInterface;
18 final class BackupCommand extends Command
20 protected function configure(): void
22 $this->setName('backup');
23 $this->setDescription('Backup a BookStack installation to a single compressed ZIP file.');
24 $this->addArgument('backup-path', InputArgument::OPTIONAL, 'Outfile file or directory to store the resulting backup file.', '');
25 $this->addOption('no-database', null, InputOption::VALUE_NONE, "Skip adding a database dump to the backup");
26 $this->addOption('no-uploads', null, InputOption::VALUE_NONE, "Skip adding uploaded files to the backup");
27 $this->addOption('no-themes', null, InputOption::VALUE_NONE, "Skip adding the themes folder to the backup");
28 $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to backup', '');
32 * @throws CommandError
34 protected function execute(InputInterface $input, OutputInterface $output): int
36 $appDir = AppLocator::require($input->getOption('app-directory'));
37 $output->writeln("<info>Checking system requirements...</info>");
38 $this->ensureRequiredExtensionInstalled();
40 $handleDatabase = !$input->getOption('no-database');
41 $handleUploads = !$input->getOption('no-uploads');
42 $handleThemes = !$input->getOption('no-themes');
43 $suggestedOutPath = $input->getArgument('backup-path');
45 $zipOutFile = $this->buildZipFilePath($suggestedOutPath, $appDir);
47 // Create a new ZIP file
48 $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
50 $zip = new ZipArchive();
51 $zip->open($zipTempFile, ZipArchive::CREATE);
53 // Add default files (.env config file and this CLI if existing)
54 $zip->addFile(Paths::join($appDir, '.env'), '.env');
55 $cliPath = Paths::join($appDir, 'bookstack-system-cli');
56 if (file_exists($cliPath)) {
57 $zip->addFile($cliPath, 'bookstack-system-cli');
60 if ($handleDatabase) {
61 $output->writeln("<info>Dumping the database via mysqldump...</info>");
62 $dumpTempFile = $this->createDatabaseDump($appDir);
63 $output->writeln("<info>Adding database dump to backup archive...</info>");
64 $zip->addFile($dumpTempFile, 'db.sql');
68 $output->writeln("<info>Adding BookStack upload folders to backup archive...</info>");
69 $this->addUploadFoldersToZip($zip, $appDir);
73 $output->writeln("<info>Adding BookStack theme folders to backup archive...</info>");
74 $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'themes'), 'themes');
77 // Close off our zip and move it to the required location
79 // Delete our temporary DB dump file if exists. Must be done after zip close.
81 unlink($dumpTempFile);
83 // Move the zip into the target location
84 rename($zipTempFile, $zipOutFile);
87 $output->writeln("<info>Backup finished.</info>");
88 $output->writeln("Output ZIP saved to: {$zipOutFile}");
90 return Command::SUCCESS;
94 * Ensure the required PHP extensions are installed for this command.
95 * @throws CommandError
97 protected function ensureRequiredExtensionInstalled(): void
99 if (!extension_loaded('zip')) {
100 throw new CommandError('The "zip" PHP extension is required to run this command');
105 * Build a full zip path from the given suggestion, which may be empty,
106 * a path to a folder, or a path to a file in relative or absolute form.
107 * Targets the <app>/backups directory by default if existing, otherwise <app>.
108 * @throws CommandError
110 protected function buildZipFilePath(string $suggestedOutPath, string $appDir): string
112 $suggestedOutPath = Paths::resolve($suggestedOutPath);
113 $zipDir = Paths::join($appDir, 'backups');
114 $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
116 if ($suggestedOutPath) {
117 if (is_dir($suggestedOutPath)) {
118 $zipDir = realpath($suggestedOutPath);
119 } else if (is_dir(dirname($suggestedOutPath))) {
120 $zipDir = realpath(dirname($suggestedOutPath));
121 $zipName = basename($suggestedOutPath);
123 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
126 if (!is_dir($zipDir)) {
131 $fullPath = Paths::join($zipDir, $zipName);
133 if (file_exists($fullPath)) {
134 throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
135 } else if (!is_dir($zipDir)) {
136 throw new CommandError("Target ZIP output directory [{$fullPath}] could not be found.");
137 } else if (!is_writable($zipDir)) {
138 throw new CommandError("Target ZIP output directory [{$fullPath}] is not writable.");
145 * Add app-relative upload folders to the provided zip archive.
146 * Will recursively go through all directories to add all files.
148 protected function addUploadFoldersToZip(ZipArchive $zip, string $appDir): void
150 $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'public', 'uploads'), 'public/uploads');
151 $this->addFolderToZipRecursive($zip, Paths::join($appDir, 'storage', 'uploads'), 'storage/uploads');
155 * Recursively add all contents of the given dirPath to the provided zip file
156 * with a zip location of the targetZipPath.
158 protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
160 $dirIter = new RecursiveDirectoryIterator($dirPath);
161 $fileIter = new \RecursiveIteratorIterator($dirIter);
162 /** @var SplFileInfo $file */
163 foreach ($fileIter as $file) {
164 if (!$file->isDir()) {
165 $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
171 * Create a database dump and return the path to the dumped SQL output.
172 * @throws CommandError
174 protected function createDatabaseDump(string $appDir): string
176 $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
177 $mysql = MySqlRunner::fromEnvOptions($envOptions);
178 $mysql->ensureOptionsSet();
180 $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
182 $mysql->runDumpToFile($dumpTempFile);
183 } catch (\Exception $exception) {
184 unlink($dumpTempFile);
185 throw new CommandError($exception->getMessage());
188 return $dumpTempFile;