Completed
Branch master (04f712)
by Greg
06:30 queued 21s
created

AdminUpgradeController::wizardStepDownload()   B

Complexity

Conditions 6
Paths 40

Size

Total Lines 42
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 25
nc 40
nop 0
dl 0
loc 42
rs 8.8977
c 0
b 0
f 0
1
<?php
2
/**
3
 * webtrees: online genealogy
4
 * Copyright (C) 2018 webtrees development team
5
 * This program is free software: you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation, either version 3 of the License, or
8
 * (at your option) any later version.
9
 * This program is distributed in the hope that it will be useful,
10
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
 * GNU General Public License for more details.
13
 * You should have received a copy of the GNU General Public License
14
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
15
 */
16
declare(strict_types=1);
17
18
namespace Fisharebest\Webtrees\Http\Controllers;
19
20
use Exception;
21
use Fisharebest\Webtrees\Database;
22
use Fisharebest\Webtrees\DebugBar;
23
use Fisharebest\Webtrees\I18N;
24
use Fisharebest\Webtrees\Services\TimeoutService;
25
use Fisharebest\Webtrees\Services\UpgradeService;
26
use Fisharebest\Webtrees\Tree;
27
use GuzzleHttp\Client;
28
use League\Flysystem\Adapter\Local;
29
use League\Flysystem\Filesystem;
30
use League\Flysystem\ZipArchive\ZipArchiveAdapter;
31
use Symfony\Component\HttpFoundation\Request;
32
use Symfony\Component\HttpFoundation\Response;
33
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
34
use Throwable;
35
use ZipArchive;
36
37
/**
38
 * Controller for upgrading to a new version of webtrees.
39
 */
40
class AdminUpgradeController extends AbstractBaseController
41
{
42
    // Icons for success and failure
43
    const SUCCESS = '<i class="fas fa-check" style="color:green"></i> ';
44
    const FAILURE = '<i class="fas fa-times" style="color:red"></i> ';
45
46
    // Options for fetching files using GuzzleHTTP
47
    const GUZZLE_OPTIONS = [
48
        'connect_timeout' => 25,
49
        'read_timeout'    => 25,
50
        'timeout'         => 55,
51
    ];
52
53
    const LOCK_FILE = 'data/offline.txt';
54
55
    /** @var string */
56
    protected $layout = 'layouts/administration';
57
58
    /** @var TimeoutService */
59
    private $timeout_service;
60
61
    /** @var UpgradeService */
62
    private $upgrade_service;
63
64
    /**
65
     * AdminUpgradeController constructor.
66
     *
67
     * @param TimeoutService $timeout_service
68
     * @param UpgradeService $upgrade_service
69
     */
70
    public function __construct(TimeoutService $timeout_service, UpgradeService $upgrade_service)
71
    {
72
        $this->timeout_service = $timeout_service;
73
        $this->upgrade_service = $upgrade_service;
74
    }
75
76
    /**
77
     * @param Request $request
78
     *
79
     * @return Response
80
     */
81
    public function wizard(Request $request): Response
82
    {
83
        $continue = (bool) $request->get('continue');
84
85
        $title = I18N::translate('Upgrade wizard');
86
87
        if ($continue) {
88
            return $this->viewResponse('admin/upgrade/steps', [
89
                'steps' => $this->wizardSteps(),
90
                'title' => $title,
91
            ]);
92
        }
93
94
        return $this->viewResponse('admin/upgrade/wizard', [
95
            'current_version' => WT_VERSION,
96
            'latest_version'  => $this->upgrade_service->latestVersion(),
97
            'title'           => $title,
98
        ]);
99
    }
100
101
    /**
102
     * Perform one step of the wizard
103
     *
104
     * @param Request   $request
105
     * @param Tree|null $tree
106
     *
107
     * @return Response
108
     */
109
    public function step(Request $request, Tree $tree = null): Response
110
    {
111
        $step = $request->get('step');
112
113
        switch ($step) {
114
            case 'Check':
115
                return $this->wizardStepCheck();
116
            case 'Pending':
117
                return $this->wizardStepPending();
118
            case 'Export':
119
                if ($tree instanceof Tree) {
120
                    return $this->wizardStepExport($tree);
121
                }
122
123
                return $this->success('The tree no longer exists.');
124
            case 'Download':
125
                return $this->wizardStepDownload();
126
            case 'Unzip':
127
                return $this->wizardStepUnzip();
128
            case 'Copy':
129
                return $this->wizardStepCopy();
130
            default:
131
                throw new NotFoundHttpException();
132
        }
133
    }
134
135
    /**
136
     * @return string[]
137
     */
138
    private function wizardSteps(): array
139
    {
140
        $download_url = $this->upgrade_service->downloadUrl();
141
142
        $export_steps = [];
143
144
        foreach (Tree::getAll() as $tree) {
145
            $route = route('upgrade', [
146
                'step' => 'Export',
147
                'ged'  => $tree->getName(),
148
            ]);
149
150
            $export_steps[$route] = I18N::translate('Export all the family trees to GEDCOM files…') . ' ' . e($tree->getTitle());
151
        }
152
153
        return [
154
                route('upgrade', ['step' => 'Check'])   => 'config.php',
155
                route('upgrade', ['step' => 'Pending']) => I18N::translate('Check for pending changes…'),
156
            ] + $export_steps + [
157
                route('upgrade', ['step' => 'Download']) => I18N::translate('Download %s…', e($download_url)),
158
                route('upgrade', ['step' => 'Unzip'])    => I18N::translate('Unzip %s to a temporary folder…', e(basename($download_url))),
159
                route('upgrade', ['step' => 'Copy'])     => I18N::translate('Copy files…'),
160
            ];
161
    }
162
163
    /**
164
     * @return Response
165
     */
166
    private function wizardStepCheck(): Response
167
    {
168
        $latest_version = $this->upgrade_service->latestVersion();
169
170
        if ($latest_version === '') {
171
            return $this->failure(I18N::translate('No upgrade information is available.'));
172
        }
173
174
        if (version_compare(WT_VERSION, $latest_version) >= 0) {
175
            return $this->failure(I18N::translate('This is the latest version of webtrees. No upgrade is available.'));
176
        }
177
178
        /* I18N: %s is a version number, such as 1.2.3 */
179
        return $this->success(I18N::translate('Upgrade to webtrees %s.', e($latest_version)));
180
    }
181
182
    /**
183
     * @return Response
184
     */
185
    private function wizardStepPending(): Response
186
    {
187
        $changes = Database::prepare("SELECT 1 FROM `##change` WHERE status='pending' LIMIT 1")->fetchOne();
188
189
        if (empty($changes)) {
190
            return $this->success(I18N::translate('There are no pending changes.'));
191
        }
192
193
        $route   = route('show-pending');
194
        $message = I18N::translate('You should accept or reject all pending changes before upgrading.');
195
        $message .= ' <a href="' . e($route) . '">' . I18N::translate('Pending changes') . '</a>';
196
197
        return $this->failure($message);
198
    }
199
200
    /**
201
     * @param Tree $tree
202
     *
203
     * @return Response
204
     */
205
    private function wizardStepExport(Tree $tree): Response
206
    {
207
        $filename = WT_DATA_DIR . $tree->getName() . date('-Y-m-d') . '.ged';
208
209
        try {
210
            $stream = fopen($filename, 'w');
211
212
            if ($stream !== false) {
213
                $tree->exportGedcom($stream);
214
                fclose($stream);
215
216
                return $this->success(I18N::translate('The family tree has been exported to %s.', e($filename)));
217
            }
218
        } catch (Throwable $ex) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
219
        }
220
221
        return $this->failure(I18N::translate('The file %s could not be created.', e($filename)));
222
    }
223
224
    /**
225
     * @return Response
226
     */
227
    private function wizardStepDownload(): Response
228
    {
229
        try {
230
            $download_url = $this->upgrade_service->downloadUrl();
231
            $zip_file     = WT_DATA_DIR . basename($download_url);
232
            $zip_stream   = fopen($zip_file, 'w');
233
            
234
            if ($zip_stream === false) {
235
                throw new Exception('Cannot read ZIP file: ' . $zip_file);
236
            }
237
238
            $start_time   = microtime(true);
239
            $client       = new Client();
240
241
            $response = $client->get($download_url, self::GUZZLE_OPTIONS);
242
            $stream   = $response->getBody();
243
244
            while (!$stream->eof()) {
245
                fwrite($zip_stream, $stream->read(65536));
246
            }
247
248
            $stream->close();
249
            fclose($zip_stream);
250
            $zip_size = filesize($zip_file);
251
            $end_time = microtime(true);
252
253
            if ($zip_size > 0) {
254
                $kb      = I18N::number(intdiv($zip_size + 1023, 1024));
255
                $seconds = I18N::number($end_time - $start_time, 2);
256
257
                /* I18N: %1$s is a number of KB, %2$s is a (fractional) number of seconds */
258
                return $this->success(I18N::translate('%1$s KB were downloaded in %2$s seconds.', $kb, $seconds));
259
            }
260
261
            if (!in_array('ssl', stream_get_transports())) {
262
                // Guess why we might have failed...
263
                return $this->failure(I18N::translate('This server does not support secure downloads using HTTPS.'));
264
            }
265
266
            return $this->failure('');
267
        } catch (Exception $ex) {
268
            return $this->failure($ex->getMessage());
269
        }
270
    }
271
272
    /**
273
     * @return Response
274
     */
275
    private function wizardStepUnzip(): Response
276
    {
277
        $download_url   = $this->upgrade_service->downloadUrl();
278
        $zip_file       = WT_DATA_DIR . basename($download_url);
279
        $tmp_folder     = WT_DATA_DIR . basename($download_url, '.zip');
280
        $src_filesystem = new Filesystem(new ZipArchiveAdapter($zip_file, null, 'webtrees'));
281
        $dst_filesystem = new Filesystem(new Local($tmp_folder));
0 ignored issues
show
Unused Code introduced by
The assignment to $dst_filesystem is dead and can be removed.
Loading history...
282
        $paths          = $src_filesystem->listContents('', true);
283
        $paths          = array_filter($paths, function (array $file): bool {
284
            return $file['type'] === 'file';
285
        });
286
287
        $start_time = microtime(true);
288
289
        // The Flysystem/ZipArchiveAdapter is very slow, taking over a second per file.
290
        // So we do this step using the native PHP library.
291
292
        $zip = new ZipArchive();
293
        if ($zip->open($zip_file)) {
294
            $zip->extractTo($tmp_folder);
295
            $zip->close();
296
            echo 'ok';
297
        } else {
298
            echo 'failed';
299
        }
300
301
        $end_time = microtime(true);
302
        $seconds  = I18N::number($end_time - $start_time, 2);
303
        $count    = count($paths);
304
305
        /* I18N: …from the .ZIP file, %2$s is a (fractional) number of seconds */
306
        return $this->success(I18N::plural('%1$s file was extracted in %2$s seconds.', '%1$s files were extracted in %2$s seconds.', $count, $count, $seconds));
307
    }
308
309
    /**
310
     * @return Response
311
     */
312
    private function wizardStepCopy(): Response
313
    {
314
        $download_url   = $this->upgrade_service->downloadUrl();
315
        $src_filesystem = new Filesystem(new Local(WT_DATA_DIR . basename($download_url, '.zip') . '/webtrees'));
316
        $dst_filesystem = new Filesystem(new Local(WT_ROOT));
317
        $paths          = $src_filesystem->listContents('', true);
318
        $paths          = array_filter($paths, function (array $file): bool {
319
            return $file['type'] === 'file';
320
        });
321
322
        $lock_file_text = I18N::translate('This website is being upgraded. Try again in a few minutes.');
323
        $dst_filesystem->put(self::LOCK_FILE, $lock_file_text);
324
325
        foreach ($paths as $path) {
326
            $dst_filesystem->put($path['path'], $src_filesystem->read($path['path']));
327
328
            if ($this->timeout_service->isTimeNearlyUp()) {
329
                return $this->failure(I18N::translate('The server’s time limit has been reached.'));
330
            }
331
        }
332
333
        $dst_filesystem->delete(self::LOCK_FILE);
334
335
        // Delete the temporary files - if there is enough time.
336
        foreach ($paths as $path) {
337
            $src_filesystem->delete($path['path']);
338
339
            if (($this->timeout_service->isTimeNearlyUp())) {
340
                break;
341
            }
342
        }
343
344
        return $this->success(I18N::translate('The upgrade is complete.'));
345
    }
346
347
    /**
348
     * @param string $message
349
     *
350
     * @return Response
351
     */
352
    private function success(string $message): Response
353
    {
354
        return new Response(self::SUCCESS . $message);
355
    }
356
357
    /**
358
     * @param string $message
359
     *
360
     * @return Response
361
     */
362
    private function failure(string $message): Response
363
    {
364
        return new Response(self::FAILURE . $message, Response::HTTP_INTERNAL_SERVER_ERROR);
365
    }
366
}
367