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