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