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