Passed
Push — master ( 89f718...4b3ef6 )
by Greg
06:43
created

UpgradeWizardStep::handle()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 42
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 26
c 0
b 0
f 0
nc 8
nop 1
dl 0
loc 42
rs 8.4444
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\RequestHandlers;
21
22
use Fig\Http\Message\StatusCodeInterface;
23
use Fisharebest\Flysystem\Adapter\ChrootAdapter;
24
use Fisharebest\Webtrees\Exceptions\HttpServerErrorException;
25
use Fisharebest\Webtrees\I18N;
26
use Fisharebest\Webtrees\Registry;
27
use Fisharebest\Webtrees\Services\GedcomExportService;
28
use Fisharebest\Webtrees\Services\TreeService;
29
use Fisharebest\Webtrees\Services\UpgradeService;
30
use Fisharebest\Webtrees\Tree;
31
use Fisharebest\Webtrees\Webtrees;
32
use Illuminate\Database\Capsule\Manager as DB;
33
use Illuminate\Support\Collection;
34
use League\Flysystem\Filesystem;
35
use League\Flysystem\FilesystemInterface;
36
use Psr\Http\Message\ResponseInterface;
37
use Psr\Http\Message\ServerRequestInterface;
38
use Psr\Http\Server\RequestHandlerInterface;
39
use RuntimeException;
40
use Throwable;
41
42
use function assert;
43
use function date;
44
use function e;
45
use function fclose;
46
use function fopen;
47
use function fseek;
48
use function intdiv;
49
use function microtime;
50
use function response;
51
use function route;
52
use function version_compare;
53
use function view;
54
55
/**
56
 * Upgrade to a new version of webtrees.
57
 */
58
class UpgradeWizardStep implements RequestHandlerInterface
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 GedcomExportService */
86
    private $gedcom_export_service;
87
88
    /** @var UpgradeService */
89
    private $upgrade_service;
90
91
    /** @var TreeService */
92
    private $tree_service;
93
94
    /**
95
     * UpgradeController constructor.
96
     *
97
     * @param GedcomExportService $gedcom_export_service
98
     * @param TreeService         $tree_service
99
     * @param UpgradeService      $upgrade_service
100
     */
101
    public function __construct(
102
        GedcomExportService $gedcom_export_service,
103
        TreeService $tree_service,
104
        UpgradeService $upgrade_service
105
    ) {
106
        $this->gedcom_export_service = $gedcom_export_service;
107
        $this->tree_service          = $tree_service;
108
        $this->upgrade_service       = $upgrade_service;
109
    }
110
111
    /**
112
     * Perform one step of the wizard
113
     *
114
     * @param ServerRequestInterface $request
115
     *
116
     * @return ResponseInterface
117
     */
118
    public function handle(ServerRequestInterface $request): ResponseInterface
119
    {
120
        $root_filesystem = Registry::filesystem()->root();
121
        $data_filesystem = Registry::filesystem()->data();
122
123
        // Somewhere to unpack a .ZIP file
124
        $temporary_filesystem = new Filesystem(new ChrootAdapter($root_filesystem, self::UPGRADE_FOLDER));
125
126
        $zip_file   = Webtrees::ROOT_DIR . self::ZIP_FILENAME;
127
        $zip_folder = Webtrees::ROOT_DIR . self::UPGRADE_FOLDER;
128
129
130
        $step = $request->getQueryParams()['step'] ?? self::STEP_CHECK;
131
132
        switch ($step) {
133
            case self::STEP_CHECK:
134
                return $this->wizardStepCheck();
135
136
            case self::STEP_PREPARE:
137
                return $this->wizardStepPrepare($root_filesystem);
138
139
            case self::STEP_PENDING:
140
                return $this->wizardStepPending();
141
142
            case self::STEP_EXPORT:
143
                $tree_name = $request->getQueryParams()['tree'] ?? '';
144
                $tree      = $this->tree_service->all()[$tree_name];
145
                assert($tree instanceof Tree);
146
147
                return $this->wizardStepExport($tree, $data_filesystem);
148
149
            case self::STEP_DOWNLOAD:
150
                return $this->wizardStepDownload($root_filesystem);
151
152
            case self::STEP_UNZIP:
153
                return $this->wizardStepUnzip($zip_file, $zip_folder);
154
155
            case self::STEP_COPY:
156
                return $this->wizardStepCopyAndCleanUp($zip_file, $root_filesystem, $temporary_filesystem);
157
158
            default:
159
                return response('', StatusCodeInterface::STATUS_NO_CONTENT);
160
        }
161
    }
162
163
    /**
164
     * @return ResponseInterface
165
     */
166
    private function wizardStepCheck(): ResponseInterface
167
    {
168
        $latest_version = $this->upgrade_service->latestVersion();
169
170
        if ($latest_version === '') {
171
            throw new HttpServerErrorException(I18N::translate('No upgrade information is available.'));
172
        }
173
174
        if (version_compare(Webtrees::VERSION, $latest_version) >= 0) {
175
            $message = I18N::translate('This is the latest version of webtrees. No upgrade is available.');
176
            throw new HttpServerErrorException($message);
177
        }
178
179
        /* I18N: %s is a version number, such as 1.2.3 */
180
        $alert = I18N::translate('Upgrade to webtrees %s.', e($latest_version));
181
182
        return response(view('components/alert-success', [
183
            'alert' => $alert,
184
        ]));
185
    }
186
187
    /**
188
     * Make sure the temporary folder exists.
189
     *
190
     * @param FilesystemInterface $root_filesystem
191
     *
192
     * @return ResponseInterface
193
     */
194
    private function wizardStepPrepare(FilesystemInterface $root_filesystem): ResponseInterface
195
    {
196
        $root_filesystem->deleteDir(self::UPGRADE_FOLDER);
197
        $root_filesystem->createDir(self::UPGRADE_FOLDER);
198
199
        return response(view('components/alert-success', [
200
            'alert' => I18N::translate('The folder %s has been created.', e(self::UPGRADE_FOLDER)),
201
        ]));
202
    }
203
204
    /**
205
     * @return ResponseInterface
206
     */
207
    private function wizardStepPending(): ResponseInterface
208
    {
209
        $changes = DB::table('change')->where('status', '=', 'pending')->exists();
210
211
        if ($changes) {
212
            throw new HttpServerErrorException(I18N::translate('You should accept or reject all pending changes before upgrading.'));
213
        }
214
215
        return response(view('components/alert-success', [
216
            'alert' => I18N::translate('There are no pending changes.'),
217
        ]));
218
    }
219
220
    /**
221
     * @param Tree                $tree
222
     * @param FilesystemInterface $data_filesystem
223
     *
224
     * @return ResponseInterface
225
     */
226
    private function wizardStepExport(Tree $tree, FilesystemInterface $data_filesystem): ResponseInterface
227
    {
228
        // We store the data in PHP temporary storage.
229
        $stream = fopen('php://temp', 'wb+');
230
231
        if ($stream === false) {
232
            throw new RuntimeException('Failed to create temporary stream');
233
        }
234
235
        $filename = $tree->name() . date('-Y-m-d') . '.ged';
236
237
        $this->gedcom_export_service->export($tree, $stream);
238
239
        fseek($stream, 0);
240
        $data_filesystem->putStream($tree->name() . date('-Y-m-d') . '.ged', $stream);
241
        fclose($stream);
242
243
        return response(view('components/alert-success', [
244
            'alert' => I18N::translate('The family tree has been exported to %s.', e($filename)),
245
        ]));
246
    }
247
248
    /**
249
     * @param FilesystemInterface $root_filesystem
250
     *
251
     * @return ResponseInterface
252
     */
253
    private function wizardStepDownload(FilesystemInterface $root_filesystem): ResponseInterface
254
    {
255
        $start_time   = microtime(true);
256
        $download_url = $this->upgrade_service->downloadUrl();
257
258
        try {
259
            $bytes = $this->upgrade_service->downloadFile($download_url, $root_filesystem, self::ZIP_FILENAME);
260
        } catch (Throwable $exception) {
261
            throw new HttpServerErrorException($exception->getMessage());
262
        }
263
264
        $kb       = I18N::number(intdiv($bytes + 1023, 1024));
265
        $end_time = microtime(true);
266
        $seconds  = I18N::number($end_time - $start_time, 2);
267
268
        return response(view('components/alert-success', [
269
            'alert' => I18N::translate('%1$s KB were downloaded in %2$s seconds.', $kb, $seconds),
270
        ]));
271
    }
272
273
    /**
274
     * For performance reasons, we use direct filesystem access for this step.
275
     *
276
     * @param string $zip_file
277
     * @param string $zip_folder
278
     *
279
     * @return ResponseInterface
280
     */
281
    private function wizardStepUnzip(string $zip_file, string $zip_folder): ResponseInterface
282
    {
283
        $start_time = microtime(true);
284
        $this->upgrade_service->extractWebtreesZip($zip_file, $zip_folder);
285
        $count    = $this->upgrade_service->webtreesZipContents($zip_file)->count();
286
        $end_time = microtime(true);
287
        $seconds  = I18N::number($end_time - $start_time, 2);
288
289
        /* I18N: …from the .ZIP file, %2$s is a (fractional) number of seconds */
290
        $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);
291
292
        return response(view('components/alert-success', [
293
            'alert' => $alert,
294
        ]));
295
    }
296
297
    /**
298
     * @param string              $zip_file
299
     * @param FilesystemInterface $root_filesystem
300
     * @param FilesystemInterface $temporary_filesystem
301
     *
302
     * @return ResponseInterface
303
     */
304
    private function wizardStepCopyAndCleanUp(
305
        string $zip_file,
306
        FilesystemInterface $root_filesystem,
307
        FilesystemInterface $temporary_filesystem
308
    ): ResponseInterface {
309
        $source_filesystem = new Filesystem(new ChrootAdapter($temporary_filesystem, self::ZIP_FILE_PREFIX));
310
311
        $this->upgrade_service->startMaintenanceMode();
312
        $this->upgrade_service->moveFiles($source_filesystem, $root_filesystem);
313
        $this->upgrade_service->endMaintenanceMode();
314
315
        // While we have time, clean up any old files.
316
        $files_to_keep    = $this->upgrade_service->webtreesZipContents($zip_file);
317
        $folders_to_clean = new Collection(self::FOLDERS_TO_CLEAN);
318
319
        $this->upgrade_service->cleanFiles($root_filesystem, $folders_to_clean, $files_to_keep);
320
321
        $url    = route(ControlPanel::class);
322
        $alert  = I18N::translate('The upgrade is complete.');
323
        $button = '<a href="' . e($url) . '" class="btn btn-primary">' . I18N::translate('continue') . '</a>';
324
325
        return response(view('components/alert-success', [
326
            'alert' => $alert . ' ' . $button,
327
        ]));
328
    }
329
}
330