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