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 League\Flysystem\Cached\CachedAdapter; 29use League\Flysystem\Cached\Storage\Memory; 30use League\Flysystem\Filesystem; 31use League\Flysystem\ZipArchive\ZipArchiveAdapter; 32use function rewind; 33use Symfony\Component\HttpFoundation\Response; 34use ZipArchive; 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 // Create this file to put the site into maintenance mode. 59 private const LOCK_FILE = 'data/offline.txt'; 60 61 // If the update server doesn't respond after this time, give up. 62 private const HTTP_TIMEOUT = 3.0; 63 64 /** @var TimeoutService */ 65 private $timeout_service; 66 67 /** 68 * UpgradeService constructor. 69 * 70 * @param TimeoutService $timeout_service 71 */ 72 public function __construct(TimeoutService $timeout_service) 73 { 74 $this->timeout_service = $timeout_service; 75 } 76 77 /** 78 * Unpack webtrees.zip. 79 * 80 * @param string $zip_file 81 * @param string $target_folder 82 */ 83 public function extractWebtreesZip(string $zip_file, string $target_folder) 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 * @return Collection 100 */ 101 public function webtreesZipContents($zip_file): Collection 102 { 103 $zip_adapter = new ZipArchiveAdapter($zip_file, null, 'webtrees'); 104 $zip_filesystem = new Filesystem(new CachedAdapter($zip_adapter, new Memory())); 105 $paths = new Collection($zip_filesystem->listContents('', true)); 106 107 return $paths->filter(function (array $path): bool { 108 return $path['type'] === 'file'; 109 }) 110 ->map(function (array $path): string { 111 return $path['path']; 112 }); 113 } 114 115 /** 116 * Fetch a file from a URL and save it in a filesystem. 117 * Use streams so that we can copy files larger than our available memory. 118 * 119 * @param string $url 120 * @param Filesystem $filesystem 121 * @param string $path 122 * 123 * @return int The number of bytes downloaded 124 */ 125 public function downloadFile(string $url, Filesystem $filesystem, string $path): int 126 { 127 // Overwrite any previous/partial/failed download. 128 if ($filesystem->has($path)) { 129 $filesystem->delete($path); 130 } 131 132 // We store the data in PHP temporary storage. 133 $tmp = fopen('php://temp', 'w+'); 134 135 // Read from the URL 136 $client = new Client(); 137 $response = $client->get($url, self::GUZZLE_OPTIONS); 138 $stream = $response->getBody(); 139 140 // Download the file to temporary storage. 141 while (!$stream->eof()) { 142 fwrite($tmp, $stream->read(self::READ_BLOCK_SIZE)); 143 144 if (!$this->timeout_service->isTimeNearlyUp()) { 145 throw new InternalServerErrorException(I18N::translate('The server’s time limit has been reached.')); 146 } 147 } 148 149 if (is_resource($stream)) { 150 fclose($stream); 151 } 152 153 // Copy from temporary storage to the file. 154 $bytes = ftell($stream); 155 rewind($tmp); 156 $filesystem->writeStream($path, $tmp); 157 fclose($tmp); 158 159 return $bytes; 160 } 161 162 /** 163 * Move (copy and delete) all files from one filesystem to another. 164 * 165 * @param Filesystem $source 166 * @param Filesystem $destination 167 */ 168 public function moveFiles(Filesystem $source, Filesystem $destination) 169 { 170 foreach ($source->listContents() as $path) { 171 if ($path['type'] === 'file') { 172 $destination->put($path['path'], $source->read($path['path'])); 173 $source->delete($path['path']); 174 175 if (!$this->timeout_service->isTimeNearlyUp()) { 176 throw new InternalServerErrorException(I18N::translate('The server’s time limit has been reached.')); 177 } 178 } 179 } 180 } 181 182 /** 183 * @return bool 184 */ 185 public function isUpgradeAvailable(): bool 186 { 187 // If the latest version is unavailable, we will have an empty sting which equates to version 0. 188 189 return version_compare(Webtrees::VERSION, $this->fetchLatestVersion()) < 0; 190 } 191 192 /** 193 * What is the latest version of webtrees. 194 * 195 * @return string 196 */ 197 public function latestVersion(): string 198 { 199 $latest_version = $this->fetchLatestVersion(); 200 201 [$version] = explode('|', $latest_version); 202 203 return $version; 204 } 205 206 /** 207 * Where can we download the latest version of webtrees. 208 * 209 * @return string 210 */ 211 public function downloadUrl(): string 212 { 213 $latest_version = $this->fetchLatestVersion(); 214 215 [, , $url] = explode('|', $latest_version . '||'); 216 217 return $url; 218 } 219 220 public function startMaintenanceMode(): void 221 { 222 $message = I18N::translate('This website is being upgraded. Try again in a few minutes.'); 223 224 file_put_contents(WT_ROOT . self::LOCK_FILE, $message); 225 } 226 227 public function endMaintenanceMode(): void 228 { 229 if (file_exists(WT_ROOT . self::LOCK_FILE)) { 230 unlink(WT_ROOT . self::LOCK_FILE); 231 } 232 } 233 234 /** 235 * Check with the webtrees.net server for the latest version of webtrees. 236 * Fetching the remote file can be slow, so check infrequently, and cache the result. 237 * Pass the current versions of webtrees, PHP and MySQL, as the response 238 * may be different for each. The server logs are used to generate 239 * installation statistics which can be found at http://dev.webtrees.net/statistics.html 240 * 241 * @return string 242 */ 243 private function fetchLatestVersion(): string 244 { 245 $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); 246 247 $current_timestamp = Carbon::now()->timestamp; 248 249 if ($last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) { 250 try { 251 $client = new Client([ 252 'timeout' => self::HTTP_TIMEOUT, 253 ]); 254 255 $response = $client->get(self::UPDATE_URL, [ 256 'query' => $this->serverParameters(), 257 ]); 258 259 if ($response->getStatusCode() === Response::HTTP_OK) { 260 Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents()); 261 Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp); 262 } 263 } catch (RequestException $ex) { 264 // Can't connect to the server? 265 // Use the existing information about latest versions. 266 } 267 } 268 269 return Site::getPreference('LATEST_WT_VERSION'); 270 } 271 272 /** 273 * The upgrade server needs to know a little about this server. 274 */ 275 private function serverParameters(): array 276 { 277 $operating_system = DIRECTORY_SEPARATOR === '/' ? 'u' : 'w'; 278 279 return [ 280 'w' => Webtrees::VERSION, 281 'p' => PHP_VERSION, 282 'o' => $operating_system, 283 ]; 284 } 285} 286