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