3 namespace Cli\Commands;
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;
21 class RestoreCommand extends Command
23 protected function configure(): void
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', '');
32 * @throws CommandError
34 protected function execute(InputInterface $input, OutputInterface $output): int
36 $interactions = new InteractiveConsole($this->getHelper('question'), $input, $output);
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>");
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();
49 $zipPath = realpath($input->getArgument('backup-zip'));
50 $zip = new BackupZip($zipPath);
51 // TODO - Fix folders not being picked up here:
52 $contents = $zip->getContentsOverview();
54 $output->writeln("\n<info>Contents found in the backup ZIP:</info>");
56 foreach ($contents as $info) {
57 $output->writeln(($info['exists'] ? '✔ ' : '❌ ') . $info['desc']);
58 if ($info['exists']) {
64 throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restore-able content.");
67 $output->writeln("<info>The checked elements will be restored into [{$appDir}].</info>");
68 $output->writeln("<info>Existing content may be overwritten.</info>");
70 if (!$interactions->confirm("Do you want to continue?")) {
71 $output->writeln("<info>Stopping restore operation.</info>");
72 return Command::SUCCESS;
75 $output->writeln("<info>Extracting ZIP into temporary directory...</info>");
76 $extractDir = $appDir . DIRECTORY_SEPARATOR . 'restore-temp-' . time();
77 if (!mkdir($extractDir)) {
78 throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
80 $zip->extractInto($extractDir);
82 if ($contents['env']['exists']) {
83 $output->writeln("<info>Restoring and merging .env file...</info>");
84 $this->restoreEnv($extractDir, $appDir);
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);
95 if ($contents['db']['exists']) {
96 $output->writeln("<info>Restoring database from SQL dump...</info>");
97 $this->restoreDatabase($appDir, $extractDir);
99 $output->writeln("<info>Running database migrations...</info>");
100 $artisan = (new ArtisanRunner($appDir));
101 $artisan->run(['migrate', '--force']);
104 // TODO - Handle change of URL?
105 // TODO - Update system URL (via BookStack artisan command) if
106 // there's been a change from old backup env
108 $output->writeln("<info>Clearing app caches...</info>");
109 $artisan->run(['cache:clear']);
110 $artisan->run(['config:clear']);
111 $artisan->run(['view:clear']);
113 $output->writeln("<info>Cleaning up extract directory...</info>");
114 $this->deleteDirectoryAndContents($extractDir);
116 $output->writeln("<info>\nRestore operation complete!</info>");
118 return Command::SUCCESS;
121 protected function restoreEnv(string $extractDir, string $appDir)
123 $oldEnv = EnvironmentLoader::load($extractDir);
124 $currentEnv = EnvironmentLoader::load($appDir);
125 $envContents = file_get_contents($extractDir . DIRECTORY_SEPARATOR . '.env');
126 $appEnvPath = $appDir . DIRECTORY_SEPARATOR . '.env';
128 $mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
129 $mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
130 if (!$mysqlOld->testConnection()) {
131 $currentWorking = $mysqlCurrent->testConnection();
132 if (!$currentWorking) {
133 throw new CommandError("Could not find a working database configuration");
136 // Copy across new env details to old env
137 $currentEnvContents = file_get_contents($appEnvPath);
138 $currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
139 return str_starts_with($line, 'DB_');
141 $oldEnvLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
142 return !str_starts_with($line, 'DB_');
144 $envContents = implode("\n", [
145 '# Database credentials merged from existing .env file',
146 ...$currentEnvDbLines,
149 copy($appEnvPath, $appEnvPath . '.backup');
152 file_put_contents($appDir . DIRECTORY_SEPARATOR . '.env', $envContents);
155 protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
157 $fullAppFolderPath = $appDir . DIRECTORY_SEPARATOR . $folderSubPath;
158 $this->deleteDirectoryAndContents($fullAppFolderPath);
159 rename($extractDir . DIRECTORY_SEPARATOR . $folderSubPath, $fullAppFolderPath);
162 protected function deleteDirectoryAndContents(string $dir)
164 $files = new RecursiveIteratorIterator(
165 new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
166 RecursiveIteratorIterator::CHILD_FIRST
169 foreach ($files as $fileinfo) {
170 $path = $fileinfo->getRealPath();
171 $fileinfo->isDir() ? rmdir($path) : unlink($path);
177 protected function restoreDatabase(string $appDir, string $extractDir): void
179 $dbDump = $extractDir . DIRECTORY_SEPARATOR . 'db.sql';
180 $currentEnv = EnvironmentLoader::load($appDir);
181 $mysql = MySqlRunner::fromEnvOptions($currentEnv);
182 $mysql->importSqlFile($dbDump);