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