3 namespace Cli\Commands;
5 use Cli\Services\EnvironmentLoader;
6 use Cli\Services\ProgramRunner;
7 use Minicli\Command\CommandCall;
8 use RecursiveDirectoryIterator;
10 use Symfony\Component\Process\Exception\ProcessTimedOutException;
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');
40 $zip = new ZipArchive();
41 $zip->open($zipTempFile, ZipArchive::CREATE);
43 // Add default files (.env config file and this CLI)
44 $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . '.env', '.env');
45 $zip->addFile($this->appDir . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'run', 'run');
47 if ($handleDatabase) {
48 echo "Dumping the database via mysqldump...\n";
49 $dumpTempFile = $this->createDatabaseDump();
50 echo "Adding database dump to backup archive...\n";
51 $zip->addFile($dumpTempFile, 'db.sql');
55 echo "Adding BookStack upload folders to backup archive...\n";
56 $this->addUploadFoldersToZip($zip);
60 echo "Adding BookStack theme folders to backup archive...\n";
61 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
64 // Close off our zip and move it to the required location
66 // Delete our temporary DB dump file if exists. Must be done after zip close.
68 unlink($dumpTempFile);
70 // Move the zip into the target location
71 rename($zipTempFile, $zipOutFile);
74 echo "Backup finished.\nOutput ZIP saved to: {$zipOutFile}\n";
78 * Ensure the required PHP extensions are installed for this command.
79 * @throws CommandError
81 protected function ensureRequiredExtensionInstalled(): void
83 if (!extension_loaded('zip')) {
84 throw new CommandError('The "zip" PHP extension is required to run this command');
89 * Build a full zip path from the given suggestion, which may be empty,
90 * a path to a folder, or a path to a file in relative or absolute form.
91 * @throws CommandError
93 protected function buildZipFilePath(string $suggestedOutPath): string
95 $zipDir = getcwd() ?: $this->appDir;
96 $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
98 if ($suggestedOutPath) {
99 if (is_dir($suggestedOutPath)) {
100 $zipDir = realpath($suggestedOutPath);
101 } else if (is_dir(dirname($suggestedOutPath))) {
102 $zipDir = realpath(dirname($suggestedOutPath));
103 $zipName = basename($suggestedOutPath);
105 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
109 $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
111 if (file_exists($fullPath)) {
112 throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
119 * Add app-relative upload folders to the provided zip archive.
120 * Will recursively go through all directories to add all files.
122 protected function addUploadFoldersToZip(ZipArchive $zip): void
124 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'public', 'uploads']), 'public/uploads');
125 $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'storage', 'uploads']), 'storage/uploads');
129 * Recursively add all contents of the given dirPath to the provided zip file
130 * with a zip location of the targetZipPath.
132 protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
134 $dirIter = new RecursiveDirectoryIterator($dirPath);
135 $fileIter = new \RecursiveIteratorIterator($dirIter);
136 /** @var SplFileInfo $file */
137 foreach ($fileIter as $file) {
138 if (!$file->isDir()) {
139 $zip->addFile($file->getPathname(), $targetZipPath . '/' . $fileIter->getSubPathname());
145 * Create a database dump and return the path to the dumped SQL output.
146 * @throws CommandError
148 protected function createDatabaseDump(): string
150 $envOptions = EnvironmentLoader::loadMergedWithCurrentEnv($this->appDir);
152 'host' => ($envOptions['DB_HOST'] ?? ''),
153 'username' => ($envOptions['DB_USERNAME'] ?? ''),
154 'password' => ($envOptions['DB_PASSWORD'] ?? ''),
155 'database' => ($envOptions['DB_DATABASE'] ?? ''),
158 $port = $envOptions['DB_PORT'] ?? '';
160 $dbOptions['host'] .= ':' . $port;
163 foreach ($dbOptions as $name => $option) {
165 throw new CommandError("Could not find a value for the database {$name}");
171 $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
172 $dumpTempFileResource = fopen($dumpTempFile, 'w');
175 (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
177 ->withIdleTimeout(15)
178 ->runWithoutOutputCallbacks([
179 '-h', $dbOptions['host'],
180 '-u', $dbOptions['username'],
181 '-p' . $dbOptions['password'],
182 '--single-transaction',
184 $dbOptions['database'],
185 ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
186 fwrite($dumpTempFileResource, $data);
188 }, function ($error) use (&$errors) {
189 $errors .= $error . "\n";
191 } catch (\Exception $exception) {
192 fclose($dumpTempFileResource);
193 unlink($dumpTempFile);
194 if ($exception instanceof ProcessTimedOutException) {
196 throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
198 throw new CommandError("mysqldump operation timed-out after data was received.");
201 throw new CommandError($exception->getMessage());
204 fclose($dumpTempFileResource);
207 unlink($dumpTempFile);
208 throw new CommandError("Failed mysqldump with errors:\n" . $errors);
211 return $dumpTempFile;