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 Carbon\Carbon; 21use Fisharebest\Webtrees\Exceptions\InternalServerErrorException; 22use Fisharebest\Webtrees\I18N; 23use Fisharebest\Webtrees\Site; 24use Fisharebest\Webtrees\Webtrees; 25use GuzzleHttp\Client; 26use GuzzleHttp\Exception\RequestException; 27use Illuminate\Support\Collection; 28use Illuminate\Support\Str; 29use League\Flysystem\Cached\CachedAdapter; 30use League\Flysystem\Cached\Storage\Memory; 31use League\Flysystem\Filesystem; 32use League\Flysystem\ZipArchive\ZipArchiveAdapter; 33use Symfony\Component\HttpFoundation\Response; 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 // Create this file to put the site into maintenance mode. 60 private const LOCK_FILE = 'data/offline.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 public function extractWebtreesZip(string $zip_file, string $target_folder) 85 { 86 // The Flysystem ZIP archive adapter is painfully slow, so use the native PHP library. 87 $zip = new ZipArchive(); 88 89 if ($zip->open($zip_file)) { 90 $zip->extractTo($target_folder); 91 $zip->close(); 92 } else { 93 throw new InternalServerErrorException('Cannot read ZIP file. Is it corrupt?'); 94 } 95 } 96 97 /** 98 * Create a list of all the files in a webtrees .ZIP archive 99 * 100 * @param string $zip_file 101 * 102 * @return Collection 103 */ 104 public function webtreesZipContents(string $zip_file): Collection 105 { 106 $zip_adapter = new ZipArchiveAdapter($zip_file, null, 'webtrees'); 107 $zip_filesystem = new Filesystem(new CachedAdapter($zip_adapter, new Memory())); 108 $paths = new Collection($zip_filesystem->listContents('', true)); 109 110 return $paths->filter(function (array $path): bool { 111 return $path['type'] === 'file'; 112 }) 113 ->map(function (array $path): string { 114 return $path['path']; 115 }); 116 } 117 118 /** 119 * Fetch a file from a URL and save it in a filesystem. 120 * Use streams so that we can copy files larger than our available memory. 121 * 122 * @param string $url 123 * @param Filesystem $filesystem 124 * @param string $path 125 * 126 * @return int The number of bytes downloaded 127 */ 128 public function downloadFile(string $url, Filesystem $filesystem, string $path): int 129 { 130 // Overwrite any previous/partial/failed download. 131 if ($filesystem->has($path)) { 132 $filesystem->delete($path); 133 } 134 135 // We store the data in PHP temporary storage. 136 $tmp = fopen('php://temp', 'w+'); 137 138 // Read from the URL 139 $client = new Client(); 140 $response = $client->get($url, self::GUZZLE_OPTIONS); 141 $stream = $response->getBody(); 142 143 // Download the file to temporary storage. 144 while (!$stream->eof()) { 145 fwrite($tmp, $stream->read(self::READ_BLOCK_SIZE)); 146 147 if ($this->timeout_service->isTimeNearlyUp()) { 148 throw new InternalServerErrorException(I18N::translate('The server’s time limit has been reached.')); 149 } 150 } 151 152 if (is_resource($stream)) { 153 fclose($stream); 154 } 155 156 // Copy from temporary storage to the file. 157 $bytes = ftell($tmp); 158 rewind($tmp); 159 $filesystem->writeStream($path, $tmp); 160 fclose($tmp); 161 162 return $bytes; 163 } 164 165 /** 166 * Move (copy and delete) all files from one filesystem to another. 167 * 168 * @param Filesystem $source 169 * @param Filesystem $destination 170 */ 171 public function moveFiles(Filesystem $source, Filesystem $destination) 172 { 173 foreach ($source->listContents() 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 public function cleanFiles(Filesystem $filesystem, Collection $folders_to_clean, Collection $files_to_keep) 193 { 194 foreach ($folders_to_clean as $folder_to_clean) { 195 foreach ($filesystem->listContents($folder_to_clean, true) as $path) { 196 if ($path['type'] === 'file' && !$files_to_keep->contains($path['path'])) { 197 $filesystem->delete($path['path']); 198 } 199 200 // If we run out of time, then just stop. 201 if ($this->timeout_service->isTimeNearlyUp()) { 202 return; 203 } 204 } 205 } 206 } 207 208 /** 209 * @return bool 210 */ 211 public function isUpgradeAvailable(): bool 212 { 213 // If the latest version is unavailable, we will have an empty sting which equates to version 0. 214 215 return version_compare(Webtrees::VERSION, $this->fetchLatestVersion()) < 0; 216 } 217 218 /** 219 * What is the latest version of webtrees. 220 * 221 * @return string 222 */ 223 public function latestVersion(): string 224 { 225 $latest_version = $this->fetchLatestVersion(); 226 227 [$version] = explode('|', $latest_version); 228 229 return $version; 230 } 231 232 /** 233 * Where can we download the latest version of webtrees. 234 * 235 * @return string 236 */ 237 public function downloadUrl(): string 238 { 239 $latest_version = $this->fetchLatestVersion(); 240 241 [, , $url] = explode('|', $latest_version . '||'); 242 243 return $url; 244 } 245 246 public function startMaintenanceMode(): void 247 { 248 $message = I18N::translate('This website is being upgraded. Try again in a few minutes.'); 249 250 file_put_contents(WT_ROOT . self::LOCK_FILE, $message); 251 } 252 253 public function endMaintenanceMode(): void 254 { 255 if (file_exists(WT_ROOT . self::LOCK_FILE)) { 256 unlink(WT_ROOT . self::LOCK_FILE); 257 } 258 } 259 260 /** 261 * Check with the webtrees.net server for the latest version of webtrees. 262 * Fetching the remote file can be slow, so check infrequently, and cache the result. 263 * Pass the current versions of webtrees, PHP and MySQL, as the response 264 * may be different for each. The server logs are used to generate 265 * installation statistics which can be found at http://dev.webtrees.net/statistics.html 266 * 267 * @return string 268 */ 269 private function fetchLatestVersion(): string 270 { 271 $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); 272 273 $current_timestamp = Carbon::now()->timestamp; 274 275 if ($last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) { 276 try { 277 $client = new Client([ 278 'timeout' => self::HTTP_TIMEOUT, 279 ]); 280 281 $response = $client->get(self::UPDATE_URL, [ 282 'query' => $this->serverParameters(), 283 ]); 284 285 if ($response->getStatusCode() === Response::HTTP_OK) { 286 Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents()); 287 Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp); 288 } 289 } catch (RequestException $ex) { 290 // Can't connect to the server? 291 // Use the existing information about latest versions. 292 } 293 } 294 295 return Site::getPreference('LATEST_WT_VERSION'); 296 } 297 298 /** 299 * The upgrade server needs to know a little about this server. 300 */ 301 private function serverParameters(): array 302 { 303 $operating_system = DIRECTORY_SEPARATOR === '/' ? 'u' : 'w'; 304 305 return [ 306 'w' => Webtrees::VERSION, 307 'p' => PHP_VERSION, 308 'o' => $operating_system, 309 ]; 310 } 311} 312