xref: /webtrees/app/Services/UpgradeService.php (revision a6f12146f73e4210567ef1d5e31ca72dba65f509)
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 Carbon\Carbon;
21use Fisharebest\Webtrees\Exceptions\InternalServerErrorException;
22use Fisharebest\Webtrees\I18N;
23use Fisharebest\Webtrees\Site;
24use Fisharebest\Webtrees\Webtrees;
25use GuzzleHttp\Client;
26use GuzzleHttp\Exception\RequestException;
27use Illuminate\Support\Collection;
28use League\Flysystem\Cached\CachedAdapter;
29use League\Flysystem\Cached\Storage\Memory;
30use League\Flysystem\Filesystem;
31use League\Flysystem\ZipArchive\ZipArchiveAdapter;
32use function rewind;
33use Symfony\Component\HttpFoundation\Response;
34use ZipArchive;
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    // Create this file to put the site into maintenance mode.
59    private const LOCK_FILE = 'data/offline.txt';
60
61    // If the update server doesn't respond after this time, give up.
62    private const HTTP_TIMEOUT = 3.0;
63
64    /** @var TimeoutService */
65    private $timeout_service;
66
67    /**
68     * UpgradeService constructor.
69     *
70     * @param TimeoutService $timeout_service
71     */
72    public function __construct(TimeoutService $timeout_service)
73    {
74        $this->timeout_service = $timeout_service;
75    }
76
77    /**
78     * Unpack webtrees.zip.
79     *
80     * @param string $zip_file
81     * @param string $target_folder
82     */
83    public function extractWebtreesZip(string $zip_file, string $target_folder)
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     * @return Collection
100     */
101    public function webtreesZipContents($zip_file): Collection
102    {
103        $zip_adapter    = new ZipArchiveAdapter($zip_file, null, 'webtrees');
104        $zip_filesystem = new Filesystem(new CachedAdapter($zip_adapter, new Memory()));
105        $paths          = new Collection($zip_filesystem->listContents('', true));
106
107        return $paths->filter(function (array $path): bool {
108            return $path['type'] === 'file';
109        })
110       ->map(function (array $path): string {
111           return $path['path'];
112       });
113    }
114
115    /**
116     * Fetch a file from a URL and save it in a filesystem.
117     * Use streams so that we can copy files larger than our available memory.
118     *
119     * @param string     $url
120     * @param Filesystem $filesystem
121     * @param string     $path
122     *
123     * @return int The number of bytes downloaded
124     */
125    public function downloadFile(string $url, Filesystem $filesystem, string $path): int
126    {
127        // Overwrite any previous/partial/failed download.
128        if ($filesystem->has($path)) {
129            $filesystem->delete($path);
130        }
131
132        // We store the data in PHP temporary storage.
133        $tmp = fopen('php://temp', 'w+');
134
135        // Read from the URL
136        $client   = new Client();
137        $response = $client->get($url, self::GUZZLE_OPTIONS);
138        $stream   = $response->getBody();
139
140        // Download the file to temporary storage.
141        while (!$stream->eof()) {
142            fwrite($tmp, $stream->read(self::READ_BLOCK_SIZE));
143
144            if (!$this->timeout_service->isTimeNearlyUp()) {
145                throw new InternalServerErrorException(I18N::translate('The server’s time limit has been reached.'));
146            }
147        }
148
149        if (is_resource($stream)) {
150            fclose($stream);
151        }
152
153        // Copy from temporary storage to the file.
154        $bytes = ftell($stream);
155        rewind($tmp);
156        $filesystem->writeStream($path, $tmp);
157        fclose($tmp);
158
159        return $bytes;
160    }
161
162    /**
163     * Move (copy and delete) all files from one filesystem to another.
164     *
165     * @param Filesystem $source
166     * @param Filesystem $destination
167     */
168    public function moveFiles(Filesystem $source, Filesystem $destination)
169    {
170        foreach ($source->listContents() as $path) {
171            if ($path['type'] === 'file') {
172                $destination->put($path['path'], $source->read($path['path']));
173                $source->delete($path['path']);
174
175                if (!$this->timeout_service->isTimeNearlyUp()) {
176                    throw new InternalServerErrorException(I18N::translate('The server’s time limit has been reached.'));
177                }
178            }
179        }
180    }
181
182    /**
183     * @return bool
184     */
185    public function isUpgradeAvailable(): bool
186    {
187        // If the latest version is unavailable, we will have an empty sting which equates to version 0.
188
189        return version_compare(Webtrees::VERSION, $this->fetchLatestVersion()) < 0;
190    }
191
192    /**
193     * What is the latest version of webtrees.
194     *
195     * @return string
196     */
197    public function latestVersion(): string
198    {
199        $latest_version = $this->fetchLatestVersion();
200
201        [$version] = explode('|', $latest_version);
202
203        return $version;
204    }
205
206    /**
207     * Where can we download the latest version of webtrees.
208     *
209     * @return string
210     */
211    public function downloadUrl(): string
212    {
213        $latest_version = $this->fetchLatestVersion();
214
215        [, , $url] = explode('|', $latest_version . '||');
216
217        return $url;
218    }
219
220    public function startMaintenanceMode(): void
221    {
222        $message = I18N::translate('This website is being upgraded. Try again in a few minutes.');
223
224        file_put_contents(WT_ROOT . self::LOCK_FILE, $message);
225    }
226
227    public function endMaintenanceMode(): void
228    {
229        if (file_exists(WT_ROOT . self::LOCK_FILE)) {
230            unlink(WT_ROOT . self::LOCK_FILE);
231        }
232    }
233
234    /**
235     * Check with the webtrees.net server for the latest version of webtrees.
236     * Fetching the remote file can be slow, so check infrequently, and cache the result.
237     * Pass the current versions of webtrees, PHP and MySQL, as the response
238     * may be different for each. The server logs are used to generate
239     * installation statistics which can be found at http://dev.webtrees.net/statistics.html
240     *
241     * @return string
242     */
243    private function fetchLatestVersion(): string
244    {
245        $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP');
246
247        $current_timestamp = Carbon::now()->timestamp;
248
249        if ($last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) {
250            try {
251                $client = new Client([
252                    'timeout' => self::HTTP_TIMEOUT,
253                ]);
254
255                $response = $client->get(self::UPDATE_URL, [
256                    'query' => $this->serverParameters(),
257                ]);
258
259                if ($response->getStatusCode() === Response::HTTP_OK) {
260                    Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents());
261                    Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp);
262                }
263            } catch (RequestException $ex) {
264                // Can't connect to the server?
265                // Use the existing information about latest versions.
266            }
267        }
268
269        return Site::getPreference('LATEST_WT_VERSION');
270    }
271
272    /**
273     * The upgrade server needs to know a little about this server.
274     */
275    private function serverParameters(): array
276    {
277        $operating_system = DIRECTORY_SEPARATOR === '/' ? 'u' : 'w';
278
279        return [
280            'w' => Webtrees::VERSION,
281            'p' => PHP_VERSION,
282            'o' => $operating_system,
283        ];
284    }
285}
286