10d11ac7eSGreg Roach<?php 23976b470SGreg Roach 30d11ac7eSGreg Roach/** 40d11ac7eSGreg Roach * webtrees: online genealogy 55bfc6897SGreg Roach * Copyright (C) 2022 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 1589f7189bSGreg Roach * along with this program. If not, see <https://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; 23*bfd5083cSGreg Roachuse Fisharebest\Webtrees\Contracts\TimestampInterface; 2481b729d3SGreg Roachuse Fisharebest\Webtrees\Http\Exceptions\HttpServerErrorException; 257def76c7SGreg Roachuse Fisharebest\Webtrees\I18N; 262e464181SGreg Roachuse Fisharebest\Webtrees\Registry; 270d11ac7eSGreg Roachuse Fisharebest\Webtrees\Site; 288d0ebef0SGreg Roachuse Fisharebest\Webtrees\Webtrees; 290d11ac7eSGreg Roachuse GuzzleHttp\Client; 304022f21aSGreg Roachuse GuzzleHttp\Exception\GuzzleException; 31c4cbcd7bSGreg Roachuse Illuminate\Database\Capsule\Manager as DB; 327def76c7SGreg Roachuse Illuminate\Support\Collection; 337def76c7SGreg Roachuse League\Flysystem\Filesystem; 34f7cf8a15SGreg Roachuse League\Flysystem\FilesystemException; 35f7cf8a15SGreg Roachuse League\Flysystem\FilesystemOperator; 36782714c2SGreg Roachuse League\Flysystem\FilesystemReader; 37f7cf8a15SGreg Roachuse League\Flysystem\StorageAttributes; 38f0448b68SGreg Roachuse League\Flysystem\UnableToDeleteFile; 39f7cf8a15SGreg Roachuse League\Flysystem\ZipArchive\FilesystemZipArchiveProvider; 407def76c7SGreg Roachuse League\Flysystem\ZipArchive\ZipArchiveAdapter; 41783b32e3SGreg Roachuse RuntimeException; 427def76c7SGreg Roachuse ZipArchive; 433976b470SGreg Roach 443551ba8dSGreg Roachuse function explode; 453551ba8dSGreg Roachuse function fclose; 463551ba8dSGreg Roachuse function file_exists; 473551ba8dSGreg Roachuse function file_put_contents; 483551ba8dSGreg Roachuse function fopen; 493551ba8dSGreg Roachuse function ftell; 503551ba8dSGreg Roachuse function fwrite; 51fbb4b472SGreg Roachuse function rewind; 52783b32e3SGreg Roachuse function strlen; 53d97083feSGreg Roachuse function time; 543551ba8dSGreg Roachuse function unlink; 553551ba8dSGreg Roachuse function version_compare; 563551ba8dSGreg Roach 573551ba8dSGreg Roachuse const PHP_VERSION; 580d11ac7eSGreg Roach 590d11ac7eSGreg Roach/** 600d11ac7eSGreg Roach * Automatic upgrades. 610d11ac7eSGreg Roach */ 620d11ac7eSGreg Roachclass UpgradeService 630d11ac7eSGreg Roach{ 647def76c7SGreg Roach // Options for fetching files using GuzzleHTTP 657def76c7SGreg Roach private const GUZZLE_OPTIONS = [ 667def76c7SGreg Roach 'connect_timeout' => 25, 677def76c7SGreg Roach 'read_timeout' => 25, 687def76c7SGreg Roach 'timeout' => 55, 697def76c7SGreg Roach ]; 707def76c7SGreg Roach 717def76c7SGreg Roach // Transfer stream data in blocks of this number of bytes. 727def76c7SGreg Roach private const READ_BLOCK_SIZE = 65535; 737def76c7SGreg Roach 747def76c7SGreg Roach // Only check the webtrees server once per day. 7516d6367aSGreg Roach private const CHECK_FOR_UPDATE_INTERVAL = 24 * 60 * 60; 760d11ac7eSGreg Roach 770d11ac7eSGreg Roach // Fetch information about upgrades from here. 780d11ac7eSGreg Roach // Note: earlier versions of webtrees used svn.webtrees.net, so we must maintain both URLs. 7916d6367aSGreg Roach private const UPDATE_URL = 'https://dev.webtrees.net/build/latest-version.txt'; 800d11ac7eSGreg Roach 810d11ac7eSGreg Roach // If the update server doesn't respond after this time, give up. 8216d6367aSGreg Roach private const HTTP_TIMEOUT = 3.0; 830d11ac7eSGreg Roach 8443f2f523SGreg Roach private TimeoutService $timeout_service; 857def76c7SGreg Roach 867def76c7SGreg Roach /** 877def76c7SGreg Roach * UpgradeService constructor. 887def76c7SGreg Roach * 897def76c7SGreg Roach * @param TimeoutService $timeout_service 907def76c7SGreg Roach */ 917def76c7SGreg Roach public function __construct(TimeoutService $timeout_service) 927def76c7SGreg Roach { 937def76c7SGreg Roach $this->timeout_service = $timeout_service; 947def76c7SGreg Roach } 957def76c7SGreg Roach 967def76c7SGreg Roach /** 977def76c7SGreg Roach * Unpack webtrees.zip. 987def76c7SGreg Roach * 997def76c7SGreg Roach * @param string $zip_file 1007def76c7SGreg Roach * @param string $target_folder 10125d7fe95SGreg Roach * 10225d7fe95SGreg Roach * @return void 1037def76c7SGreg Roach */ 10425d7fe95SGreg Roach public function extractWebtreesZip(string $zip_file, string $target_folder): void 1057def76c7SGreg Roach { 1067def76c7SGreg Roach // The Flysystem ZIP archive adapter is painfully slow, so use the native PHP library. 1077def76c7SGreg Roach $zip = new ZipArchive(); 1087def76c7SGreg Roach 109320f6a24SGreg Roach if ($zip->open($zip_file) === true) { 1107def76c7SGreg Roach $zip->extractTo($target_folder); 1117def76c7SGreg Roach $zip->close(); 1127def76c7SGreg Roach } else { 113d501c45dSGreg Roach throw new HttpServerErrorException('Cannot read ZIP file. Is it corrupt?'); 1147def76c7SGreg Roach } 1157def76c7SGreg Roach } 1167def76c7SGreg Roach 1177def76c7SGreg Roach /** 1187def76c7SGreg Roach * Create a list of all the files in a webtrees .ZIP archive 1197def76c7SGreg Roach * 1204db4b4a9SGreg Roach * @param string $zip_file 1214db4b4a9SGreg Roach * 12236779af1SGreg Roach * @return Collection<int,string> 123f7cf8a15SGreg Roach * @throws FilesystemException 1247def76c7SGreg Roach */ 1254db4b4a9SGreg Roach public function webtreesZipContents(string $zip_file): Collection 12623de9ab7SGreg Roach { 127f7cf8a15SGreg Roach $zip_provider = new FilesystemZipArchiveProvider($zip_file, 0755); 128f7cf8a15SGreg Roach $zip_adapter = new ZipArchiveAdapter($zip_provider, 'webtrees'); 129f7cf8a15SGreg Roach $zip_filesystem = new Filesystem($zip_adapter); 1307def76c7SGreg Roach 131782714c2SGreg Roach $files = $zip_filesystem->listContents('', FilesystemReader::LIST_DEEP) 132f7cf8a15SGreg Roach ->filter(static function (StorageAttributes $attributes): bool { 133f7cf8a15SGreg Roach return $attributes->isFile(); 1347def76c7SGreg Roach }) 135f7cf8a15SGreg Roach ->map(static function (StorageAttributes $attributes): string { 136f7cf8a15SGreg Roach return $attributes->path(); 1377def76c7SGreg Roach }); 138f7cf8a15SGreg Roach 139f7cf8a15SGreg Roach return new Collection($files); 1407def76c7SGreg Roach } 1417def76c7SGreg Roach 1427def76c7SGreg Roach /** 1437def76c7SGreg Roach * Fetch a file from a URL and save it in a filesystem. 1447def76c7SGreg Roach * Use streams so that we can copy files larger than our available memory. 1457def76c7SGreg Roach * 1467def76c7SGreg Roach * @param string $url 147f7cf8a15SGreg Roach * @param FilesystemOperator $filesystem 1487def76c7SGreg Roach * @param string $path 1497def76c7SGreg Roach * 1507def76c7SGreg Roach * @return int The number of bytes downloaded 1514022f21aSGreg Roach * @throws GuzzleException 152f0448b68SGreg Roach * @throws FilesystemException 1537def76c7SGreg Roach */ 154f7cf8a15SGreg Roach public function downloadFile(string $url, FilesystemOperator $filesystem, string $path): int 1557def76c7SGreg Roach { 1567def76c7SGreg Roach // We store the data in PHP temporary storage. 157ea517a3bSGreg Roach $tmp = fopen('php://memory', 'wb+'); 1587def76c7SGreg Roach 1597def76c7SGreg Roach // Read from the URL 1607def76c7SGreg Roach $client = new Client(); 1617def76c7SGreg Roach $response = $client->get($url, self::GUZZLE_OPTIONS); 1627def76c7SGreg Roach $stream = $response->getBody(); 1637def76c7SGreg Roach 1647def76c7SGreg Roach // Download the file to temporary storage. 1657def76c7SGreg Roach while (!$stream->eof()) { 166783b32e3SGreg Roach $data = $stream->read(self::READ_BLOCK_SIZE); 167783b32e3SGreg Roach 168783b32e3SGreg Roach $bytes_written = fwrite($tmp, $data); 169783b32e3SGreg Roach 170783b32e3SGreg Roach if ($bytes_written !== strlen($data)) { 171783b32e3SGreg Roach throw new RuntimeException('Unable to write to stream. Perhaps the disk is full?'); 172783b32e3SGreg Roach } 1737def76c7SGreg Roach 174fbb4b472SGreg Roach if ($this->timeout_service->isTimeNearlyUp()) { 1753551ba8dSGreg Roach $stream->close(); 176d501c45dSGreg Roach throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); 1777def76c7SGreg Roach } 1787def76c7SGreg Roach } 1797def76c7SGreg Roach 1803551ba8dSGreg Roach $stream->close(); 1817def76c7SGreg Roach 1827def76c7SGreg Roach // Copy from temporary storage to the file. 183fbb4b472SGreg Roach $bytes = ftell($tmp); 1847def76c7SGreg Roach rewind($tmp); 1857def76c7SGreg Roach $filesystem->writeStream($path, $tmp); 1867def76c7SGreg Roach fclose($tmp); 1877def76c7SGreg Roach 1887def76c7SGreg Roach return $bytes; 1897def76c7SGreg Roach } 1907def76c7SGreg Roach 1917def76c7SGreg Roach /** 1927def76c7SGreg Roach * Move (copy and delete) all files from one filesystem to another. 1937def76c7SGreg Roach * 194f7cf8a15SGreg Roach * @param FilesystemOperator $source 195f7cf8a15SGreg Roach * @param FilesystemOperator $destination 19625d7fe95SGreg Roach * 19725d7fe95SGreg Roach * @return void 198f7cf8a15SGreg Roach * @throws FilesystemException 1997def76c7SGreg Roach */ 200f7cf8a15SGreg Roach public function moveFiles(FilesystemOperator $source, FilesystemOperator $destination): void 20123de9ab7SGreg Roach { 202782714c2SGreg Roach foreach ($source->listContents('', FilesystemReader::LIST_DEEP) as $attributes) { 203f7cf8a15SGreg Roach if ($attributes->isFile()) { 204f7cf8a15SGreg Roach $destination->write($attributes->path(), $source->read($attributes->path())); 205f7cf8a15SGreg Roach $source->delete($attributes->path()); 2067def76c7SGreg Roach 207fbb4b472SGreg Roach if ($this->timeout_service->isTimeNearlyUp()) { 208d501c45dSGreg Roach throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); 2097def76c7SGreg Roach } 2107def76c7SGreg Roach } 2117def76c7SGreg Roach } 2127def76c7SGreg Roach } 2137def76c7SGreg Roach 2140d11ac7eSGreg Roach /** 215fbb4b472SGreg Roach * Delete files in $destination that aren't in $source. 216fbb4b472SGreg Roach * 217f7cf8a15SGreg Roach * @param FilesystemOperator $filesystem 21836779af1SGreg Roach * @param Collection<int,string> $folders_to_clean 21936779af1SGreg Roach * @param Collection<int,string> $files_to_keep 22025d7fe95SGreg Roach * 22125d7fe95SGreg Roach * @return void 222fbb4b472SGreg Roach */ 223f7cf8a15SGreg Roach public function cleanFiles(FilesystemOperator $filesystem, Collection $folders_to_clean, Collection $files_to_keep): void 224fbb4b472SGreg Roach { 225fbb4b472SGreg Roach foreach ($folders_to_clean as $folder_to_clean) { 226f0448b68SGreg Roach try { 227782714c2SGreg Roach foreach ($filesystem->listContents($folder_to_clean, FilesystemReader::LIST_DEEP) as $path) { 228fbb4b472SGreg Roach if ($path['type'] === 'file' && !$files_to_keep->contains($path['path'])) { 229f0448b68SGreg Roach try { 230fbb4b472SGreg Roach $filesystem->delete($path['path']); 23128d026adSGreg Roach } catch (FilesystemException | UnableToDeleteFile) { 232f0448b68SGreg Roach // Skip to the next file. 233f0448b68SGreg Roach } 234fbb4b472SGreg Roach } 235fbb4b472SGreg Roach 236fbb4b472SGreg Roach // If we run out of time, then just stop. 237fbb4b472SGreg Roach if ($this->timeout_service->isTimeNearlyUp()) { 238fbb4b472SGreg Roach return; 239fbb4b472SGreg Roach } 240fbb4b472SGreg Roach } 24128d026adSGreg Roach } catch (FilesystemException) { 242f0448b68SGreg Roach // Skip to the next folder. 243f0448b68SGreg Roach } 244fbb4b472SGreg Roach } 245fbb4b472SGreg Roach } 246fbb4b472SGreg Roach 247fbb4b472SGreg Roach /** 248*bfd5083cSGreg Roach * @param bool $force 249*bfd5083cSGreg Roach * 2500d11ac7eSGreg Roach * @return bool 2510d11ac7eSGreg Roach */ 252*bfd5083cSGreg Roach public function isUpgradeAvailable(bool $force = false): bool 2530d11ac7eSGreg Roach { 25423927444SGreg Roach // If the latest version is unavailable, we will have an empty string which equates to version 0. 2550d11ac7eSGreg Roach 256*bfd5083cSGreg Roach return version_compare(Webtrees::VERSION, $this->fetchLatestVersion($force)) < 0; 2570d11ac7eSGreg Roach } 2580d11ac7eSGreg Roach 2590d11ac7eSGreg Roach /** 2600d11ac7eSGreg Roach * What is the latest version of webtrees. 2610d11ac7eSGreg Roach * 2620d11ac7eSGreg Roach * @return string 2630d11ac7eSGreg Roach */ 2640d11ac7eSGreg Roach public function latestVersion(): string 2650d11ac7eSGreg Roach { 266*bfd5083cSGreg Roach $latest_version = $this->fetchLatestVersion(false); 2670d11ac7eSGreg Roach 26865e02381SGreg Roach [$version] = explode('|', $latest_version); 2690d11ac7eSGreg Roach 2700d11ac7eSGreg Roach return $version; 2710d11ac7eSGreg Roach } 2720d11ac7eSGreg Roach 2730d11ac7eSGreg Roach /** 274*bfd5083cSGreg Roach * What, if any, error did we have when fetching the latest version of webtrees. 275*bfd5083cSGreg Roach * 276*bfd5083cSGreg Roach * @return string 277*bfd5083cSGreg Roach */ 278*bfd5083cSGreg Roach public function latestVersionError(): string 279*bfd5083cSGreg Roach { 280*bfd5083cSGreg Roach return Site::getPreference('LATEST_WT_VERSION_ERROR'); 281*bfd5083cSGreg Roach } 282*bfd5083cSGreg Roach 283*bfd5083cSGreg Roach /** 284*bfd5083cSGreg Roach * When did we last try to fetch the latest version of webtrees. 285*bfd5083cSGreg Roach * 286*bfd5083cSGreg Roach * @return TimestampInterface 287*bfd5083cSGreg Roach */ 288*bfd5083cSGreg Roach public function latestVersionTimestamp(): TimestampInterface 289*bfd5083cSGreg Roach { 290*bfd5083cSGreg Roach $latest_version_wt_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); 291*bfd5083cSGreg Roach 292*bfd5083cSGreg Roach return Registry::timestampFactory()->make($latest_version_wt_timestamp); 293*bfd5083cSGreg Roach } 294*bfd5083cSGreg Roach 295*bfd5083cSGreg Roach /** 2960d11ac7eSGreg Roach * Where can we download the latest version of webtrees. 2970d11ac7eSGreg Roach * 2980d11ac7eSGreg Roach * @return string 2990d11ac7eSGreg Roach */ 3000d11ac7eSGreg Roach public function downloadUrl(): string 3010d11ac7eSGreg Roach { 302*bfd5083cSGreg Roach $latest_version = $this->fetchLatestVersion(false); 3030d11ac7eSGreg Roach 30465e02381SGreg Roach [, , $url] = explode('|', $latest_version . '||'); 3050d11ac7eSGreg Roach 3060d11ac7eSGreg Roach return $url; 3070d11ac7eSGreg Roach } 3080d11ac7eSGreg Roach 30992a78a2fSGreg Roach /** 31092a78a2fSGreg Roach * @return void 31192a78a2fSGreg Roach */ 3127def76c7SGreg Roach public function startMaintenanceMode(): void 3137def76c7SGreg Roach { 3147def76c7SGreg Roach $message = I18N::translate('This website is being upgraded. Try again in a few minutes.'); 3157def76c7SGreg Roach 316f397d0fdSGreg Roach file_put_contents(Webtrees::OFFLINE_FILE, $message); 3177def76c7SGreg Roach } 3187def76c7SGreg Roach 31992a78a2fSGreg Roach /** 32092a78a2fSGreg Roach * @return void 32192a78a2fSGreg Roach */ 3227def76c7SGreg Roach public function endMaintenanceMode(): void 3237def76c7SGreg Roach { 324f397d0fdSGreg Roach if (file_exists(Webtrees::OFFLINE_FILE)) { 325f397d0fdSGreg Roach unlink(Webtrees::OFFLINE_FILE); 3267def76c7SGreg Roach } 3277def76c7SGreg Roach } 3287def76c7SGreg Roach 3290d11ac7eSGreg Roach /** 3300d11ac7eSGreg Roach * Check with the webtrees.net server for the latest version of webtrees. 3310d11ac7eSGreg Roach * Fetching the remote file can be slow, so check infrequently, and cache the result. 332*bfd5083cSGreg Roach * Pass the current versions of webtrees, PHP and database, as the response 3330d11ac7eSGreg Roach * may be different for each. The server logs are used to generate 334ad3143ccSGreg Roach * installation statistics which can be found at https://dev.webtrees.net/statistics.html 3350d11ac7eSGreg Roach * 336*bfd5083cSGreg Roach * @param bool $force 337*bfd5083cSGreg Roach * 3380d11ac7eSGreg Roach * @return string 3390d11ac7eSGreg Roach */ 340*bfd5083cSGreg Roach private function fetchLatestVersion(bool $force): string 3410d11ac7eSGreg Roach { 3420d11ac7eSGreg Roach $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); 3430d11ac7eSGreg Roach 344d97083feSGreg Roach $current_timestamp = time(); 345bf57b580SGreg Roach 346*bfd5083cSGreg Roach if ($force || $last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) { 3470d11ac7eSGreg Roach try { 3480d11ac7eSGreg Roach $client = new Client([ 3490d11ac7eSGreg Roach 'timeout' => self::HTTP_TIMEOUT, 3500d11ac7eSGreg Roach ]); 3510d11ac7eSGreg Roach 3520d11ac7eSGreg Roach $response = $client->get(self::UPDATE_URL, [ 3530d11ac7eSGreg Roach 'query' => $this->serverParameters(), 3540d11ac7eSGreg Roach ]); 3550d11ac7eSGreg Roach 3566ccdf4f0SGreg Roach if ($response->getStatusCode() === StatusCodeInterface::STATUS_OK) { 3570d11ac7eSGreg Roach Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents()); 358bf57b580SGreg Roach Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp); 359*bfd5083cSGreg Roach Site::setPreference('LATEST_WT_VERSION_ERROR', ''); 360*bfd5083cSGreg Roach } else { 361*bfd5083cSGreg Roach Site::setPreference('LATEST_WT_VERSION_ERROR', 'HTTP' . $response->getStatusCode()); 3620d11ac7eSGreg Roach } 3634022f21aSGreg Roach } catch (GuzzleException $ex) { 364c6a0ce5cSGreg Roach // Can't connect to the server? 365c6a0ce5cSGreg Roach // Use the existing information about latest versions. 366*bfd5083cSGreg Roach Site::setPreference('LATEST_WT_VERSION_ERROR', $ex->getMessage()); 3670d11ac7eSGreg Roach } 3680d11ac7eSGreg Roach } 3690d11ac7eSGreg Roach 3700d11ac7eSGreg Roach return Site::getPreference('LATEST_WT_VERSION'); 3710d11ac7eSGreg Roach } 3720d11ac7eSGreg Roach 3730d11ac7eSGreg Roach /** 3740d11ac7eSGreg Roach * The upgrade server needs to know a little about this server. 3755d4b7ec2SGreg Roach * 3765d4b7ec2SGreg Roach * @return array<string,string> 3770d11ac7eSGreg Roach */ 3788f53f488SRico Sonntag private function serverParameters(): array 3790d11ac7eSGreg Roach { 380c4cbcd7bSGreg Roach $site_uuid = Site::getPreference('SITE_UUID'); 381c4cbcd7bSGreg Roach 382c4cbcd7bSGreg Roach if ($site_uuid === '') { 3832e464181SGreg Roach $site_uuid = Registry::idFactory()->uuid(); 384c4cbcd7bSGreg Roach Site::setPreference('SITE_UUID', $site_uuid); 385c4cbcd7bSGreg Roach } 386c4cbcd7bSGreg Roach 387c4cbcd7bSGreg Roach $database_type = DB::connection()->getDriverName(); 3880d11ac7eSGreg Roach 3890d11ac7eSGreg Roach return [ 3908d0ebef0SGreg Roach 'w' => Webtrees::VERSION, 3910d11ac7eSGreg Roach 'p' => PHP_VERSION, 392c4cbcd7bSGreg Roach 's' => $site_uuid, 393c4cbcd7bSGreg Roach 'd' => $database_type, 3940d11ac7eSGreg Roach ]; 3950d11ac7eSGreg Roach } 3960d11ac7eSGreg Roach} 397