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