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