3 namespace Cli\Commands;
5 use Minicli\Command\CommandCall;
6 use RecursiveDirectoryIterator;
8 use Symfony\Component\Process\Exception\ProcessTimedOutException;
9 use Symfony\Component\Process\ExecutableFinder;
10 use Symfony\Component\Process\Process;
13 final class BackupCommand
15 public function __construct(
16 protected string $appDir
21 * @throws CommandError
23 public function handle(CommandCall $input)
25 $this->ensureRequiredExtensionInstalled();
27 $handleDatabase = !$input->hasFlag('no-database');
28 $handleUploads = !$input->hasFlag('no-uploads');
29 $handleThemes = !$input->hasFlag('no-themes');
30 $suggestedOutPath = $input->subcommand;
31 if ($suggestedOutPath === 'default') {
32 $suggestedOutPath = '';
35 $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
37 // Create a new ZIP file
38 $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
39 $zip = new ZipArchive();
40 $zip->open($zipTempFile, ZipArchive::CREATE);
42 // Add default files (.env config file and this CLI)
43 $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . '.env', '.env');
44 $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
46 if ($handleDatabase) {
47 echo "Dumping the database via mysqldump...\n";
48 $dumpTempFile = $this->createDatabaseDump();
49 echo "Adding database dump to backup archive...\n";
50 $zip->addFile($dumpTempFile, 'db.sql');
51 // Delete our temporary DB dump file
52 unlink($dumpTempFile);
56 echo "Adding BookStack upload folders to backup archive...\n";
57 $this->addUploadFoldersToZip($zip);
61 echo "Adding BookStack theme folders to backup archive...\n";
62 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
65 // Close off our zip and move it to the required location
67 rename($zipTempFile, $zipOutFile);
69 // Announce end and display errors
70 echo "Backup finished.\nOutput ZIP saved to: {$zipOutFile}\n";
74 * Ensure the required PHP extensions are installed for this command.
75 * @throws CommandError
77 protected function ensureRequiredExtensionInstalled(): void
79 if (!extension_loaded('zip')) {
80 throw new CommandError('The "zip" PHP extension is required to run this command');
85 * Build a full zip path from the given suggestion, which may be empty,
86 * a path to a folder, or a path to a file in relative or absolute form.
87 * @throws CommandError
89 protected function buildZipFilePath(string $suggestedOutPath): string
91 $zipDir = getcwd() ?: $this->appDir;
92 $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
94 if ($suggestedOutPath) {
95 if (is_dir($suggestedOutPath)) {
96 $zipDir = realpath($suggestedOutPath);
97 } else if (is_dir(dirname($suggestedOutPath))) {
98 $zipDir = realpath(dirname($suggestedOutPath));
99 $zipName = basename($suggestedOutPath);
101 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
105 $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
107 if (file_exists($fullPath)) {
108 throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
115 * Add app-relative upload folders to the provided zip archive.
116 * Will recursively go through all directories to add all files.
118 protected function addUploadFoldersToZip(ZipArchive $zip): void
120 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'public', 'uploads']), 'public/uploads');
121 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'storage', 'uploads']), 'storage/uploads');
125 * Recursively add all contents of the given dirPath to the provided zip file
126 * with a zip location of the targetZipPath.
128 protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
130 $dirIter = new RecursiveDirectoryIterator($dirPath);
131 $fileIter = new \RecursiveIteratorIterator($dirIter);
132 /** @var SplFileInfo $file */
133 foreach ($fileIter as $file) {
134 if (!$file->isDir()) {
135 $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
141 * Create a database dump and return the path to the dumped SQL output.
142 * @throws CommandError
144 protected function createDatabaseDump(): string
147 'host' => ($_SERVER['DB_HOST'] ?? ''),
148 'username' => ($_SERVER['DB_USERNAME'] ?? ''),
149 'password' => ($_SERVER['DB_PASSWORD'] ?? ''),
150 'database' => ($_SERVER['DB_DATABASE'] ?? ''),
153 foreach ($dbOptions as $name => $option) {
155 throw new CommandError("Could not find a value for the database {$name}");
159 // Create a mysqldump for the BookStack database
160 $executableFinder = new ExecutableFinder();
161 $mysqldumpPath = $executableFinder->find('mysqldump', '/usr/bin/mysqldump');
163 if (!is_file($mysqldumpPath)) {
164 throw new CommandError('Could not locate "mysqldump" program');
167 $process = new Process([
169 '-h', $dbOptions['host'],
170 '-u', $dbOptions['username'],
171 '-p' . $dbOptions['password'],
172 '--single-transaction',
174 $dbOptions['database'],
176 $process->setTimeout(240);
177 $process->setIdleTimeout(5);
182 $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
183 $dumpTempFileResource = fopen($dumpTempFile, 'w');
185 foreach ($process as $type => $data) {
186 if ($process::OUT === $type) {
187 fwrite($dumpTempFileResource, $data);
189 } else { // $process::ERR === $type
190 $errors .= $data . "\n";
193 } catch (ProcessTimedOutException $timedOutException) {
194 fclose($dumpTempFileResource);
195 unlink($dumpTempFile);
197 throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
199 throw new CommandError("mysqldump operation timed-out after data was received.");
203 fclose($dumpTempFileResource);
206 unlink($dumpTempFile);
207 throw new CommandError("Failed mysqldump with errors:\n" . $errors);
210 return $dumpTempFile;