]> BookStack Code Mirror - system-cli/blob - src/Commands/RestoreCommand.php
Range of changes to MySQL execution
[system-cli] / src / Commands / RestoreCommand.php
1 <?php declare(strict_types=1);
2
3 namespace Cli\Commands;
4
5 use Cli\Services\AppLocator;
6 use Cli\Services\ArtisanRunner;
7 use Cli\Services\BackupZip;
8 use Cli\Services\Directories;
9 use Cli\Services\EnvironmentLoader;
10 use Cli\Services\InteractiveConsole;
11 use Cli\Services\MySqlRunner;
12 use Cli\Services\Paths;
13 use Cli\Services\ProgramRunner;
14 use Cli\Services\RequirementsValidator;
15 use Exception;
16 use Symfony\Component\Console\Command\Command;
17 use Symfony\Component\Console\Input\InputArgument;
18 use Symfony\Component\Console\Input\InputInterface;
19 use Symfony\Component\Console\Input\InputOption;
20 use Symfony\Component\Console\Output\OutputInterface;
21
22 class RestoreCommand extends Command
23 {
24     protected function configure(): void
25     {
26         $this->setName('restore');
27         $this->addArgument('backup-zip', InputArgument::REQUIRED, 'Path to the ZIP file containing your backup.');
28         $this->setDescription('Restore data and files from a backup ZIP file.');
29         $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to restore into', '');
30     }
31
32     /**
33      * @throws CommandError
34      * @throws Exception
35      */
36     protected function execute(InputInterface $input, OutputInterface $output): int
37     {
38         $interactions = new InteractiveConsole($this->getHelper('question'), $input, $output);
39
40         $output->writeln("<warn>Warning!</warn>");
41         $output->writeln("<warn>- A restore operation will overwrite and remove files & content from an existing instance.</warn>");
42         $output->writeln("<warn>- Any existing tables within the configured database will be dropped.</warn>");
43         $output->writeln("<warn>- You should only restore into an instance of the same or newer BookStack version.</warn>");
44         $output->writeln("<warn>- This command won't handle, restore or address any server configuration.</warn>");
45
46         $appDir = AppLocator::require($input->getOption('app-directory'));
47         $output->writeln("<info>Checking system requirements...</info>");
48         RequirementsValidator::validate();
49         (new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound();
50
51         $providedZipPath = $input->getArgument('backup-zip');
52         $zipPath = realpath($providedZipPath);
53         if (!$zipPath || !file_exists($zipPath)) {
54             $pathToDisplay = $zipPath ?: $providedZipPath;
55             throw new CommandError("Could not find ZIP file for restoration at provided path [{$pathToDisplay}].");
56         }
57
58         $zip = new BackupZip($zipPath);
59         $contents = $zip->getContentsOverview();
60
61         $output->writeln("\n<info>Contents found in the backup ZIP:</info>");
62         $hasContent = false;
63         foreach ($contents as $info) {
64             $output->writeln(($info['exists'] ? '✔ ' : '❌ ') . $info['desc']);
65             if ($info['exists']) {
66                 $hasContent = true;
67             }
68         }
69
70         if (!$hasContent) {
71             throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restorable content.");
72         }
73
74         $output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
75         $output->writeln("<warn>Existing content will be overwritten.</warn>");
76
77         if (!$interactions->confirm("Do you want to continue?")) {
78             $output->writeln("<info>Stopping restore operation.</info>");
79             return Command::SUCCESS;
80         }
81
82         $output->writeln("<info>Extracting ZIP into temporary directory...</info>");
83         $extractDir = Paths::join($appDir, 'restore-temp-' . time());
84         if (!mkdir($extractDir)) {
85             throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
86         }
87         $zip->extractInto($extractDir);
88
89         $envChanges = [];
90         if ($contents['env']['exists']) {
91             $output->writeln("<info>Restoring and merging .env file...</info>");
92             $envChanges = $this->restoreEnv($extractDir, $appDir, $output, $interactions);
93         }
94
95         $folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
96         foreach ($folderLocations as $folderSubPath) {
97             if ($contents[$folderSubPath]['exists']) {
98                 $output->writeln("<info>Restoring {$folderSubPath} folder...</info>");
99                 $this->restoreFolder($folderSubPath, $appDir, $extractDir);
100             }
101         }
102
103         $artisan = (new ArtisanRunner($appDir));
104         if ($contents['db']['exists']) {
105             $output->writeln("<info>Restoring database from SQL dump...</info>");
106             $this->restoreDatabase($appDir, $extractDir);
107
108             $output->writeln("<info>Running database migrations...</info>");
109             $artisan->run(['migrate', '--force']);
110         }
111
112         if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
113             $output->writeln("<info>App URL change made, updating database with URL change...</info>");
114             $artisan->run([
115                 'bookstack:update-url', '--force',
116                 $envChanges['old_url'], $envChanges['new_url'],
117             ]);
118         }
119
120         $output->writeln("<info>Clearing app caches...</info>");
121         $artisan->run(['cache:clear']);
122         $artisan->run(['config:clear']);
123         $artisan->run(['view:clear']);
124
125         $output->writeln("<info>Cleaning up extract directory...</info>");
126         Directories::delete($extractDir);
127
128         $output->writeln("<success>\nRestore operation complete!</success>");
129         $output->writeln("<info>You may need to fix file/folder permissions so that the webserver has</info>");
130         $output->writeln("<info>the required read/write access to the necessary directories & files.</info>");
131
132         return Command::SUCCESS;
133     }
134
135     protected function restoreEnv(string $extractDir, string $appDir, OutputInterface $output, InteractiveConsole $interactions): array
136     {
137         $oldEnv = EnvironmentLoader::loadMergedWithCurrentEnv($extractDir);
138         $currentEnv = EnvironmentLoader::load($appDir);
139         $envContents = file_get_contents(Paths::join($extractDir, '.env'));
140         $appEnvPath = Paths::real(Paths::join($appDir, '.env'));
141
142         $mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
143         $mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
144         if (!$mysqlOld->testConnection()) {
145             $currentWorking = $mysqlCurrent->testConnection();
146             if (!$currentWorking) {
147                 throw new CommandError("Could not find a working database configuration");
148             }
149
150             // Copy across new env details to old env
151             $currentEnvContents = file_get_contents($appEnvPath);
152             $currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
153                 return str_starts_with($line, 'DB_');
154             }));
155             $oldEnvLines = array_values(array_filter(explode("\n", $envContents), function (string $line) {
156                 return !str_starts_with($line, 'DB_');
157             }));
158             $envContents = implode("\n", [
159                 '# Database credentials merged from existing .env file',
160                 ...$currentEnvDbLines,
161                 ...$oldEnvLines
162             ]);
163             copy($appEnvPath, $appEnvPath . '.backup');
164         }
165
166         $oldUrl = $oldEnv['APP_URL'] ?? '';
167         $newUrl = $currentEnv['APP_URL'] ?? '';
168         $returnData = [
169             'old_url' => $oldUrl,
170             'new_url' => $oldUrl,
171         ];
172
173         if ($oldUrl !== $newUrl) {
174             $question = 'Found different APP_URL values, which would you like to use?';
175             $changedUrl = $interactions->choice($question, array_filter([$oldUrl, $newUrl]));
176             $envContents = preg_replace('/^APP_URL=.*?$/m', 'APP_URL="' . $changedUrl . '"', $envContents);
177             $returnData['new_url'] = $changedUrl;
178         }
179
180         file_put_contents($appEnvPath, $envContents);
181
182         return $returnData;
183     }
184
185     protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
186     {
187         $fullAppFolderPath = Paths::real(Paths::join($appDir, $folderSubPath));
188         Directories::delete($fullAppFolderPath);
189         Directories::move(Paths::join($extractDir, $folderSubPath), $fullAppFolderPath);
190     }
191
192     protected function restoreDatabase(string $appDir, string $extractDir): void
193     {
194         $dbDump = Paths::join($extractDir, 'db.sql');
195         $currentEnv = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
196         $mysql = MySqlRunner::fromEnvOptions($currentEnv);
197
198         // Drop existing tables
199         $dropSqlTempFile = tempnam(sys_get_temp_dir(), 'bs-cli-restore');
200         file_put_contents($dropSqlTempFile, $mysql->dropTablesSql());
201         $mysql->importSqlFile($dropSqlTempFile);
202
203         // Import MySQL dump
204         $mysql->importSqlFile($dbDump);
205     }
206 }