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