]> BookStack Code Mirror - system-cli/blob - scripts/Commands/BackupCommand.php
d41b36d4d5d82702671961f8c0f8d58e1e47b20e
[system-cli] / scripts / Commands / BackupCommand.php
1 <?php
2
3 namespace Cli\Commands;
4
5 use Cli\Services\ProgramRunner;
6 use Minicli\Command\CommandCall;
7 use RecursiveDirectoryIterator;
8 use SplFileInfo;
9 use Symfony\Component\Process\Exception\ProcessTimedOutException;
10 use ZipArchive;
11
12 final class BackupCommand
13 {
14     public function __construct(
15         protected string $appDir
16     ) {
17     }
18
19     /**
20      * @throws CommandError
21      */
22     public function handle(CommandCall $input)
23     {
24         $this->ensureRequiredExtensionInstalled();
25
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 = '';
32         }
33
34         $zipOutFile = $this->buildZipFilePath($suggestedOutPath);
35
36         // Create a new ZIP file
37         $zipTempFile = tempnam(sys_get_temp_dir(), 'bsbackup');
38         $dumpTempFile = '';
39         $zip = new ZipArchive();
40         $zip->open($zipTempFile, ZipArchive::CREATE);
41
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');
45
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         }
52
53         if ($handleUploads) {
54             echo "Adding BookStack upload folders to backup archive...\n";
55             $this->addUploadFoldersToZip($zip);
56         }
57
58         if ($handleThemes) {
59             echo "Adding BookStack theme folders to backup archive...\n";
60             $this->addFolderToZipRecursive($zip, implode(DIRECTORY_SEPARATOR, [$this->appDir, 'themes']), 'themes');
61         }
62
63         // Close off our zip and move it to the required location
64         $zip->close();
65         // Delete our temporary DB dump file if exists. Must be done after zip close.
66         if ($dumpTempFile) {
67             unlink($dumpTempFile);
68         }
69         // Move the zip into the target location
70         rename($zipTempFile, $zipOutFile);
71
72         // Announce end
73         echo "Backup finished.\nOutput ZIP saved to: {$zipOutFile}\n";
74     }
75
76     /**
77      * Ensure the required PHP extensions are installed for this command.
78      * @throws CommandError
79      */
80     protected function ensureRequiredExtensionInstalled(): void
81     {
82         if (!extension_loaded('zip')) {
83             throw new CommandError('The "zip" PHP extension is required to run this command');
84         }
85     }
86
87     /**
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
91      */
92     protected function buildZipFilePath(string $suggestedOutPath): string
93     {
94         $zipDir = getcwd() ?: $this->appDir;
95         $zipName = "bookstack-backup-" . date('Y-m-d-His') . '.zip';
96
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);
103             } else {
104                 throw new CommandError("Could not resolve provided [{$suggestedOutPath}] path to an existing folder.");
105             }
106         }
107
108         $fullPath = $zipDir . DIRECTORY_SEPARATOR . $zipName;
109
110         if (file_exists($fullPath)) {
111             throw new CommandError("Target ZIP output location at [{$fullPath}] already exists.");
112         }
113
114         return $fullPath;
115     }
116
117     /**
118      * Add app-relative upload folders to the provided zip archive.
119      * Will recursively go through all directories to add all files.
120      */
121     protected function addUploadFoldersToZip(ZipArchive $zip): void
122     {
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');
125     }
126
127     /**
128      * Recursively add all contents of the given dirPath to the provided zip file
129      * with a zip location of the targetZipPath.
130      */
131     protected function addFolderToZipRecursive(ZipArchive $zip, string $dirPath, string $targetZipPath): void
132     {
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());
139             }
140         }
141     }
142
143     /**
144      * Create a database dump and return the path to the dumped SQL output.
145      * @throws CommandError
146      */
147     protected function createDatabaseDump(): string
148     {
149         $dbOptions = [
150             'host' => ($_SERVER['DB_HOST'] ?? ''),
151             'username' => ($_SERVER['DB_USERNAME'] ?? ''),
152             'password' => ($_SERVER['DB_PASSWORD'] ?? ''),
153             'database' => ($_SERVER['DB_DATABASE'] ?? ''),
154         ];
155
156         $port = $_SERVER['DB_PORT'] ?? '';
157         if ($port) {
158             $dbOptions['host'] .= ':' . $port;
159         }
160
161         foreach ($dbOptions as $name => $option) {
162             if (!$option) {
163                 throw new CommandError("Could not find a value for the database {$name}");
164             }
165         }
166
167         $errors = "";
168         $hasOutput = false;
169         $dumpTempFile = tempnam(sys_get_temp_dir(), 'bsdbdump');
170         $dumpTempFileResource = fopen($dumpTempFile, 'w');
171
172         try {
173             (new ProgramRunner('mysqldump', '/usr/bin/mysqldump'))
174                 ->withTimeout(240)
175                 ->withIdleTimeout(15)
176                 ->runWithoutOutputCallbacks([
177                     '-h', $dbOptions['host'],
178                     '-u', $dbOptions['username'],
179                     '-p' . $dbOptions['password'],
180                     '--single-transaction',
181                     '--no-tablespaces',
182                     $dbOptions['database'],
183                 ], function ($data) use (&$dumpTempFileResource, &$hasOutput) {
184                     fwrite($dumpTempFileResource, $data);
185                     $hasOutput = true;
186                 }, function ($error) use (&$errors) {
187                     $errors .= $error . "\n";
188                 });
189         } catch (\Exception $exception) {
190             fclose($dumpTempFileResource);
191             unlink($dumpTempFile);
192             if ($exception instanceof ProcessTimedOutException) {
193                 if (!$hasOutput) {
194                     throw new CommandError("mysqldump operation timed-out.\nNo data has been received so the connection to your database may have failed.");
195                 } else {
196                     throw new CommandError("mysqldump operation timed-out after data was received.");
197                 }
198             }
199             throw new CommandError($exception->getMessage());
200         }
201
202         fclose($dumpTempFileResource);
203
204         if ($errors) {
205             unlink($dumpTempFile);
206             throw new CommandError("Failed mysqldump with errors:\n" . $errors);
207         }
208
209         return $dumpTempFile;
210     }
211 }