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