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