1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Services; 21 22use Fig\Http\Message\StatusCodeInterface; 23use Fisharebest\Webtrees\Contracts\TimestampInterface; 24use Fisharebest\Webtrees\DB; 25use Fisharebest\Webtrees\Http\Exceptions\HttpServerErrorException; 26use Fisharebest\Webtrees\I18N; 27use Fisharebest\Webtrees\Registry; 28use Fisharebest\Webtrees\Site; 29use Fisharebest\Webtrees\Webtrees; 30use GuzzleHttp\Client; 31use GuzzleHttp\Exception\GuzzleException; 32use Illuminate\Support\Collection; 33use League\Flysystem\Filesystem; 34use League\Flysystem\FilesystemException; 35use League\Flysystem\FilesystemOperator; 36use League\Flysystem\FilesystemReader; 37use League\Flysystem\StorageAttributes; 38use League\Flysystem\UnableToDeleteFile; 39use League\Flysystem\ZipArchive\FilesystemZipArchiveProvider; 40use League\Flysystem\ZipArchive\ZipArchiveAdapter; 41use RuntimeException; 42use ZipArchive; 43 44use function explode; 45use function fclose; 46use function file_exists; 47use function file_put_contents; 48use function fopen; 49use function ftell; 50use function fwrite; 51use function rewind; 52use function strlen; 53use function time; 54use function unlink; 55use function version_compare; 56 57use const PHP_VERSION; 58 59/** 60 * Automatic upgrades. 61 */ 62class UpgradeService 63{ 64 // Options for fetching files using GuzzleHTTP 65 private const GUZZLE_OPTIONS = [ 66 'connect_timeout' => 25, 67 'read_timeout' => 25, 68 'timeout' => 55, 69 ]; 70 71 // Transfer stream data in blocks of this number of bytes. 72 private const READ_BLOCK_SIZE = 65535; 73 74 // Only check the webtrees server once per day. 75 private const CHECK_FOR_UPDATE_INTERVAL = 24 * 60 * 60; 76 77 // Fetch information about upgrades from here. 78 // Note: earlier versions of webtrees used svn.webtrees.net, so we must maintain both URLs. 79 private const UPDATE_URL = 'https://dev.webtrees.net/build/latest-version.txt'; 80 81 // If the update server doesn't respond after this time, give up. 82 private const HTTP_TIMEOUT = 3.0; 83 84 private TimeoutService $timeout_service; 85 86 /** 87 * @param TimeoutService $timeout_service 88 */ 89 public function __construct(TimeoutService $timeout_service) 90 { 91 $this->timeout_service = $timeout_service; 92 } 93 94 /** 95 * Unpack webtrees.zip. 96 * 97 * @param string $zip_file 98 * @param string $target_folder 99 * 100 * @return void 101 */ 102 public function extractWebtreesZip(string $zip_file, string $target_folder): void 103 { 104 // The Flysystem ZIP archive adapter is painfully slow, so use the native PHP library. 105 $zip = new ZipArchive(); 106 107 if ($zip->open($zip_file) === true) { 108 $zip->extractTo($target_folder); 109 $zip->close(); 110 } else { 111 throw new HttpServerErrorException('Cannot read ZIP file. Is it corrupt?'); 112 } 113 } 114 115 /** 116 * Create a list of all the files in a webtrees .ZIP archive 117 * 118 * @param string $zip_file 119 * 120 * @return Collection<int,string> 121 * @throws FilesystemException 122 */ 123 public function webtreesZipContents(string $zip_file): Collection 124 { 125 $zip_provider = new FilesystemZipArchiveProvider($zip_file, 0755); 126 $zip_adapter = new ZipArchiveAdapter($zip_provider, 'webtrees'); 127 $zip_filesystem = new Filesystem($zip_adapter); 128 129 $files = $zip_filesystem->listContents('', FilesystemReader::LIST_DEEP) 130 ->filter(static fn(StorageAttributes $attributes): bool => $attributes->isFile()) 131 ->map(static fn(StorageAttributes $attributes): string => $attributes->path()); 132 133 return new Collection($files); 134 } 135 136 /** 137 * Fetch a file from a URL and save it in a filesystem. 138 * Use streams so that we can copy files larger than our available memory. 139 * 140 * @param string $url 141 * @param FilesystemOperator $filesystem 142 * @param string $path 143 * 144 * @return int The number of bytes downloaded 145 * @throws GuzzleException 146 * @throws FilesystemException 147 */ 148 public function downloadFile(string $url, FilesystemOperator $filesystem, string $path): int 149 { 150 // We store the data in PHP temporary storage. 151 $tmp = fopen('php://memory', 'wb+'); 152 153 // Read from the URL 154 $client = new Client(); 155 $response = $client->get($url, self::GUZZLE_OPTIONS); 156 $stream = $response->getBody(); 157 158 // Download the file to temporary storage. 159 while (!$stream->eof()) { 160 $data = $stream->read(self::READ_BLOCK_SIZE); 161 162 $bytes_written = fwrite($tmp, $data); 163 164 if ($bytes_written !== strlen($data)) { 165 throw new RuntimeException('Unable to write to stream. Perhaps the disk is full?'); 166 } 167 168 if ($this->timeout_service->isTimeNearlyUp()) { 169 $stream->close(); 170 throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); 171 } 172 } 173 174 $stream->close(); 175 176 // Copy from temporary storage to the file. 177 $bytes = ftell($tmp); 178 rewind($tmp); 179 $filesystem->writeStream($path, $tmp); 180 fclose($tmp); 181 182 return $bytes; 183 } 184 185 /** 186 * Move (copy and delete) all files from one filesystem to another. 187 * 188 * @param FilesystemOperator $source 189 * @param FilesystemOperator $destination 190 * 191 * @return void 192 * @throws FilesystemException 193 */ 194 public function moveFiles(FilesystemOperator $source, FilesystemOperator $destination): void 195 { 196 foreach ($source->listContents('', FilesystemReader::LIST_DEEP) as $attributes) { 197 if ($attributes->isFile()) { 198 $destination->write($attributes->path(), $source->read($attributes->path())); 199 $source->delete($attributes->path()); 200 201 if ($this->timeout_service->isTimeNearlyUp()) { 202 throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); 203 } 204 } 205 } 206 } 207 208 /** 209 * Delete files in $destination that aren't in $source. 210 * 211 * @param FilesystemOperator $filesystem 212 * @param Collection<int,string> $folders_to_clean 213 * @param Collection<int,string> $files_to_keep 214 * 215 * @return void 216 */ 217 public function cleanFiles(FilesystemOperator $filesystem, Collection $folders_to_clean, Collection $files_to_keep): void 218 { 219 foreach ($folders_to_clean as $folder_to_clean) { 220 try { 221 foreach ($filesystem->listContents($folder_to_clean, FilesystemReader::LIST_DEEP) as $path) { 222 if ($path['type'] === 'file' && !$files_to_keep->contains($path['path'])) { 223 try { 224 $filesystem->delete($path['path']); 225 } catch (FilesystemException | UnableToDeleteFile) { 226 // Skip to the next file. 227 } 228 } 229 230 // If we run out of time, then just stop. 231 if ($this->timeout_service->isTimeNearlyUp()) { 232 return; 233 } 234 } 235 } catch (FilesystemException) { 236 // Skip to the next folder. 237 } 238 } 239 } 240 241 /** 242 * @param bool $force 243 * 244 * @return bool 245 */ 246 public function isUpgradeAvailable(bool $force = false): bool 247 { 248 // If the latest version is unavailable, we will have an empty string which equates to version 0. 249 250 return version_compare(Webtrees::VERSION, $this->fetchLatestVersion($force)) < 0; 251 } 252 253 /** 254 * What is the latest version of webtrees. 255 * 256 * @return string 257 */ 258 public function latestVersion(): string 259 { 260 $latest_version = $this->fetchLatestVersion(false); 261 262 [$version] = explode('|', $latest_version); 263 264 return $version; 265 } 266 267 /** 268 * What, if any, error did we have when fetching the latest version of webtrees. 269 * 270 * @return string 271 */ 272 public function latestVersionError(): string 273 { 274 return Site::getPreference('LATEST_WT_VERSION_ERROR'); 275 } 276 277 /** 278 * When did we last try to fetch the latest version of webtrees. 279 * 280 * @return TimestampInterface 281 */ 282 public function latestVersionTimestamp(): TimestampInterface 283 { 284 $latest_version_wt_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); 285 286 return Registry::timestampFactory()->make($latest_version_wt_timestamp); 287 } 288 289 /** 290 * Where can we download the latest version of webtrees. 291 * 292 * @return string 293 */ 294 public function downloadUrl(): string 295 { 296 $latest_version = $this->fetchLatestVersion(false); 297 298 [, , $url] = explode('|', $latest_version . '||'); 299 300 return $url; 301 } 302 303 /** 304 * @return void 305 */ 306 public function startMaintenanceMode(): void 307 { 308 $message = I18N::translate('This website is being upgraded. Try again in a few minutes.'); 309 310 file_put_contents(Webtrees::OFFLINE_FILE, $message); 311 } 312 313 /** 314 * @return void 315 */ 316 public function endMaintenanceMode(): void 317 { 318 if (file_exists(Webtrees::OFFLINE_FILE)) { 319 unlink(Webtrees::OFFLINE_FILE); 320 } 321 } 322 323 /** 324 * Check with the webtrees.net server for the latest version of webtrees. 325 * Fetching the remote file can be slow, so check infrequently, and cache the result. 326 * Pass the current versions of webtrees, PHP and database, as the response 327 * may be different for each. The server logs are used to generate 328 * installation statistics which can be found at https://dev.webtrees.net/statistics.html 329 * 330 * @param bool $force 331 * 332 * @return string 333 */ 334 private function fetchLatestVersion(bool $force): string 335 { 336 $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); 337 338 $current_timestamp = time(); 339 340 if ($force || $last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) { 341 Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp); 342 343 try { 344 $client = new Client([ 345 'timeout' => self::HTTP_TIMEOUT, 346 ]); 347 348 $response = $client->get(self::UPDATE_URL, [ 349 'query' => $this->serverParameters(), 350 ]); 351 352 if ($response->getStatusCode() === StatusCodeInterface::STATUS_OK) { 353 Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents()); 354 Site::setPreference('LATEST_WT_VERSION_ERROR', ''); 355 } else { 356 Site::setPreference('LATEST_WT_VERSION_ERROR', 'HTTP' . $response->getStatusCode()); 357 } 358 } catch (GuzzleException $ex) { 359 // Can't connect to the server? 360 // Use the existing information about latest versions. 361 Site::setPreference('LATEST_WT_VERSION_ERROR', $ex->getMessage()); 362 } 363 } 364 365 return Site::getPreference('LATEST_WT_VERSION'); 366 } 367 368 /** 369 * The upgrade server needs to know a little about this server. 370 * 371 * @return array<string,string> 372 */ 373 private function serverParameters(): array 374 { 375 $site_uuid = Site::getPreference('SITE_UUID'); 376 377 if ($site_uuid === '') { 378 $site_uuid = Registry::idFactory()->uuid(); 379 Site::setPreference('SITE_UUID', $site_uuid); 380 } 381 382 $database_type = DB::connection()->getDriverName(); 383 384 return [ 385 'w' => Webtrees::VERSION, 386 'p' => PHP_VERSION, 387 's' => $site_uuid, 388 'd' => $database_type, 389 ]; 390 } 391} 392