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