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