1 <?php declare(strict_types=1);
3 namespace Cli\Commands;
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;
16 use RecursiveDirectoryIterator;
17 use RecursiveIteratorIterator;
18 use Symfony\Component\Console\Command\Command;
19 use Symfony\Component\Console\Input\InputArgument;
20 use Symfony\Component\Console\Input\InputInterface;
21 use Symfony\Component\Console\Input\InputOption;
22 use Symfony\Component\Console\Output\OutputInterface;
24 class RestoreCommand extends Command
26 protected function configure(): void
28 $this->setName('restore');
29 $this->addArgument('backup-zip', InputArgument::REQUIRED, 'Path to the ZIP file containing your backup.');
30 $this->setDescription('Restore data and files from a backup ZIP file.');
31 $this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to restore into', '');
35 * @throws CommandError
38 protected function execute(InputInterface $input, OutputInterface $output): int
40 $interactions = new InteractiveConsole($this->getHelper('question'), $input, $output);
42 $output->writeln("<warn>Warning!</warn>");
43 $output->writeln("<warn>- A restore operation will overwrite and remove files & content from an existing instance.</warn>");
44 $output->writeln("<warn>- Any existing tables within the configured database will be dropped.</warn>");
45 $output->writeln("<warn>- You should only restore into an instance of the same or newer BookStack version.</warn>");
46 $output->writeln("<warn>- This command won't handle, restore or address any server configuration.</warn>");
48 $appDir = AppLocator::require($input->getOption('app-directory'));
49 $output->writeln("<info>Checking system requirements...</info>");
50 RequirementsValidator::validate();
51 (new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound();
53 $zipPath = realpath($input->getArgument('backup-zip'));
54 $zip = new BackupZip($zipPath);
55 $contents = $zip->getContentsOverview();
57 $output->writeln("\n<info>Contents found in the backup ZIP:</info>");
59 foreach ($contents as $info) {
60 $output->writeln(($info['exists'] ? '✔ ' : '❌ ') . $info['desc']);
61 if ($info['exists']) {
67 throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restorable content.");
70 $output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
71 $output->writeln("<warn>Existing content will be overwritten.</warn>");
73 if (!$interactions->confirm("Do you want to continue?")) {
74 $output->writeln("<info>Stopping restore operation.</info>");
75 return Command::SUCCESS;
78 $output->writeln("<info>Extracting ZIP into temporary directory...</info>");
79 $extractDir = Paths::join($appDir, 'restore-temp-' . time());
80 if (!mkdir($extractDir)) {
81 throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
83 $zip->extractInto($extractDir);
86 if ($contents['env']['exists']) {
87 $output->writeln("<info>Restoring and merging .env file...</info>");
88 $envChanges = $this->restoreEnv($extractDir, $appDir, $output, $interactions);
91 $folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
92 foreach ($folderLocations as $folderSubPath) {
93 if ($contents[$folderSubPath]['exists']) {
94 $output->writeln("<info>Restoring {$folderSubPath} folder...</info>");
95 $this->restoreFolder($folderSubPath, $appDir, $extractDir);
99 $artisan = (new ArtisanRunner($appDir));
100 if ($contents['db']['exists']) {
101 $output->writeln("<info>Restoring database from SQL dump...</info>");
102 $this->restoreDatabase($appDir, $extractDir);
104 $output->writeln("<info>Running database migrations...</info>");
105 $artisan->run(['migrate', '--force']);
108 if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
109 $output->writeln("<info>App URL change made, Updating database with URL change...</info>");
111 'bookstack:update-url',
112 $envChanges['old_url'], $envChanges['new_url'],
116 $output->writeln("<info>Clearing app caches...</info>");
117 $artisan->run(['cache:clear']);
118 $artisan->run(['config:clear']);
119 $artisan->run(['view:clear']);
121 $output->writeln("<info>Cleaning up extract directory...</info>");
122 Directories::delete($extractDir);
124 $output->writeln("<success>\nRestore operation complete!</success>");
125 $output->writeln("<info>You may need to fix file/folder permissions so that the webserver has</info>");
126 $output->writeln("<info>the required read/write access to the necessary directories & files.</info>");
128 return Command::SUCCESS;
131 protected function restoreEnv(string $extractDir, string $appDir, OutputInterface $output, InteractiveConsole $interactions): array
133 $oldEnv = EnvironmentLoader::load($extractDir);
134 $currentEnv = EnvironmentLoader::load($appDir);
135 $envContents = file_get_contents(Paths::join($extractDir, '.env'));
136 $appEnvPath = Paths::real(Paths::join($appDir, '.env'));
138 $mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
139 $mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
140 if (!$mysqlOld->testConnection()) {
141 $currentWorking = $mysqlCurrent->testConnection();
142 if (!$currentWorking) {
143 throw new CommandError("Could not find a working database configuration");
146 // Copy across new env details to old env
147 $currentEnvContents = file_get_contents($appEnvPath);
148 $currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
149 return str_starts_with($line, 'DB_');
151 $oldEnvLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
152 return !str_starts_with($line, 'DB_');
154 $envContents = implode("\n", [
155 '# Database credentials merged from existing .env file',
156 ...$currentEnvDbLines,
159 copy($appEnvPath, $appEnvPath . '.backup');
162 $oldUrl = $oldEnv['APP_URL'] ?? '';
163 $newUrl = $currentEnv['APP_URL'] ?? '';
165 'old_url' => $oldUrl,
166 'new_url' => $oldUrl,
169 if ($oldUrl !== $newUrl) {
170 $output->writeln("Found different APP_URL values:");
171 $changedUrl = $interactions->choice('Which would you like to use?', array_filter([$oldUrl, $newUrl]));
172 $envContents = preg_replace('/^APP_URL=.*?$/', 'APP_URL="' . $changedUrl . '"', $envContents);
173 $returnData['new_url'] = $changedUrl;
176 file_put_contents($appEnvPath, $envContents);
181 protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
183 $fullAppFolderPath = Paths::real(Paths::join($appDir, $folderSubPath));
184 Directories::delete($fullAppFolderPath);
185 Directories::move(Paths::join($extractDir, $folderSubPath), $fullAppFolderPath);
188 protected function restoreDatabase(string $appDir, string $extractDir): void
190 $dbDump = Paths::join($extractDir, 'db.sql');
191 $currentEnv = EnvironmentLoader::load($appDir);
192 $mysql = MySqlRunner::fromEnvOptions($currentEnv);
194 // Drop existing tables
195 $dropSqlTempFile = tempnam(sys_get_temp_dir(), 'bs-cli-restore');
196 file_put_contents($dropSqlTempFile, $mysql->dropTablesSql());
197 $mysql->importSqlFile($dropSqlTempFile);
200 $mysql->importSqlFile($dbDump);