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