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