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