Passed
Push — 2.1 ( 0dbfff...fd0452 )
by Greg
05:56
created

UpgradeService   A

Complexity

Total Complexity 35

Size/Duplication

Total Lines 332
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 100
dl 0
loc 332
rs 9.6
c 1
b 0
f 0
wmc 35

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
B cleanFiles() 0 19 8
A moveFiles() 0 9 4
A webtreesZipContents() 0 15 1
A downloadFile() 0 35 4
A extractWebtreesZip() 0 10 2
A latestVersion() 0 7 1
A serverParameters() 0 16 2
A isUpgradeAvailable() 0 5 1
A latestVersionTimestamp() 0 5 1
A startMaintenanceMode() 0 5 1
A fetchLatestVersion() 0 31 5
A endMaintenanceMode() 0 4 2
A latestVersionError() 0 3 1
A downloadUrl() 0 7 1
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2022 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\Services;
21
22
use Fig\Http\Message\StatusCodeInterface;
23
use Fisharebest\Webtrees\Contracts\TimestampInterface;
24
use Fisharebest\Webtrees\Http\Exceptions\HttpServerErrorException;
25
use Fisharebest\Webtrees\I18N;
26
use Fisharebest\Webtrees\Registry;
27
use Fisharebest\Webtrees\Site;
28
use Fisharebest\Webtrees\Webtrees;
29
use GuzzleHttp\Client;
30
use GuzzleHttp\Exception\GuzzleException;
31
use Illuminate\Database\Capsule\Manager as DB;
32
use Illuminate\Support\Collection;
33
use League\Flysystem\Filesystem;
34
use League\Flysystem\FilesystemException;
35
use League\Flysystem\FilesystemOperator;
36
use League\Flysystem\FilesystemReader;
37
use League\Flysystem\StorageAttributes;
38
use League\Flysystem\UnableToDeleteFile;
39
use League\Flysystem\ZipArchive\FilesystemZipArchiveProvider;
40
use League\Flysystem\ZipArchive\ZipArchiveAdapter;
41
use RuntimeException;
42
use ZipArchive;
43
44
use function explode;
45
use function fclose;
46
use function file_exists;
47
use function file_put_contents;
48
use function fopen;
49
use function ftell;
50
use function fwrite;
51
use function rewind;
52
use function strlen;
53
use function time;
54
use function unlink;
55
use function version_compare;
56
57
use const PHP_VERSION;
58
59
/**
60
 * Automatic upgrades.
61
 */
62
class UpgradeService
63
{
64
    // Options for fetching files using GuzzleHTTP
65
    private const GUZZLE_OPTIONS = [
66
        'connect_timeout' => 25,
67
        'read_timeout'    => 25,
68
        'timeout'         => 55,
69
    ];
70
71
    // Transfer stream data in blocks of this number of bytes.
72
    private const READ_BLOCK_SIZE = 65535;
73
74
    // Only check the webtrees server once per day.
75
    private const CHECK_FOR_UPDATE_INTERVAL = 24 * 60 * 60;
76
77
    // Fetch information about upgrades from here.
78
    // Note: earlier versions of webtrees used svn.webtrees.net, so we must maintain both URLs.
79
    private const UPDATE_URL = 'https://dev.webtrees.net/build/latest-version.txt';
80
81
    // If the update server doesn't respond after this time, give up.
82
    private const HTTP_TIMEOUT = 3.0;
83
84
    private TimeoutService $timeout_service;
85
86
    /**
87
     * UpgradeService constructor.
88
     *
89
     * @param TimeoutService $timeout_service
90
     */
91
    public function __construct(TimeoutService $timeout_service)
92
    {
93
        $this->timeout_service = $timeout_service;
94
    }
95
96
    /**
97
     * Unpack webtrees.zip.
98
     *
99
     * @param string $zip_file
100
     * @param string $target_folder
101
     *
102
     * @return void
103
     */
104
    public function extractWebtreesZip(string $zip_file, string $target_folder): void
105
    {
106
        // The Flysystem ZIP archive adapter is painfully slow, so use the native PHP library.
107
        $zip = new ZipArchive();
108
109
        if ($zip->open($zip_file) === true) {
110
            $zip->extractTo($target_folder);
111
            $zip->close();
112
        } else {
113
            throw new HttpServerErrorException('Cannot read ZIP file. Is it corrupt?');
114
        }
115
    }
116
117
    /**
118
     * Create a list of all the files in a webtrees .ZIP archive
119
     *
120
     * @param string $zip_file
121
     *
122
     * @return Collection<int,string>
123
     * @throws FilesystemException
124
     */
125
    public function webtreesZipContents(string $zip_file): Collection
126
    {
127
        $zip_provider   = new FilesystemZipArchiveProvider($zip_file, 0755);
128
        $zip_adapter    = new ZipArchiveAdapter($zip_provider, 'webtrees');
129
        $zip_filesystem = new Filesystem($zip_adapter);
130
131
        $files = $zip_filesystem->listContents('', FilesystemReader::LIST_DEEP)
132
            ->filter(static function (StorageAttributes $attributes): bool {
133
                return $attributes->isFile();
134
            })
135
            ->map(static function (StorageAttributes $attributes): string {
136
                return $attributes->path();
137
            });
138
139
        return new Collection($files);
140
    }
141
142
    /**
143
     * Fetch a file from a URL and save it in a filesystem.
144
     * Use streams so that we can copy files larger than our available memory.
145
     *
146
     * @param string             $url
147
     * @param FilesystemOperator $filesystem
148
     * @param string             $path
149
     *
150
     * @return int The number of bytes downloaded
151
     * @throws GuzzleException
152
     * @throws FilesystemException
153
     */
154
    public function downloadFile(string $url, FilesystemOperator $filesystem, string $path): int
155
    {
156
        // We store the data in PHP temporary storage.
157
        $tmp = fopen('php://memory', 'wb+');
158
159
        // Read from the URL
160
        $client   = new Client();
161
        $response = $client->get($url, self::GUZZLE_OPTIONS);
162
        $stream   = $response->getBody();
163
164
        // Download the file to temporary storage.
165
        while (!$stream->eof()) {
166
            $data = $stream->read(self::READ_BLOCK_SIZE);
167
168
            $bytes_written = fwrite($tmp, $data);
169
170
            if ($bytes_written !== strlen($data)) {
171
                throw new RuntimeException('Unable to write to stream.  Perhaps the disk is full?');
172
            }
173
174
            if ($this->timeout_service->isTimeNearlyUp()) {
175
                $stream->close();
176
                throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.'));
177
            }
178
        }
179
180
        $stream->close();
181
182
        // Copy from temporary storage to the file.
183
        $bytes = ftell($tmp);
184
        rewind($tmp);
185
        $filesystem->writeStream($path, $tmp);
186
        fclose($tmp);
187
188
        return $bytes;
189
    }
190
191
    /**
192
     * Move (copy and delete) all files from one filesystem to another.
193
     *
194
     * @param FilesystemOperator $source
195
     * @param FilesystemOperator $destination
196
     *
197
     * @return void
198
     * @throws FilesystemException
199
     */
200
    public function moveFiles(FilesystemOperator $source, FilesystemOperator $destination): void
201
    {
202
        foreach ($source->listContents('', FilesystemReader::LIST_DEEP) as $attributes) {
203
            if ($attributes->isFile()) {
204
                $destination->write($attributes->path(), $source->read($attributes->path()));
205
                $source->delete($attributes->path());
206
207
                if ($this->timeout_service->isTimeNearlyUp()) {
208
                    throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.'));
209
                }
210
            }
211
        }
212
    }
213
214
    /**
215
     * Delete files in $destination that aren't in $source.
216
     *
217
     * @param FilesystemOperator $filesystem
218
     * @param Collection<int,string> $folders_to_clean
219
     * @param Collection<int,string> $files_to_keep
220
     *
221
     * @return void
222
     */
223
    public function cleanFiles(FilesystemOperator $filesystem, Collection $folders_to_clean, Collection $files_to_keep): void
224
    {
225
        foreach ($folders_to_clean as $folder_to_clean) {
226
            try {
227
                foreach ($filesystem->listContents($folder_to_clean, FilesystemReader::LIST_DEEP) as $path) {
228
                    if ($path['type'] === 'file' && !$files_to_keep->contains($path['path'])) {
229
                        try {
230
                            $filesystem->delete($path['path']);
231
                        } catch (FilesystemException | UnableToDeleteFile $ex) {
232
                            // Skip to the next file.
233
                        }
234
                    }
235
236
                    // If we run out of time, then just stop.
237
                    if ($this->timeout_service->isTimeNearlyUp()) {
238
                        return;
239
                    }
240
                }
241
            } catch (FilesystemException $ex) {
242
                // Skip to the next folder.
243
            }
244
        }
245
    }
246
247
    /**
248
     * @param bool $force
249
     *
250
     * @return bool
251
     */
252
    public function isUpgradeAvailable(bool $force = false): bool
253
    {
254
        // If the latest version is unavailable, we will have an empty string which equates to version 0.
255
256
        return version_compare(Webtrees::VERSION, $this->fetchLatestVersion($force)) < 0;
257
    }
258
259
    /**
260
     * What is the latest version of webtrees.
261
     *
262
     * @return string
263
     */
264
    public function latestVersion(): string
265
    {
266
        $latest_version = $this->fetchLatestVersion(false);
267
268
        [$version] = explode('|', $latest_version);
269
270
        return $version;
271
    }
272
273
    /**
274
     * What, if any, error did we have when fetching the latest version of webtrees.
275
     *
276
     * @return string
277
     */
278
    public function latestVersionError(): string
279
    {
280
        return Site::getPreference('LATEST_WT_VERSION_ERROR');
281
    }
282
283
    /**
284
     * When did we last try to fetch the latest version of webtrees.
285
     *
286
     * @return TimestampInterface
287
     */
288
    public function latestVersionTimestamp(): TimestampInterface
289
    {
290
        $latest_version_wt_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP');
291
292
        return Registry::timestampFactory()->make($latest_version_wt_timestamp);
293
    }
294
295
    /**
296
     * Where can we download the latest version of webtrees.
297
     *
298
     * @return string
299
     */
300
    public function downloadUrl(): string
301
    {
302
        $latest_version = $this->fetchLatestVersion(false);
303
304
        [, , $url] = explode('|', $latest_version . '||');
305
306
        return $url;
307
    }
308
309
    /**
310
     * @return void
311
     */
312
    public function startMaintenanceMode(): void
313
    {
314
        $message = I18N::translate('This website is being upgraded. Try again in a few minutes.');
315
316
        file_put_contents(Webtrees::OFFLINE_FILE, $message);
317
    }
318
319
    /**
320
     * @return void
321
     */
322
    public function endMaintenanceMode(): void
323
    {
324
        if (file_exists(Webtrees::OFFLINE_FILE)) {
325
            unlink(Webtrees::OFFLINE_FILE);
326
        }
327
    }
328
329
    /**
330
     * Check with the webtrees.net server for the latest version of webtrees.
331
     * Fetching the remote file can be slow, so check infrequently, and cache the result.
332
     * Pass the current versions of webtrees, PHP and database, as the response
333
     * may be different for each. The server logs are used to generate
334
     * installation statistics which can be found at https://dev.webtrees.net/statistics.html
335
     *
336
     * @param bool $force
337
     *
338
     * @return string
339
     */
340
    private function fetchLatestVersion(bool $force): string
341
    {
342
        $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP');
343
344
        $current_timestamp = time();
345
346
        if ($force || $last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) {
347
            try {
348
                $client = new Client([
349
                    'timeout' => self::HTTP_TIMEOUT,
350
                ]);
351
352
                $response = $client->get(self::UPDATE_URL, [
353
                    'query' => $this->serverParameters(),
354
                ]);
355
356
                if ($response->getStatusCode() === StatusCodeInterface::STATUS_OK) {
357
                    Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents());
358
                    Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp);
359
                    Site::setPreference('LATEST_WT_VERSION_ERROR', '');
360
                } else {
361
                    Site::setPreference('LATEST_WT_VERSION_ERROR', 'HTTP' . $response->getStatusCode());
362
                }
363
            } catch (GuzzleException $ex) {
364
                // Can't connect to the server?
365
                // Use the existing information about latest versions.
366
                Site::setPreference('LATEST_WT_VERSION_ERROR', $ex->getMessage());
367
            }
368
        }
369
370
        return Site::getPreference('LATEST_WT_VERSION');
371
    }
372
373
    /**
374
     * The upgrade server needs to know a little about this server.
375
     *
376
     * @return array<string,string>
377
     */
378
    private function serverParameters(): array
379
    {
380
        $site_uuid = Site::getPreference('SITE_UUID');
381
382
        if ($site_uuid === '') {
383
            $site_uuid = Registry::idFactory()->uuid();
384
            Site::setPreference('SITE_UUID', $site_uuid);
385
        }
386
387
        $database_type = DB::connection()->getDriverName();
388
389
        return [
390
            'w' => Webtrees::VERSION,
391
            'p' => PHP_VERSION,
392
            's' => $site_uuid,
393
            'd' => $database_type,
394
        ];
395
    }
396
}
397