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