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