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