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