This repository was archived by the owner on Jul 26, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRestoreCommand.php
More file actions
200 lines (168 loc) · 8.4 KB
/
Copy pathRestoreCommand.php
File metadata and controls
200 lines (168 loc) · 8.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
<?php declare(strict_types=1);
namespace Cli\Commands;
use Cli\Services\AppLocator;
use Cli\Services\ArtisanRunner;
use Cli\Services\BackupZip;
use Cli\Services\Directories;
use Cli\Services\EnvironmentLoader;
use Cli\Services\InteractiveConsole;
use Cli\Services\MySqlRunner;
use Cli\Services\Paths;
use Cli\Services\ProgramRunner;
use Cli\Services\RequirementsValidator;
use Exception;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class RestoreCommand extends Command
{
protected function configure(): void
{
$this->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("<warn>Warning!</warn>");
$output->writeln("<warn>- A restore operation will overwrite and remove files & content from an existing instance.</warn>");
$output->writeln("<warn>- Any existing tables within the configured database will be dropped.</warn>");
$output->writeln("<warn>- You should only restore into an instance of the same or newer BookStack version.</warn>");
$output->writeln("<warn>- This command won't handle, restore or address any server configuration.</warn>");
$appDir = AppLocator::require($input->getOption('app-directory'));
$output->writeln("<info>Checking system requirements...</info>");
RequirementsValidator::validate();
(new ProgramRunner('mysql', '/usr/bin/mysql'))->ensureFound();
$zipPath = realpath($input->getArgument('backup-zip'));
$zip = new BackupZip($zipPath);
$contents = $zip->getContentsOverview();
$output->writeln("\n<info>Contents found in the backup ZIP:</info>");
$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("<info>The checked elements will be restored into [{$appDir}].</info>");
$output->writeln("<warn>Existing content will be overwritten.</warn>");
if (!$interactions->confirm("Do you want to continue?")) {
$output->writeln("<info>Stopping restore operation.</info>");
return Command::SUCCESS;
}
$output->writeln("<info>Extracting ZIP into temporary directory...</info>");
$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("<info>Restoring and merging .env file...</info>");
$envChanges = $this->restoreEnv($extractDir, $appDir, $output, $interactions);
}
$folderLocations = ['themes', 'public/uploads', 'storage/uploads'];
foreach ($folderLocations as $folderSubPath) {
if ($contents[$folderSubPath]['exists']) {
$output->writeln("<info>Restoring {$folderSubPath} folder...</info>");
$this->restoreFolder($folderSubPath, $appDir, $extractDir);
}
}
$artisan = (new ArtisanRunner($appDir));
if ($contents['db']['exists']) {
$output->writeln("<info>Restoring database from SQL dump...</info>");
$this->restoreDatabase($appDir, $extractDir);
$output->writeln("<info>Running database migrations...</info>");
$artisan->run(['migrate', '--force']);
}
if ($envChanges && $envChanges['old_url'] !== $envChanges['new_url']) {
$output->writeln("<info>App URL change made, updating database with URL change...</info>");
$artisan->run([
'bookstack:update-url', '--force',
$envChanges['old_url'], $envChanges['new_url'],
]);
}
$output->writeln("<info>Clearing app caches...</info>");
$artisan->run(['cache:clear']);
$artisan->run(['config:clear']);
$artisan->run(['view:clear']);
$output->writeln("<info>Cleaning up extract directory...</info>");
Directories::delete($extractDir);
$output->writeln("<success>\nRestore operation complete!</success>");
$output->writeln("<info>You may need to fix file/folder permissions so that the webserver has</info>");
$output->writeln("<info>the required read/write access to the necessary directories & files.</info>");
return Command::SUCCESS;
}
protected function restoreEnv(string $extractDir, string $appDir, OutputInterface $output, InteractiveConsole $interactions): array
{
$oldEnv = EnvironmentLoader::load($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::load($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);
}
}