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