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