setName('restore');
$this->addArgument('backup-zip', InputArgument::REQUIRED, 'Path to the ZIP file containing your backup.');
$this->setDescription('Restore data and files from a backup ZIP file.');
$this->addOption('app-directory', null, InputOption::VALUE_OPTIONAL, 'BookStack install directory to restore into', '');
}
/**
* @throws CommandError
* @throws Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$interactions = new InteractiveConsole($this->getHelper('question'), $input, $output);
$output->writeln("Warning!");
$output->writeln("- A restore operation will overwrite and remove files & content from an existing instance.");
$output->writeln("- Any existing tables within the configured database will be dropped.");
$output->writeln("- You should only restore into an instance of the same or newer BookStack version.");
$output->writeln("- This command won't handle, restore or address any server configuration.");
$appDir = AppLocator::require($input->getOption('app-directory'));
$output->writeln("Checking system requirements...");
RequirementsValidator::validate();
(new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound();
$providedZipPath = $input->getArgument('backup-zip');
$zipPath = realpath($providedZipPath);
if (!$zipPath || !file_exists($zipPath)) {
$pathToDisplay = $zipPath ?: $providedZipPath;
throw new CommandError("Could not find ZIP file for restoration at provided path [{$pathToDisplay}].");
}
$zip = new BackupZip($zipPath);
$contents = $zip->getContentsOverview();
$output->writeln("\nContents found in the backup ZIP:");
$hasContent = false;
foreach ($contents as $info) {
$output->writeln(($info['exists'] ? '✔ ' : '❌ ') . $info['desc']);
if ($info['exists']) {
$hasContent = true;
}
}
if (!$hasContent) {
throw new CommandError("Provided ZIP backup [{$zipPath}] does not have any expected restorable content.");
}
$output->writeln("The checked elements will be restored into [{$appDir}].");
$output->writeln("Existing content will be overwritten.");
if (!$interactions->confirm("Do you want to continue?")) {
$output->writeln("Stopping restore operation.");
return Command::SUCCESS;
}
$output->writeln("Extracting ZIP into temporary directory...");
$extractDir = Paths::join($appDir, 'restore-temp-' . time());
if (!mkdir($extractDir)) {
throw new CommandError("Could not create temporary extraction directory at [{$extractDir}].");
}
$zip->extractInto($extractDir);
$envChanges = [];
if ($contents['env']['exists']) {
$output->writeln("Restoring and merging .env file...");
$envChanges = $this->restoreEnv($extractDir, $appDir, $output, $interactions);
}
$folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
foreach ($folderLocations as $folderSubPath) {
if ($contents[$folderSubPath]['exists']) {
$output->writeln("Restoring {$folderSubPath} folder...");
$this->restoreFolder($folderSubPath, $appDir, $extractDir);
}
}
$artisan = (new ArtisanRunner($appDir));
if ($contents['db']['exists']) {
$output->writeln("Restoring database from SQL dump...");
$this->restoreDatabase($appDir, $extractDir);
$output->writeln("Running database migrations...");
$artisan->run(['migrate', '--force']);
}
if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
$output->writeln("App URL change made, updating database with URL change...");
$artisan->run([
'bookstack:update-url', '--force',
$envChanges['old_url'], $envChanges['new_url'],
]);
}
$output->writeln("Clearing app caches...");
$artisan->run(['cache:clear']);
$artisan->run(['config:clear']);
$artisan->run(['view:clear']);
$output->writeln("Cleaning up extract directory...");
Directories::delete($extractDir);
$output->writeln("\nRestore operation complete!");
$output->writeln("You may need to fix file/folder permissions so that the webserver has");
$output->writeln("the required read/write access to the necessary directories & files.");
return Command::SUCCESS;
}
protected function restoreEnv(string $extractDir, string $appDir, OutputInterface $output, InteractiveConsole $interactions): array
{
$oldEnv = EnvironmentLoader::loadMergedWithCurrentEnv($extractDir);
$currentEnv = EnvironmentLoader::load($appDir);
$envContents = file_get_contents(Paths::join($extractDir, '.env'));
$appEnvPath = Paths::real(Paths::join($appDir, '.env'));
$mysqlCurrent = MySqlRunner::fromEnvOptions($currentEnv);
$mysqlOld = MySqlRunner::fromEnvOptions($oldEnv);
if (!$mysqlOld->testConnection()) {
$currentWorking = $mysqlCurrent->testConnection();
if (!$currentWorking) {
throw new CommandError("Could not find a working database configuration");
}
// Copy across new env details to old env
$currentEnvContents = file_get_contents($appEnvPath);
$currentEnvDbLines = array_values(array_filter(explode("\n", $currentEnvContents), function (string $line) {
return str_starts_with($line, 'DB_');
}));
$oldEnvLines = array_values(array_filter(explode("\n", $envContents), function (string $line) {
return !str_starts_with($line, 'DB_');
}));
$envContents = implode("\n", [
'# Database credentials merged from existing .env file',
...$currentEnvDbLines,
...$oldEnvLines
]);
copy($appEnvPath, $appEnvPath . '.backup');
}
$oldUrl = $oldEnv['APP_URL'] ?? '';
$newUrl = $currentEnv['APP_URL'] ?? '';
$returnData = [
'old_url' => $oldUrl,
'new_url' => $oldUrl,
];
if ($oldUrl !== $newUrl) {
$question = 'Found different APP_URL values, which would you like to use?';
$changedUrl = $interactions->choice($question, array_filter([$oldUrl, $newUrl]));
$envContents = preg_replace('/^APP_URL=.*?$/m', 'APP_URL="' . $changedUrl . '"', $envContents);
$returnData['new_url'] = $changedUrl;
}
file_put_contents($appEnvPath, $envContents);
return $returnData;
}
protected function restoreFolder(string $folderSubPath, string $appDir, string $extractDir): void
{
$fullAppFolderPath = Paths::real(Paths::join($appDir, $folderSubPath));
Directories::delete($fullAppFolderPath);
Directories::move(Paths::join($extractDir, $folderSubPath), $fullAppFolderPath);
}
protected function restoreDatabase(string $appDir, string $extractDir): void
{
$dbDump = Paths::join($extractDir, 'db.sql');
$currentEnv = EnvironmentLoader::loadMergedWithCurrentEnv($appDir);
$mysql = MySqlRunner::fromEnvOptions($currentEnv);
// Drop existing tables
$dropSqlTempFile = tempnam(sys_get_temp_dir(), 'bs-cli-restore');
file_put_contents($dropSqlTempFile, $mysql->dropTablesSql());
$mysql->importSqlFile($dropSqlTempFile);
// Import MySQL dump
$mysql->importSqlFile($dbDump);
}
}