10d11ac7eSGreg Roach<?php 23976b470SGreg Roach 30d11ac7eSGreg Roach/** 40d11ac7eSGreg Roach * webtrees: online genealogy 58fcd0d32SGreg Roach * Copyright (C) 2019 webtrees development team 60d11ac7eSGreg Roach * This program is free software: you can redistribute it and/or modify 70d11ac7eSGreg Roach * it under the terms of the GNU General Public License as published by 80d11ac7eSGreg Roach * the Free Software Foundation, either version 3 of the License, or 90d11ac7eSGreg Roach * (at your option) any later version. 100d11ac7eSGreg Roach * This program is distributed in the hope that it will be useful, 110d11ac7eSGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of 120d11ac7eSGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 130d11ac7eSGreg Roach * GNU General Public License for more details. 140d11ac7eSGreg Roach * You should have received a copy of the GNU General Public License 150d11ac7eSGreg Roach * along with this program. If not, see <http://www.gnu.org/licenses/>. 160d11ac7eSGreg Roach */ 17fcfa147eSGreg Roach 18e7f56f2aSGreg Roachdeclare(strict_types=1); 19e7f56f2aSGreg Roach 200d11ac7eSGreg Roachnamespace Fisharebest\Webtrees\Services; 210d11ac7eSGreg Roach 226ccdf4f0SGreg Roachuse Fig\Http\Message\StatusCodeInterface; 234459dc9aSGreg Roachuse Fisharebest\Webtrees\Carbon; 24d501c45dSGreg Roachuse Fisharebest\Webtrees\Exceptions\HttpServerErrorException; 257def76c7SGreg Roachuse Fisharebest\Webtrees\I18N; 260d11ac7eSGreg Roachuse Fisharebest\Webtrees\Site; 278d0ebef0SGreg Roachuse Fisharebest\Webtrees\Webtrees; 280d11ac7eSGreg Roachuse GuzzleHttp\Client; 290d11ac7eSGreg Roachuse GuzzleHttp\Exception\RequestException; 307def76c7SGreg Roachuse Illuminate\Support\Collection; 317def76c7SGreg Roachuse League\Flysystem\Cached\CachedAdapter; 327def76c7SGreg Roachuse League\Flysystem\Cached\Storage\Memory; 337def76c7SGreg Roachuse League\Flysystem\Filesystem; 34e37157fcSGreg Roachuse League\Flysystem\FilesystemInterface; 357def76c7SGreg Roachuse League\Flysystem\ZipArchive\ZipArchiveAdapter; 367def76c7SGreg Roachuse ZipArchive; 373976b470SGreg Roach 38fbb4b472SGreg Roachuse function rewind; 390d11ac7eSGreg Roach 400d11ac7eSGreg Roach/** 410d11ac7eSGreg Roach * Automatic upgrades. 420d11ac7eSGreg Roach */ 430d11ac7eSGreg Roachclass UpgradeService 440d11ac7eSGreg Roach{ 457def76c7SGreg Roach // Options for fetching files using GuzzleHTTP 467def76c7SGreg Roach private const GUZZLE_OPTIONS = [ 477def76c7SGreg Roach 'connect_timeout' => 25, 487def76c7SGreg Roach 'read_timeout' => 25, 497def76c7SGreg Roach 'timeout' => 55, 507def76c7SGreg Roach ]; 517def76c7SGreg Roach 527def76c7SGreg Roach // Transfer stream data in blocks of this number of bytes. 537def76c7SGreg Roach private const READ_BLOCK_SIZE = 65535; 547def76c7SGreg Roach 557def76c7SGreg Roach // Only check the webtrees server once per day. 5616d6367aSGreg Roach private const CHECK_FOR_UPDATE_INTERVAL = 24 * 60 * 60; 570d11ac7eSGreg Roach 580d11ac7eSGreg Roach // Fetch information about upgrades from here. 590d11ac7eSGreg Roach // Note: earlier versions of webtrees used svn.webtrees.net, so we must maintain both URLs. 6016d6367aSGreg Roach private const UPDATE_URL = 'https://dev.webtrees.net/build/latest-version.txt'; 610d11ac7eSGreg Roach 620d11ac7eSGreg Roach // If the update server doesn't respond after this time, give up. 6316d6367aSGreg Roach private const HTTP_TIMEOUT = 3.0; 640d11ac7eSGreg Roach 657def76c7SGreg Roach /** @var TimeoutService */ 667def76c7SGreg Roach private $timeout_service; 677def76c7SGreg Roach 687def76c7SGreg Roach /** 697def76c7SGreg Roach * UpgradeService constructor. 707def76c7SGreg Roach * 717def76c7SGreg Roach * @param TimeoutService $timeout_service 727def76c7SGreg Roach */ 737def76c7SGreg Roach public function __construct(TimeoutService $timeout_service) 747def76c7SGreg Roach { 757def76c7SGreg Roach $this->timeout_service = $timeout_service; 767def76c7SGreg Roach } 777def76c7SGreg Roach 787def76c7SGreg Roach /** 797def76c7SGreg Roach * Unpack webtrees.zip. 807def76c7SGreg Roach * 817def76c7SGreg Roach * @param string $zip_file 827def76c7SGreg Roach * @param string $target_folder 8325d7fe95SGreg Roach * 8425d7fe95SGreg Roach * @return void 857def76c7SGreg Roach */ 8625d7fe95SGreg Roach public function extractWebtreesZip(string $zip_file, string $target_folder): void 877def76c7SGreg Roach { 887def76c7SGreg Roach // The Flysystem ZIP archive adapter is painfully slow, so use the native PHP library. 897def76c7SGreg Roach $zip = new ZipArchive(); 907def76c7SGreg Roach 91320f6a24SGreg Roach if ($zip->open($zip_file) === true) { 927def76c7SGreg Roach $zip->extractTo($target_folder); 937def76c7SGreg Roach $zip->close(); 947def76c7SGreg Roach } else { 95d501c45dSGreg Roach throw new HttpServerErrorException('Cannot read ZIP file. Is it corrupt?'); 967def76c7SGreg Roach } 977def76c7SGreg Roach } 987def76c7SGreg Roach 997def76c7SGreg Roach /** 1007def76c7SGreg Roach * Create a list of all the files in a webtrees .ZIP archive 1017def76c7SGreg Roach * 1024db4b4a9SGreg Roach * @param string $zip_file 1034db4b4a9SGreg Roach * 104*b5c8fd7eSGreg Roach * @return Collection<string> 1057def76c7SGreg Roach */ 1064db4b4a9SGreg Roach public function webtreesZipContents(string $zip_file): Collection 10723de9ab7SGreg Roach { 1087def76c7SGreg Roach $zip_adapter = new ZipArchiveAdapter($zip_file, null, 'webtrees'); 1097def76c7SGreg Roach $zip_filesystem = new Filesystem(new CachedAdapter($zip_adapter, new Memory())); 1107def76c7SGreg Roach $paths = new Collection($zip_filesystem->listContents('', true)); 1117def76c7SGreg Roach 1120b5fd0a6SGreg Roach return $paths->filter(static function (array $path): bool { 1137def76c7SGreg Roach return $path['type'] === 'file'; 1147def76c7SGreg Roach }) 1150b5fd0a6SGreg Roach ->map(static function (array $path): string { 1167def76c7SGreg Roach return $path['path']; 1177def76c7SGreg Roach }); 1187def76c7SGreg Roach } 1197def76c7SGreg Roach 1207def76c7SGreg Roach /** 1217def76c7SGreg Roach * Fetch a file from a URL and save it in a filesystem. 1227def76c7SGreg Roach * Use streams so that we can copy files larger than our available memory. 1237def76c7SGreg Roach * 1247def76c7SGreg Roach * @param string $url 125e37157fcSGreg Roach * @param FilesystemInterface $filesystem 1267def76c7SGreg Roach * @param string $path 1277def76c7SGreg Roach * 1287def76c7SGreg Roach * @return int The number of bytes downloaded 1297def76c7SGreg Roach */ 130e37157fcSGreg Roach public function downloadFile(string $url, FilesystemInterface $filesystem, string $path): int 1317def76c7SGreg Roach { 1327def76c7SGreg Roach // Overwrite any previous/partial/failed download. 1337def76c7SGreg Roach if ($filesystem->has($path)) { 1347def76c7SGreg Roach $filesystem->delete($path); 1357def76c7SGreg Roach } 1367def76c7SGreg Roach 1377def76c7SGreg Roach // We store the data in PHP temporary storage. 138e364afe4SGreg Roach $tmp = fopen('php://temp', 'wb+'); 1397def76c7SGreg Roach 1407def76c7SGreg Roach // Read from the URL 1417def76c7SGreg Roach $client = new Client(); 1427def76c7SGreg Roach $response = $client->get($url, self::GUZZLE_OPTIONS); 1437def76c7SGreg Roach $stream = $response->getBody(); 1447def76c7SGreg Roach 1457def76c7SGreg Roach // Download the file to temporary storage. 1467def76c7SGreg Roach while (!$stream->eof()) { 1477def76c7SGreg Roach fwrite($tmp, $stream->read(self::READ_BLOCK_SIZE)); 1487def76c7SGreg Roach 149fbb4b472SGreg Roach if ($this->timeout_service->isTimeNearlyUp()) { 150d501c45dSGreg Roach throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); 1517def76c7SGreg Roach } 1527def76c7SGreg Roach } 1537def76c7SGreg Roach 1547def76c7SGreg Roach if (is_resource($stream)) { 1557def76c7SGreg Roach fclose($stream); 1567def76c7SGreg Roach } 1577def76c7SGreg Roach 1587def76c7SGreg Roach // Copy from temporary storage to the file. 159fbb4b472SGreg Roach $bytes = ftell($tmp); 1607def76c7SGreg Roach rewind($tmp); 1617def76c7SGreg Roach $filesystem->writeStream($path, $tmp); 1627def76c7SGreg Roach fclose($tmp); 1637def76c7SGreg Roach 1647def76c7SGreg Roach return $bytes; 1657def76c7SGreg Roach } 1667def76c7SGreg Roach 1677def76c7SGreg Roach /** 1687def76c7SGreg Roach * Move (copy and delete) all files from one filesystem to another. 1697def76c7SGreg Roach * 170e37157fcSGreg Roach * @param FilesystemInterface $source 171e37157fcSGreg Roach * @param FilesystemInterface $destination 17225d7fe95SGreg Roach * 17325d7fe95SGreg Roach * @return void 1747def76c7SGreg Roach */ 175e37157fcSGreg Roach public function moveFiles(FilesystemInterface $source, FilesystemInterface $destination): void 17623de9ab7SGreg Roach { 17783f80ddbSGreg Roach foreach ($source->listContents('', true) as $path) { 1787def76c7SGreg Roach if ($path['type'] === 'file') { 1797def76c7SGreg Roach $destination->put($path['path'], $source->read($path['path'])); 1807def76c7SGreg Roach $source->delete($path['path']); 1817def76c7SGreg Roach 182fbb4b472SGreg Roach if ($this->timeout_service->isTimeNearlyUp()) { 183d501c45dSGreg Roach throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); 1847def76c7SGreg Roach } 1857def76c7SGreg Roach } 1867def76c7SGreg Roach } 1877def76c7SGreg Roach } 1887def76c7SGreg Roach 1890d11ac7eSGreg Roach /** 190fbb4b472SGreg Roach * Delete files in $destination that aren't in $source. 191fbb4b472SGreg Roach * 192e37157fcSGreg Roach * @param FilesystemInterface $filesystem 193fbb4b472SGreg Roach * @param Collection $folders_to_clean 194fbb4b472SGreg Roach * @param Collection $files_to_keep 19525d7fe95SGreg Roach * 19625d7fe95SGreg Roach * @return void 197fbb4b472SGreg Roach */ 198e37157fcSGreg Roach public function cleanFiles(FilesystemInterface $filesystem, Collection $folders_to_clean, Collection $files_to_keep): void 199fbb4b472SGreg Roach { 200fbb4b472SGreg Roach foreach ($folders_to_clean as $folder_to_clean) { 201fbb4b472SGreg Roach foreach ($filesystem->listContents($folder_to_clean, true) as $path) { 202fbb4b472SGreg Roach if ($path['type'] === 'file' && !$files_to_keep->contains($path['path'])) { 203fbb4b472SGreg Roach $filesystem->delete($path['path']); 204fbb4b472SGreg Roach } 205fbb4b472SGreg Roach 206fbb4b472SGreg Roach // If we run out of time, then just stop. 207fbb4b472SGreg Roach if ($this->timeout_service->isTimeNearlyUp()) { 208fbb4b472SGreg Roach return; 209fbb4b472SGreg Roach } 210fbb4b472SGreg Roach } 211fbb4b472SGreg Roach } 212fbb4b472SGreg Roach } 213fbb4b472SGreg Roach 214fbb4b472SGreg Roach /** 2150d11ac7eSGreg Roach * @return bool 2160d11ac7eSGreg Roach */ 2170d11ac7eSGreg Roach public function isUpgradeAvailable(): bool 2180d11ac7eSGreg Roach { 2190d11ac7eSGreg Roach // If the latest version is unavailable, we will have an empty sting which equates to version 0. 2200d11ac7eSGreg Roach 2218d0ebef0SGreg Roach return version_compare(Webtrees::VERSION, $this->fetchLatestVersion()) < 0; 2220d11ac7eSGreg Roach } 2230d11ac7eSGreg Roach 2240d11ac7eSGreg Roach /** 2250d11ac7eSGreg Roach * What is the latest version of webtrees. 2260d11ac7eSGreg Roach * 2270d11ac7eSGreg Roach * @return string 2280d11ac7eSGreg Roach */ 2290d11ac7eSGreg Roach public function latestVersion(): string 2300d11ac7eSGreg Roach { 2310d11ac7eSGreg Roach $latest_version = $this->fetchLatestVersion(); 2320d11ac7eSGreg Roach 23365e02381SGreg Roach [$version] = explode('|', $latest_version); 2340d11ac7eSGreg Roach 2350d11ac7eSGreg Roach return $version; 2360d11ac7eSGreg Roach } 2370d11ac7eSGreg Roach 2380d11ac7eSGreg Roach /** 2390d11ac7eSGreg Roach * Where can we download the latest version of webtrees. 2400d11ac7eSGreg Roach * 2410d11ac7eSGreg Roach * @return string 2420d11ac7eSGreg Roach */ 2430d11ac7eSGreg Roach public function downloadUrl(): string 2440d11ac7eSGreg Roach { 2450d11ac7eSGreg Roach $latest_version = $this->fetchLatestVersion(); 2460d11ac7eSGreg Roach 24765e02381SGreg Roach [, , $url] = explode('|', $latest_version . '||'); 2480d11ac7eSGreg Roach 2490d11ac7eSGreg Roach return $url; 2500d11ac7eSGreg Roach } 2510d11ac7eSGreg Roach 2527def76c7SGreg Roach public function startMaintenanceMode(): void 2537def76c7SGreg Roach { 2547def76c7SGreg Roach $message = I18N::translate('This website is being upgraded. Try again in a few minutes.'); 2557def76c7SGreg Roach 256f397d0fdSGreg Roach file_put_contents(Webtrees::OFFLINE_FILE, $message); 2577def76c7SGreg Roach } 2587def76c7SGreg Roach 2597def76c7SGreg Roach public function endMaintenanceMode(): void 2607def76c7SGreg Roach { 261f397d0fdSGreg Roach if (file_exists(Webtrees::OFFLINE_FILE)) { 262f397d0fdSGreg Roach unlink(Webtrees::OFFLINE_FILE); 2637def76c7SGreg Roach } 2647def76c7SGreg Roach } 2657def76c7SGreg Roach 2660d11ac7eSGreg Roach /** 2670d11ac7eSGreg Roach * Check with the webtrees.net server for the latest version of webtrees. 2680d11ac7eSGreg Roach * Fetching the remote file can be slow, so check infrequently, and cache the result. 2690d11ac7eSGreg Roach * Pass the current versions of webtrees, PHP and MySQL, as the response 2700d11ac7eSGreg Roach * may be different for each. The server logs are used to generate 2710d11ac7eSGreg Roach * installation statistics which can be found at http://dev.webtrees.net/statistics.html 2720d11ac7eSGreg Roach * 2730d11ac7eSGreg Roach * @return string 2740d11ac7eSGreg Roach */ 2750d11ac7eSGreg Roach private function fetchLatestVersion(): string 2760d11ac7eSGreg Roach { 2770d11ac7eSGreg Roach $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); 2780d11ac7eSGreg Roach 2794459dc9aSGreg Roach $current_timestamp = Carbon::now()->unix(); 280bf57b580SGreg Roach 281bf57b580SGreg Roach if ($last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) { 2820d11ac7eSGreg Roach try { 2830d11ac7eSGreg Roach $client = new Client([ 2840d11ac7eSGreg Roach 'timeout' => self::HTTP_TIMEOUT, 2850d11ac7eSGreg Roach ]); 2860d11ac7eSGreg Roach 2870d11ac7eSGreg Roach $response = $client->get(self::UPDATE_URL, [ 2880d11ac7eSGreg Roach 'query' => $this->serverParameters(), 2890d11ac7eSGreg Roach ]); 2900d11ac7eSGreg Roach 2916ccdf4f0SGreg Roach if ($response->getStatusCode() === StatusCodeInterface::STATUS_OK) { 2920d11ac7eSGreg Roach Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents()); 293bf57b580SGreg Roach Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp); 2940d11ac7eSGreg Roach } 2950d11ac7eSGreg Roach } catch (RequestException $ex) { 296c6a0ce5cSGreg Roach // Can't connect to the server? 297c6a0ce5cSGreg Roach // Use the existing information about latest versions. 2980d11ac7eSGreg Roach } 2990d11ac7eSGreg Roach } 3000d11ac7eSGreg Roach 3010d11ac7eSGreg Roach return Site::getPreference('LATEST_WT_VERSION'); 3020d11ac7eSGreg Roach } 3030d11ac7eSGreg Roach 3040d11ac7eSGreg Roach /** 3050d11ac7eSGreg Roach * The upgrade server needs to know a little about this server. 3060d11ac7eSGreg Roach */ 3078f53f488SRico Sonntag private function serverParameters(): array 3080d11ac7eSGreg Roach { 3090d11ac7eSGreg Roach $operating_system = DIRECTORY_SEPARATOR === '/' ? 'u' : 'w'; 3100d11ac7eSGreg Roach 3110d11ac7eSGreg Roach return [ 3128d0ebef0SGreg Roach 'w' => Webtrees::VERSION, 3130d11ac7eSGreg Roach 'p' => PHP_VERSION, 3140d11ac7eSGreg Roach 'o' => $operating_system, 3150d11ac7eSGreg Roach ]; 3160d11ac7eSGreg Roach } 3170d11ac7eSGreg Roach} 318