Passed
Push — master ( 824da6...fbfbb9 )
by Greg
05:22
created

UpgradeController::wizardStepPending()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 10
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2019 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\Controllers\Admin;
21
22
use Fig\Http\Message\StatusCodeInterface;
23
use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24
use Fisharebest\Webtrees\Exceptions\HttpServerErrorException;
25
use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
26
use Fisharebest\Webtrees\I18N;
27
use Fisharebest\Webtrees\Services\TreeService;
28
use Fisharebest\Webtrees\Services\UpgradeService;
29
use Fisharebest\Webtrees\Tree;
30
use Fisharebest\Webtrees\Webtrees;
31
use Illuminate\Database\Capsule\Manager as DB;
32
use Illuminate\Support\Collection;
33
use League\Flysystem\Filesystem;
34
use League\Flysystem\FilesystemInterface;
35
use Psr\Http\Message\ResponseInterface;
36
use Psr\Http\Message\ServerRequestInterface;
37
use RuntimeException;
38
use Throwable;
39
40
use function assert;
41
use function basename;
42
use function date;
43
use function e;
44
use function fclose;
45
use function fopen;
46
use function fseek;
47
use function intdiv;
48
use function microtime;
49
use function redirect;
50
use function response;
51
use function route;
52
use function version_compare;
53
use function view;
54
55
/**
56
 * Controller for upgrading to a new version of webtrees.
57
 */
58
class UpgradeController extends AbstractAdminController
59
{
60
    // We make the upgrade in a number of small steps to keep within server time limits.
61
    private const STEP_CHECK    = 'Check';
62
    private const STEP_PREPARE  = 'Prepare';
63
    private const STEP_PENDING  = 'Pending';
64
    private const STEP_EXPORT   = 'Export';
65
    private const STEP_DOWNLOAD = 'Download';
66
    private const STEP_UNZIP    = 'Unzip';
67
    private const STEP_COPY     = 'Copy';
68
69
    // Where to store our temporary files.
70
    private const UPGRADE_FOLDER = 'data/tmp/upgrade/';
71
72
    // Where to store the downloaded ZIP archive.
73
    private const ZIP_FILENAME = 'data/tmp/webtrees.zip';
74
75
    // The ZIP archive stores everything inside this top-level folder.
76
    private const ZIP_FILE_PREFIX = 'webtrees';
77
78
    // Cruft can accumulate after upgrades.
79
    private const FOLDERS_TO_CLEAN = [
80
        'app',
81
        'resources',
82
        'vendor',
83
    ];
84
85
    /** @var UpgradeService */
86
    private $upgrade_service;
87
88
    /** @var TreeService */
89
    private $tree_service;
90
91
    /**
92
     * UpgradeController constructor.
93
     *
94
     * @param TreeService    $tree_service
95
     * @param UpgradeService $upgrade_service
96
     */
97
    public function __construct(
98
        TreeService $tree_service,
99
        UpgradeService $upgrade_service
100
    ) {
101
        $this->tree_service    = $tree_service;
102
        $this->upgrade_service = $upgrade_service;
103
    }
104
105
    /**
106
     * @param ServerRequestInterface $request
107
     *
108
     * @return ResponseInterface
109
     */
110
    public function wizard(ServerRequestInterface $request): ResponseInterface
111
    {
112
        $continue = $request->getQueryParams()['continue'] ?? '';
113
114
        $title = I18N::translate('Upgrade wizard');
115
116
        if ($continue === '1') {
117
            return $this->viewResponse('admin/upgrade/steps', [
118
                'steps' => $this->wizardSteps(),
119
                'title' => $title,
120
            ]);
121
        }
122
123
        return $this->viewResponse('admin/upgrade/wizard', [
124
            'current_version' => Webtrees::VERSION,
125
            'latest_version'  => $this->upgrade_service->latestVersion(),
126
            'title'           => $title,
127
        ]);
128
    }
129
130
    /**
131
     * @param ServerRequestInterface $request
132
     *
133
     * @return ResponseInterface
134
     */
135
    public function confirm(ServerRequestInterface $request): ResponseInterface
136
    {
137
        return redirect(route('upgrade', ['continue' => 1]));
138
    }
139
140
    /**
141
     * Perform one step of the wizard
142
     *
143
     * @param ServerRequestInterface $request
144
     *
145
     * @return ResponseInterface
146
     */
147
    public function step(ServerRequestInterface $request): ResponseInterface
148
    {
149
        $root_filesystem = $request->getAttribute('filesystem.root');
150
        assert($root_filesystem instanceof FilesystemInterface);
151
152
        $data_filesystem = $request->getAttribute('filesystem.data');
153
        assert($data_filesystem instanceof FilesystemInterface);
154
155
        // Somewhere to unpack a .ZIP file
156
        $temporary_filesystem = new Filesystem(new ChrootAdapter($root_filesystem, self::UPGRADE_FOLDER));
157
158
        $zip_file   = Webtrees::ROOT_DIR . self::ZIP_FILENAME;
159
        $zip_folder = Webtrees::ROOT_DIR . self::UPGRADE_FOLDER;
160
161
162
        $step = $request->getQueryParams()['step'] ?? self::STEP_CHECK;
163
164
        switch ($step) {
165
            case self::STEP_CHECK:
166
                return $this->wizardStepCheck();
167
168
            case self::STEP_PREPARE:
169
                return $this->wizardStepPrepare($root_filesystem);
170
171
            case self::STEP_PENDING:
172
                return $this->wizardStepPending();
173
174
            case self::STEP_EXPORT:
175
                $tree_name = $request->getQueryParams()['tree'] ?? '';
176
                $tree      = $this->tree_service->all()[$tree_name];
177
                assert($tree instanceof Tree);
178
179
                return $this->wizardStepExport($tree, $data_filesystem);
180
181
            case self::STEP_DOWNLOAD:
182
                return $this->wizardStepDownload($root_filesystem);
183
184
            case self::STEP_UNZIP:
185
                return $this->wizardStepUnzip($zip_file, $zip_folder);
186
187
            case self::STEP_COPY:
188
                return $this->wizardStepCopyAndCleanUp($zip_file, $root_filesystem, $temporary_filesystem);
189
190
            default:
191
                return response('', StatusCodeInterface::STATUS_NO_CONTENT);
192
        }
193
    }
194
195
    /**
196
     * @return string[]
197
     */
198
    private function wizardSteps(): array
199
    {
200
        $download_url = $this->upgrade_service->downloadUrl();
201
202
        $export_steps = [];
203
204
        foreach ($this->tree_service->all() as $tree) {
205
            $route = route('upgrade', [
206
                'step' => self::STEP_EXPORT,
207
                'tree' => $tree->name(),
208
            ]);
209
210
            $export_steps[$route] = I18N::translate('Export all the family trees to GEDCOM files…') . ' ' . e($tree->title());
211
        }
212
213
        return [
214
                route('upgrade', ['step' => self::STEP_CHECK])   => I18N::translate('Upgrade wizard'),
215
                route('upgrade', ['step' => self::STEP_PREPARE]) => I18N::translate('Create a temporary folder…'),
216
                route('upgrade', ['step' => self::STEP_PENDING]) => I18N::translate('Check for pending changes…'),
217
            ] + $export_steps + [
218
                route('upgrade', ['step' => self::STEP_DOWNLOAD]) => I18N::translate('Download %s…', e($download_url)),
219
                route('upgrade', ['step' => self::STEP_UNZIP])    => I18N::translate('Unzip %s to a temporary folder…', e(basename($download_url))),
220
                route('upgrade', ['step' => self::STEP_COPY])     => I18N::translate('Copy files…'),
221
            ];
222
    }
223
224
    /**
225
     * @return ResponseInterface
226
     */
227
    private function wizardStepCheck(): ResponseInterface
228
    {
229
        $latest_version = $this->upgrade_service->latestVersion();
230
231
        if ($latest_version === '') {
232
            throw new HttpServerErrorException(I18N::translate('No upgrade information is available.'));
233
        }
234
235
        if (version_compare(Webtrees::VERSION, $latest_version) >= 0) {
236
            $message = I18N::translate('This is the latest version of webtrees. No upgrade is available.');
237
            throw new HttpServerErrorException($message);
238
        }
239
240
        /* I18N: %s is a version number, such as 1.2.3 */
241
        $alert = I18N::translate('Upgrade to webtrees %s.', e($latest_version));
242
243
        return response(view('components/alert-success', [
244
            'alert' => $alert,
245
        ]));
246
    }
247
248
    /**
249
     * Make sure the temporary folder exists.
250
     *
251
     * @param FilesystemInterface $root_filesystem
252
     *
253
     * @return ResponseInterface
254
     */
255
    private function wizardStepPrepare(FilesystemInterface $root_filesystem): ResponseInterface
256
    {
257
        $root_filesystem->deleteDir(self::UPGRADE_FOLDER);
258
        $root_filesystem->createDir(self::UPGRADE_FOLDER);
259
260
        return response(view('components/alert-success', [
261
            'alert' => I18N::translate('The folder %s has been created.', e(self::UPGRADE_FOLDER)),
262
        ]));
263
    }
264
265
    /**
266
     * @return ResponseInterface
267
     */
268
    private function wizardStepPending(): ResponseInterface
269
    {
270
        $changes = DB::table('change')->where('status', '=', 'pending')->exists();
271
272
        if ($changes) {
273
            throw new HttpServerErrorException(I18N::translate('You should accept or reject all pending changes before upgrading.'));
274
        }
275
276
        return response(view('components/alert-success', [
277
            'alert' => I18N::translate('There are no pending changes.'),
278
        ]));
279
    }
280
281
    /**
282
     * @param Tree                $tree
283
     * @param FilesystemInterface $data_filesystem
284
     *
285
     * @return ResponseInterface
286
     */
287
    private function wizardStepExport(Tree $tree, FilesystemInterface $data_filesystem): ResponseInterface
288
    {
289
        // We store the data in PHP temporary storage.
290
        $stream = fopen('php://temp', 'wb+');
291
292
        if ($stream === false) {
293
            throw new RuntimeException('Failed to create temporary stream');
294
        }
295
296
        $filename = $tree->name() . date('-Y-m-d') . '.ged';
297
298
        if ($data_filesystem->has($filename)) {
299
            $data_filesystem->delete($filename);
300
        }
301
302
        $tree->exportGedcom($stream);
303
        fseek($stream, 0);
304
        $data_filesystem->writeStream($tree->name() . date('-Y-m-d') . '.ged', $stream);
305
        fclose($stream);
306
307
        return response(view('components/alert-success', [
308
            'alert' => I18N::translate('The family tree has been exported to %s.', e($filename)),
309
        ]));
310
    }
311
312
    /**
313
     * @param FilesystemInterface $root_filesystem
314
     *
315
     * @return ResponseInterface
316
     */
317
    private function wizardStepDownload(FilesystemInterface $root_filesystem): ResponseInterface
318
    {
319
        $start_time   = microtime(true);
320
        $download_url = $this->upgrade_service->downloadUrl();
321
322
        try {
323
            $bytes = $this->upgrade_service->downloadFile($download_url, $root_filesystem, self::ZIP_FILENAME);
324
        } catch (Throwable $exception) {
325
            throw new HttpServerErrorException($exception->getMessage());
326
        }
327
328
        $kb       = I18N::number(intdiv($bytes + 1023, 1024));
329
        $end_time = microtime(true);
330
        $seconds  = I18N::number($end_time - $start_time, 2);
331
332
        return response(view('components/alert-success', [
333
            'alert' => I18N::translate('%1$s KB were downloaded in %2$s seconds.', $kb, $seconds),
334
        ]));
335
    }
336
337
    /**
338
     * For performance reasons, we use direct filesystem access for this step.
339
     *
340
     * @param string $zip_file
341
     * @param string $zip_folder
342
     *
343
     * @return ResponseInterface
344
     */
345
    private function wizardStepUnzip(string $zip_file, string $zip_folder): ResponseInterface
346
    {
347
        $start_time = microtime(true);
348
        $this->upgrade_service->extractWebtreesZip($zip_file, $zip_folder);
349
        $count    = $this->upgrade_service->webtreesZipContents($zip_file)->count();
350
        $end_time = microtime(true);
351
        $seconds  = I18N::number($end_time - $start_time, 2);
352
353
        /* I18N: …from the .ZIP file, %2$s is a (fractional) number of seconds */
354
        $alert = I18N::plural('%1$s file was extracted in %2$s seconds.', '%1$s files were extracted in %2$s seconds.', $count, I18N::number($count), $seconds);
355
356
        return response(view('components/alert-success', [
357
            'alert' => $alert,
358
        ]));
359
    }
360
361
    /**
362
     * @param string              $zip_file
363
     * @param FilesystemInterface $root_filesystem
364
     * @param FilesystemInterface $temporary_filesystem
365
     *
366
     * @return ResponseInterface
367
     */
368
    private function wizardStepCopyAndCleanUp(
369
        string $zip_file,
370
        FilesystemInterface $root_filesystem,
371
        FilesystemInterface $temporary_filesystem
372
    ): ResponseInterface {
373
        $source_filesystem = new Filesystem(new ChrootAdapter($temporary_filesystem, self::ZIP_FILE_PREFIX));
374
375
        $this->upgrade_service->startMaintenanceMode();
376
        $this->upgrade_service->moveFiles($source_filesystem, $root_filesystem);
377
        $this->upgrade_service->endMaintenanceMode();
378
379
        // While we have time, clean up any old files.
380
        $files_to_keep    = $this->upgrade_service->webtreesZipContents($zip_file);
381
        $folders_to_clean = new Collection(self::FOLDERS_TO_CLEAN);
382
383
        $this->upgrade_service->cleanFiles($root_filesystem, $folders_to_clean, $files_to_keep);
384
385
        $url    = route(ControlPanel::class);
386
        $alert  =  I18N::translate('The upgrade is complete.');
387
        $button = '<a href="' . e($url) . '" class="btn btn-primary">' . I18N::translate('continue') . '</a>';
388
389
        return response(view('components/alert-success', [
390
            'alert' => $alert . ' ' . $button,
391
        ]));
392
    }
393
}
394