-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathImportCommand.php
More file actions
248 lines (219 loc) · 9.25 KB
/
Copy pathImportCommand.php
File metadata and controls
248 lines (219 loc) · 9.25 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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
<?php
namespace Ramon\Backup\Console;
use Flarum\Console\AbstractCommand;
use Ramon\Backup\Job\ImportJob;
use Ramon\Backup\Job\JobState;
use Ramon\Backup\StoragePaths;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
/**
* `php flarum backup:import <archive>` — restore a `.flarum` archive
* into the running install, to completion, in a single process.
*
* This is the destructive half of a transfer: every bundled table is
* dropped and recreated, and bundled files overwrite their
* destinations. Because of that we refuse to run without an explicit
* `--yes`, the CLI equivalent of the "I understand this replaces my
* data" checkbox in the admin UI.
*
* Like {@see ExportCommand}, the heavy lifting is delegated to the same
* tick-based {@see ImportJob} the web flow uses; the CLI simply loops
* `runTick()` with no HTTP timeout and no browser tab to keep alive,
* which is why large or slow restores are "better handled" here.
*/
class ImportCommand extends AbstractCommand
{
public function __construct(
protected ImportJob $job,
protected StoragePaths $paths
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setName('backup:import')
->setDescription('Restore a .flarum backup archive into this install (replaces existing data).')
->addArgument('archive', InputArgument::REQUIRED, 'Path to the .flarum archive to restore.')
->addOption('yes', 'y', InputOption::VALUE_NONE, 'Confirm that this will REPLACE existing data (required).')
->addOption('private-key', null, InputOption::VALUE_REQUIRED, 'Base64 private key to decrypt an encrypted archive.')
->addOption('db', null, InputOption::VALUE_NEGATABLE, 'Restore the database dump.')
->addOption('assets', null, InputOption::VALUE_NEGATABLE, 'Restore public/assets.')
->addOption('storage', null, InputOption::VALUE_NEGATABLE, 'Restore storage/.')
->addOption(
'extensions',
null,
InputOption::VALUE_OPTIONAL,
'Restore extensions. Omit the value for ALL, or pass a comma-separated list of ids.',
false
)
->addOption(
'root-extend',
null,
InputOption::VALUE_NONE,
'Replace this install\'s root extend.php with the backup\'s (off by default — it is loaded before Flarum\'s error handler).'
)
->addOption(
'preserve-settings',
null,
InputOption::VALUE_NEGATABLE,
'Keep this server\'s mail / queue / integration settings across the restore.',
true
);
}
protected function fire(): int
{
if (! $this->input->getOption('yes')) {
$this->error('Refusing to import without --yes: a restore REPLACES the current database and files.');
return 1;
}
$archive = (string) $this->input->getArgument('archive');
if (! is_file($archive)) {
$this->error('Archive not found: '.$archive);
return 1;
}
$jobId = bin2hex(random_bytes(8));
$dir = $this->paths->importJobDir($jobId);
$staged = $dir.DIRECTORY_SEPARATOR.'upload.flarum';
if (! @copy($archive, $staged)) {
$this->error('Could not stage archive into the import directory.');
return 1;
}
$privateKey = $this->input->getOption('private-key');
$selection = $this->resolveSelection();
try {
$state = $this->job->start(
$jobId,
is_string($privateKey) && $privateKey !== '' ? $privateKey : null,
true,
$selection,
null
);
} catch (\Throwable $e) {
$this->error($e->getMessage());
return 1;
}
$state = $this->driveToCompletion($state);
if ($state->get('phase') === 'error') {
$this->error((string) $state->get('message'));
return 1;
}
$progress = (array) $state->get('progress', []);
$this->info(sprintf(
'Restore complete. %d entries extracted, %d SQL statements applied.',
(int) ($progress['extracted_entries'] ?? 0),
(int) ($progress['restored_statements'] ?? 0)
));
$rewrite = $state->get('rewrite_stats');
if (is_array($rewrite)) {
$this->info('URL rewrite — settings: '.((int) ($rewrite['settings'] ?? 0))
.', posts content: '.((int) ($rewrite['posts_content'] ?? 0))
.', posts parsed: '.((int) ($rewrite['posts_parsed'] ?? 0)));
}
return $this->reportOutcome($state);
}
/**
* Despeja os avisos e escolhe o exit code.
*
* Um restore que terminou `incomplete` sai com 1 de propósito: são os
* casos em que o operador precisa agir antes de recarregar o fórum —
* entrada não gravada, ou classe que o extend.php da raiz referencia
* e o autoloader não resolve. Sair 0 aqui é o que faz um script de
* deploy seguir adiante rumo a um HTTP 500 sem mensagem.
*/
private function reportOutcome(JobState $state): int
{
foreach ((array) $state->get('warnings', []) as $warning) {
$this->output->writeln('<comment>! '.(string) $warning.'</comment>');
}
if (! $state->get('incomplete', false)) {
return 0;
}
$this->error('Restore finished INCOMPLETE — review the warnings above before reloading the forum.');
return 1;
}
/**
* Build the import selection. When the user passes no selection
* flags at all we return null, which ImportJob reads as "restore
* everything in the archive". Passing any flag switches to an
* explicit selection where unspecified sections default to false.
*/
private function resolveSelection(): ?array
{
$touched = $this->input->hasParameterOption('--db')
|| $this->input->hasParameterOption('--no-db')
|| $this->input->hasParameterOption('--assets')
|| $this->input->hasParameterOption('--no-assets')
|| $this->input->hasParameterOption('--storage')
|| $this->input->hasParameterOption('--no-storage')
|| $this->input->hasParameterOption('--extensions')
|| $this->input->hasParameterOption('--root-extend')
|| $this->input->hasParameterOption('--no-preserve-settings');
if (! $touched) {
return null;
}
return [
'db' => (bool) $this->input->getOption('db'),
'assets' => (bool) $this->input->getOption('assets'),
'storage' => (bool) $this->input->getOption('storage'),
'extensions' => $this->resolveExtensionSelection(),
/*
* Assimétricos de propósito: sobrescrever o extend.php da raiz
* exige pedir, preservar a config deste servidor exige recusar.
*/
'root_extend' => (bool) $this->input->getOption('root-extend'),
'preserve_settings' => (bool) $this->input->getOption('preserve-settings'),
];
}
private function resolveExtensionSelection(): bool|array
{
if (! $this->input->hasParameterOption('--extensions')) {
return false;
}
$value = $this->input->getOption('extensions');
if ($value === null || $value === '') {
return true;
}
return array_values(array_filter(array_map('trim', explode(',', (string) $value))));
}
/**
* Pump runTick() to completion with the same single-line spinner +
* percent + message indicator as {@see ExportCommand}, so a long
* restore never looks frozen. Symfony's ProgressBar throttles on
* non-decorated output, so piping to a file stays readable.
*/
private function driveToCompletion(JobState $state): JobState
{
$frames = ['-', '\\', '|', '/'];
$tick = 0;
ProgressBar::setPlaceholderFormatterDefinition(
'spinner',
function () use (&$tick, $frames) {
return $frames[$tick % count($frames)];
}
);
$bar = new ProgressBar($this->output, 100);
$bar->setFormat(' %spinner% %percent:3s%% — %message%');
$bar->setMessage('Preparing…');
$bar->start();
try {
while (! in_array($state->get('phase'), ['done', 'error'], true)) {
$state = $this->job->runTick($state);
$tick++;
$progress = (array) $state->get('progress', []);
$percent = (int) round((float) ($progress['percent'] ?? 0));
$bar->setMessage((string) $state->get('message'));
$bar->setProgress(min(100, max(0, $percent)));
$bar->display();
}
if ($state->get('phase') === 'done') {
$bar->setProgress(100);
}
$bar->finish();
} finally {
$this->output->writeln('');
}
return $state;
}
}