3 namespace Cli\Commands;
5 use Cli\Services\ProgramRunner;
6 use Minicli\Command\CommandCall;
7 use RecursiveDirectoryIterator;
9 use Symfony\Component\Process\Exception\ProcessTimedOutException;
12 final class BackupCommand
14 public function __construct(
15 protected string $appDir
20 * @throws CommandError
22 public function handle(CommandCall $input)
24 $this->ensureRequiredExtensionInstalled();
26 $handleDatabase = !$input->hasFlag('no-database');
27 $handleUploads = !$input->hasFlag('no-uploads');
28 $handleThemes = !$input->hasFlag('no-themes');
29 $suggestedOutPath = $input->subcommand;
30 if ($suggestedOutPath === 'default') {
31 $suggestedOutPath = '';
34 $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
36 // Create a new ZIP file
37 $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');
54 echo "Adding BookStack upload folders to backup archive...\n";
55 $this->addUploadFoldersToZip($zip);
59 echo "Adding BookStack theme folders to backup archive...\n";
60 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
63 // Close off our zip and move it to the required location
65 // Delete our temporary DB dump file if exists. Must be done after zip close.
67 unlink($dumpTempFile);
69 // Move the zip into the target location
70 rename($zipTempFile, $zipOutFile);
73 echo "Backup finished.\nOutput ZIP saved to: {$zipOutFile}\n";
77 * Ensure the required PHP extensions are installed for this command.
78 * @throws CommandError
80 protected function ensureRequiredExtensionInstalled(): void
82 if (!extension_loaded('zip')) {
83 throw new CommandError('The "zip" PHP extension is required to run this command');
88 * Build a full zip path from the given suggestion, which may be empty,
89 * a path to a folder, or a path to a file in relative or absolute form.
90 * @throws CommandError
92 protected function buildZipFilePath(string $suggestedOutPath): string
94 $zipDir = getcwd() ?: $this->appDir;
95 $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
97 if ($suggestedOutPath) {
98 if (is_dir($suggestedOutPath)) {
99 $zipDir = realpath($suggestedOutPath);
100 } else if (is_dir(dirname($suggestedOutPath))) {
101 $zipDir = realpath(dirname($suggestedOutPath));
102 $zipName = basename($suggestedOutPath);
104 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
108 $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
110 if (file_exists($fullPath)) {
111 throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
118 * Add app-relative upload folders to the provided zip archive.
119 * Will recursively go through all directories to add all files.
121 protected function addUploadFoldersToZip(ZipArchive $zip): void
123 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'public', 'uploads']), 'public/uploads');
124 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'storage', 'uploads']), 'storage/uploads');
128 * Recursively add all contents of the given dirPath to the provided zip file
129 * with a zip location of the targetZipPath.
131 protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
133 $dirIter = new RecursiveDirectoryIterator($dirPath);
134 $fileIter = new \RecursiveIteratorIterator($dirIter);
135 /** @var SplFileInfo $file */
136 foreach ($fileIter as $file) {
137 if (!$file->isDir()) {
138 $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
144 * Create a database dump and return the path to the dumped SQL output.
145 * @throws CommandError
147 protected function createDatabaseDump(): string
150 'host' => ($_SERVER['DB_HOST'] ?? ''),
151 'username' => ($_SERVER['DB_USERNAME'] ?? ''),
152 'password' => ($_SERVER['DB_PASSWORD'] ?? ''),
153 'database' => ($_SERVER['DB_DATABASE'] ?? ''),
156 $port = $_SERVER['DB_PORT'] ?? '';
158 $dbOptions['host'] .= ':' . $port;
161 foreach ($dbOptions as $name => $option) {
163 throw new CommandError("Could not find a value for the database {$name}");
169 $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
170 $dumpTempFileResource = fopen($dumpTempFile, 'w');
173 (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
175 ->withIdleTimeout(15)
176 ->runWithoutOutputCallbacks([
177 '-h', $dbOptions['host'],
178 '-u', $dbOptions['username'],
179 '-p' . $dbOptions['password'],
180 '--single-transaction',
182 $dbOptions['database'],
183 ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
184 fwrite($dumpTempFileResource, $data);
186 }, function ($error) use (&$errors) {
187 $errors .= $error . "\n";
189 } catch (\Exception $exception) {
190 fclose($dumpTempFileResource);
191 unlink($dumpTempFile);
192 if ($exception instanceof ProcessTimedOutException) {
194 throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
196 throw new CommandError("mysqldump operation timed-out after data was received.");
199 throw new CommandError($exception->getMessage());
202 fclose($dumpTempFileResource);
205 unlink($dumpTempFile);
206 throw new CommandError("Failed mysqldump with errors:\n" . $errors);
209 return $dumpTempFile;