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