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