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