Issues (2559)

app/Services/UpgradeService.php (1 issue)

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