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