]> BookStack Code Mirror - system-cli/blob - src/Commands/RestoreCommand.php
Made changes based upon freebsd/openbsd testing
[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\ProgramRunner;
12 use Cli\Services\RequirementsValidator;
13 use Exception;
14 use RecursiveDirectoryIterator;
15 use RecursiveIteratorIterator;
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         $zipPath = realpath($input->getArgument('backup-zip'));
52         $zip = new BackupZip($zipPath);
53         $contents = $zip->getContentsOverview();
54
55         $output->writeln("\n<info>Contents found in the backup ZIP:</info>");
56         $hasContent = false;
57         foreach ($contents as $info) {
58             $output->writeln(($info['exists'] ? '✔ ' : '❌ ') . $info['desc']);
59             if ($info['exists']) {
60                 $hasContent = true;
61             }
62         }
63
64         if (!$hasContent) {
65             throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restorable content.");
66         }
67
68         $output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
69         $output->writeln("<warn>Existing content will be overwritten.</warn>");
70
71         if (!$interactions->confirm("Do you want to continue?")) {
72             $output->writeln("<info>Stopping restore operation.</info>");
73             return Command::SUCCESS;
74         }
75
76         $output->writeln("<info>Extracting ZIP into temporary directory...</info>");
77         $extractDir = $appDir . DIRECTORY_SEPARATOR . 'restore-temp-' . time();
78         if (!mkdir($extractDir)) {
79             throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
80         }
81         $zip->extractInto($extractDir);
82
83         $envChanges = [];
84         if ($contents['env']['exists']) {
85             $output->writeln("<info>Restoring and merging .env file...</info>");
86             $envChanges = $this->restoreEnv($extractDir, $appDir, $output, $interactions);
87         }
88
89         $folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
90         foreach ($folderLocations as $folderSubPath) {
91             if ($contents[$folderSubPath]['exists']) {
92                 $output->writeln("<info>Restoring {$folderSubPath} folder...</info>");
93                 $this->restoreFolder($folderSubPath, $appDir, $extractDir);
94             }
95         }
96
97         $artisan = (new ArtisanRunner($appDir));
98         if ($contents['db']['exists']) {
99             $output->writeln("<info>Restoring database from SQL dump...</info>");
100             $this->restoreDatabase($appDir, $extractDir);
101
102             $output->writeln("<info>Running database migrations...</info>");
103             $artisan->run(['migrate', '--force']);
104         }
105
106         if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
107             $output->writeln("<info>App URL change made, Updating database with URL change...</info>");
108             $artisan->run([
109                 'bookstack:update-url',
110                 $envChanges['old_url'], $envChanges['new_url'],
111             ]);
112         }
113
114         $output->writeln("<info>Clearing app caches...</info>");
115         $artisan->run(['cache:clear']);
116         $artisan->run(['config:clear']);
117         $artisan->run(['view:clear']);
118
119         $output->writeln("<info>Cleaning up extract directory...</info>");
120         $this->deleteDirectoryAndContents($extractDir);
121
122         $output->writeln("<success>\nRestore operation complete!</success>");
123
124         return Command::SUCCESS;
125     }
126
127     protected function restoreEnv(string $extractDir, string $appDir, OutputInterface $output, InteractiveConsole $interactions): array
128     {
129         $oldEnv = EnvironmentLoader::load($extractDir);
130         $currentEnv = EnvironmentLoader::load($appDir);
131         $envContents = file_get_contents($extractDir . DIRECTORY_SEPARATOR . '.env');
132         $appEnvPath = $appDir . DIRECTORY_SEPARATOR . '.env';
133
134         $mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
135         $mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
136         if (!$mysqlOld->testConnection()) {
137             $currentWorking = $mysqlCurrent->testConnection();
138             if (!$currentWorking) {
139                 throw new CommandError("Could not find a working database configuration");
140             }
141
142             // Copy across new env details to old env
143             $currentEnvContents = file_get_contents($appEnvPath);
144             $currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
145                 return str_starts_with($line, 'DB_');
146             }));
147             $oldEnvLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
148                 return !str_starts_with($line, 'DB_');
149             }));
150             $envContents = implode("\n", [
151                 '# Database credentials merged from existing .env file',
152                 ...$currentEnvDbLines,
153                 ...$oldEnvLines
154             ]);
155             copy($appEnvPath, $appEnvPath . '.backup');
156         }
157
158         $oldUrl = $oldEnv['APP_URL'] ?? '';
159         $newUrl = $currentEnv['APP_URL'] ?? '';
160         $returnData = [
161             'old_url' => $oldUrl,
162             'new_url' => $oldUrl,
163         ];
164
165         if ($oldUrl !== $newUrl) {
166             $output->writeln("Found different APP_URL values:");
167             $changedUrl = $interactions->choice('Which would you like to use?', array_filter([$oldUrl, $newUrl]));
168             $envContents = preg_replace('/^APP_URL=.*?$/', 'APP_URL="' . $changedUrl . '"', $envContents);
169             $returnData['new_url'] = $changedUrl;
170         }
171
172         file_put_contents($appDir . DIRECTORY_SEPARATOR . '.env', $envContents);
173
174         return $returnData;
175     }
176
177     protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
178     {
179         $fullAppFolderPath = $appDir . DIRECTORY_SEPARATOR . $folderSubPath;
180         $this->deleteDirectoryAndContents($fullAppFolderPath);
181         rename($extractDir . DIRECTORY_SEPARATOR . $folderSubPath, $fullAppFolderPath);
182     }
183
184     protected function deleteDirectoryAndContents(string $dir): void
185     {
186         $files = new RecursiveIteratorIterator(
187             new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
188             RecursiveIteratorIterator::CHILD_FIRST
189         );
190
191         foreach ($files as $fileinfo) {
192             $path = $fileinfo->getRealPath();
193             $fileinfo->isDir() ? rmdir($path) : unlink($path);
194         }
195
196         rmdir($dir);
197     }
198
199     protected function restoreDatabase(string $appDir, string $extractDir): void
200     {
201         $dbDump = $extractDir . DIRECTORY_SEPARATOR . 'db.sql';
202         $currentEnv = EnvironmentLoader::load($appDir);
203         $mysql = MySqlRunner::fromEnvOptions($currentEnv);
204
205         // Drop existing tables
206         $dropSqlTempFile = tempnam(sys_get_temp_dir(), 'bs-cli-restore');
207         file_put_contents($dropSqlTempFile, $mysql->dropTablesSql());
208         $mysql->importSqlFile($dropSqlTempFile);
209
210         // Import MySQL dump
211         $mysql->importSqlFile($dbDump);
212     }
213 }