Completed
Push — master ( 2839b8...c06384 )
by Greg
13:24 queued 06:09
created

UpgradeController::confirm()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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