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