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