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