fisharebest /
webtrees
| 1 | <?php |
||
| 2 | |||
| 3 | /** |
||
| 4 | * webtrees: online genealogy |
||
| 5 | * Copyright (C) 2025 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 | |||
| 18 | declare(strict_types=1); |
||
| 19 | |||
| 20 | namespace Fisharebest\Webtrees\Services; |
||
| 21 | |||
| 22 | use Fig\Http\Message\StatusCodeInterface; |
||
| 23 | use Fisharebest\Webtrees\Contracts\TimestampInterface; |
||
| 24 | use Fisharebest\Webtrees\DB; |
||
| 25 | use Fisharebest\Webtrees\Http\Exceptions\HttpServerErrorException; |
||
| 26 | use Fisharebest\Webtrees\I18N; |
||
| 27 | use Fisharebest\Webtrees\Registry; |
||
| 28 | use Fisharebest\Webtrees\Site; |
||
| 29 | use Fisharebest\Webtrees\Webtrees; |
||
| 30 | use GuzzleHttp\Client; |
||
| 31 | use GuzzleHttp\Exception\GuzzleException; |
||
| 32 | use Illuminate\Support\Collection; |
||
| 33 | use League\Flysystem\Filesystem; |
||
| 34 | use League\Flysystem\FilesystemException; |
||
| 35 | use League\Flysystem\FilesystemOperator; |
||
| 36 | use League\Flysystem\FilesystemReader; |
||
| 37 | use League\Flysystem\StorageAttributes; |
||
| 38 | use League\Flysystem\UnableToDeleteFile; |
||
| 39 | use League\Flysystem\ZipArchive\FilesystemZipArchiveProvider; |
||
| 40 | use League\Flysystem\ZipArchive\ZipArchiveAdapter; |
||
| 41 | use RuntimeException; |
||
| 42 | use ZipArchive; |
||
| 43 | |||
| 44 | use function explode; |
||
| 45 | use function fclose; |
||
| 46 | use function fopen; |
||
| 47 | use function ftell; |
||
| 48 | use function fwrite; |
||
| 49 | use function rewind; |
||
| 50 | use function strlen; |
||
| 51 | use function time; |
||
| 52 | use function version_compare; |
||
| 53 | |||
| 54 | use const PHP_VERSION; |
||
| 55 | |||
| 56 | /** |
||
| 57 | * Automatic upgrades. |
||
| 58 | */ |
||
| 59 | class UpgradeService |
||
| 60 | { |
||
| 61 | // Options for fetching files using GuzzleHTTP |
||
| 62 | private const array GUZZLE_OPTIONS = [ |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 63 | 'connect_timeout' => 25, |
||
| 64 | 'read_timeout' => 25, |
||
| 65 | 'timeout' => 55, |
||
| 66 | ]; |
||
| 67 | |||
| 68 | // Transfer stream data in blocks of this number of bytes. |
||
| 69 | private const int READ_BLOCK_SIZE = 65535; |
||
| 70 | |||
| 71 | // Only check the webtrees server once per day. |
||
| 72 | private const int CHECK_FOR_UPDATE_INTERVAL = 24 * 60 * 60; |
||
| 73 | |||
| 74 | // Fetch information about upgrades from here. |
||
| 75 | // Note: earlier versions of webtrees used svn.webtrees.net, so we must maintain both URLs. |
||
| 76 | private const string UPDATE_URL = 'https://dev.webtrees.net/build/latest-version.txt'; |
||
| 77 | |||
| 78 | // If the update server doesn't respond after this time, give up. |
||
| 79 | private const float HTTP_TIMEOUT = 3.0; |
||
| 80 | |||
| 81 | public function __construct( |
||
| 82 | private readonly TimeoutService $timeout_service, |
||
| 83 | ) { |
||
| 84 | } |
||
| 85 | |||
| 86 | /** |
||
| 87 | * Unpack webtrees.zip. |
||
| 88 | * |
||
| 89 | * @param string $zip_file |
||
| 90 | * @param string $target_folder |
||
| 91 | * |
||
| 92 | * @return void |
||
| 93 | */ |
||
| 94 | public function extractWebtreesZip(string $zip_file, string $target_folder): void |
||
| 95 | { |
||
| 96 | // The Flysystem ZIP archive adapter is painfully slow, so use the native PHP library. |
||
| 97 | $zip = new ZipArchive(); |
||
| 98 | |||
| 99 | if ($zip->open($zip_file) === true) { |
||
| 100 | $zip->extractTo($target_folder); |
||
| 101 | $zip->close(); |
||
| 102 | } else { |
||
| 103 | throw new HttpServerErrorException('Cannot read ZIP file. Is it corrupt?'); |
||
| 104 | } |
||
| 105 | } |
||
| 106 | |||
| 107 | /** |
||
| 108 | * Create a list of all the files in a webtrees .ZIP archive |
||
| 109 | * |
||
| 110 | * @param string $zip_file |
||
| 111 | * |
||
| 112 | * @return Collection<int,string> |
||
| 113 | * @throws FilesystemException |
||
| 114 | */ |
||
| 115 | public function webtreesZipContents(string $zip_file): Collection |
||
| 116 | { |
||
| 117 | $zip_provider = new FilesystemZipArchiveProvider($zip_file, 0755); |
||
| 118 | $zip_adapter = new ZipArchiveAdapter($zip_provider, 'webtrees'); |
||
| 119 | $zip_filesystem = new Filesystem($zip_adapter); |
||
| 120 | |||
| 121 | $files = $zip_filesystem->listContents('', FilesystemReader::LIST_DEEP) |
||
| 122 | ->filter(static fn (StorageAttributes $attributes): bool => $attributes->isFile()) |
||
| 123 | ->map(static fn (StorageAttributes $attributes): string => $attributes->path()); |
||
| 124 | |||
| 125 | return new Collection($files); |
||
| 126 | } |
||
| 127 | |||
| 128 | /** |
||
| 129 | * Fetch a file from a URL and save it in a filesystem. |
||
| 130 | * Use streams so that we can copy files larger than our available memory. |
||
| 131 | * |
||
| 132 | * @param string $url |
||
| 133 | * @param FilesystemOperator $filesystem |
||
| 134 | * @param string $path |
||
| 135 | * |
||
| 136 | * @return int The number of bytes downloaded |
||
| 137 | * @throws GuzzleException |
||
| 138 | * @throws FilesystemException |
||
| 139 | */ |
||
| 140 | public function downloadFile(string $url, FilesystemOperator $filesystem, string $path): int |
||
| 141 | { |
||
| 142 | // We store the data in PHP temporary storage. |
||
| 143 | $tmp = fopen('php://memory', 'wb+'); |
||
| 144 | |||
| 145 | // Read from the URL |
||
| 146 | $client = new Client(); |
||
| 147 | $response = $client->get($url, self::GUZZLE_OPTIONS); |
||
| 148 | $stream = $response->getBody(); |
||
| 149 | |||
| 150 | // Download the file to temporary storage. |
||
| 151 | while (!$stream->eof()) { |
||
| 152 | $data = $stream->read(self::READ_BLOCK_SIZE); |
||
| 153 | |||
| 154 | $bytes_written = fwrite($tmp, $data); |
||
| 155 | |||
| 156 | if ($bytes_written !== strlen($data)) { |
||
| 157 | throw new RuntimeException('Unable to write to stream. Perhaps the disk is full?'); |
||
| 158 | } |
||
| 159 | |||
| 160 | if ($this->timeout_service->isTimeNearlyUp()) { |
||
| 161 | $stream->close(); |
||
| 162 | throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); |
||
| 163 | } |
||
| 164 | } |
||
| 165 | |||
| 166 | $stream->close(); |
||
| 167 | |||
| 168 | // Copy from temporary storage to the file. |
||
| 169 | $bytes = ftell($tmp); |
||
| 170 | rewind($tmp); |
||
| 171 | $filesystem->writeStream($path, $tmp); |
||
| 172 | fclose($tmp); |
||
| 173 | |||
| 174 | return $bytes; |
||
| 175 | } |
||
| 176 | |||
| 177 | /** |
||
| 178 | * Move (copy and delete) all files from one filesystem to another. |
||
| 179 | * |
||
| 180 | * @param FilesystemOperator $source |
||
| 181 | * @param FilesystemOperator $destination |
||
| 182 | * |
||
| 183 | * @return void |
||
| 184 | * @throws FilesystemException |
||
| 185 | */ |
||
| 186 | public function moveFiles(FilesystemOperator $source, FilesystemOperator $destination): void |
||
| 187 | { |
||
| 188 | foreach ($source->listContents('', FilesystemReader::LIST_DEEP) as $attributes) { |
||
| 189 | if ($attributes->isFile()) { |
||
| 190 | $destination->write($attributes->path(), $source->read($attributes->path())); |
||
| 191 | $source->delete($attributes->path()); |
||
| 192 | |||
| 193 | if ($this->timeout_service->isTimeNearlyUp()) { |
||
| 194 | throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.')); |
||
| 195 | } |
||
| 196 | } |
||
| 197 | } |
||
| 198 | } |
||
| 199 | |||
| 200 | /** |
||
| 201 | * Delete files in $destination that aren't in $source. |
||
| 202 | * |
||
| 203 | * @param FilesystemOperator $filesystem |
||
| 204 | * @param Collection<int,string> $folders_to_clean |
||
| 205 | * @param Collection<int,string> $files_to_keep |
||
| 206 | * |
||
| 207 | * @return void |
||
| 208 | */ |
||
| 209 | public function cleanFiles(FilesystemOperator $filesystem, Collection $folders_to_clean, Collection $files_to_keep): void |
||
| 210 | { |
||
| 211 | foreach ($folders_to_clean as $folder_to_clean) { |
||
| 212 | try { |
||
| 213 | foreach ($filesystem->listContents($folder_to_clean, FilesystemReader::LIST_DEEP) as $path) { |
||
| 214 | if ($path['type'] === 'file' && !$files_to_keep->contains($path['path'])) { |
||
| 215 | try { |
||
| 216 | $filesystem->delete($path['path']); |
||
| 217 | } catch (FilesystemException | UnableToDeleteFile) { |
||
| 218 | // Skip to the next file. |
||
| 219 | } |
||
| 220 | } |
||
| 221 | |||
| 222 | // If we run out of time, then just stop. |
||
| 223 | if ($this->timeout_service->isTimeNearlyUp()) { |
||
| 224 | return; |
||
| 225 | } |
||
| 226 | } |
||
| 227 | } catch (FilesystemException) { |
||
| 228 | // Skip to the next folder. |
||
| 229 | } |
||
| 230 | } |
||
| 231 | } |
||
| 232 | |||
| 233 | /** |
||
| 234 | * @param bool $force |
||
| 235 | * |
||
| 236 | * @return bool |
||
| 237 | */ |
||
| 238 | public function isUpgradeAvailable(bool $force = false): bool |
||
| 239 | { |
||
| 240 | // If the latest version is unavailable, we will have an empty string which equates to version 0. |
||
| 241 | |||
| 242 | return version_compare(Webtrees::VERSION, $this->fetchLatestVersion($force)) < 0; |
||
| 243 | } |
||
| 244 | |||
| 245 | /** |
||
| 246 | * What is the latest version of webtrees. |
||
| 247 | * |
||
| 248 | * @return string |
||
| 249 | */ |
||
| 250 | public function latestVersion(): string |
||
| 251 | { |
||
| 252 | $latest_version = $this->fetchLatestVersion(false); |
||
| 253 | |||
| 254 | [$version] = explode('|', $latest_version); |
||
| 255 | |||
| 256 | return $version; |
||
| 257 | } |
||
| 258 | |||
| 259 | /** |
||
| 260 | * What, if any, error did we have when fetching the latest version of webtrees. |
||
| 261 | * |
||
| 262 | * @return string |
||
| 263 | */ |
||
| 264 | public function latestVersionError(): string |
||
| 265 | { |
||
| 266 | return Site::getPreference('LATEST_WT_VERSION_ERROR'); |
||
| 267 | } |
||
| 268 | |||
| 269 | /** |
||
| 270 | * When did we last try to fetch the latest version of webtrees. |
||
| 271 | * |
||
| 272 | * @return TimestampInterface |
||
| 273 | */ |
||
| 274 | public function latestVersionTimestamp(): TimestampInterface |
||
| 275 | { |
||
| 276 | $latest_version_wt_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); |
||
| 277 | |||
| 278 | return Registry::timestampFactory()->make($latest_version_wt_timestamp); |
||
| 279 | } |
||
| 280 | |||
| 281 | /** |
||
| 282 | * Where can we download the latest version of webtrees. |
||
| 283 | * |
||
| 284 | * @return string |
||
| 285 | */ |
||
| 286 | public function downloadUrl(): string |
||
| 287 | { |
||
| 288 | $latest_version = $this->fetchLatestVersion(false); |
||
| 289 | |||
| 290 | [, , $url] = explode('|', $latest_version . '||'); |
||
| 291 | |||
| 292 | return $url; |
||
| 293 | } |
||
| 294 | |||
| 295 | /** |
||
| 296 | * Check with the webtrees.net server for the latest version of webtrees. |
||
| 297 | * Fetching the remote file can be slow, so check infrequently, and cache the result. |
||
| 298 | * Pass the current versions of webtrees, PHP and database, as the response |
||
| 299 | * may be different for each. The server logs are used to generate |
||
| 300 | * installation statistics which can be found at https://dev.webtrees.net/statistics.html |
||
| 301 | * |
||
| 302 | * @param bool $force |
||
| 303 | * |
||
| 304 | * @return string |
||
| 305 | */ |
||
| 306 | private function fetchLatestVersion(bool $force): string |
||
| 307 | { |
||
| 308 | $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP'); |
||
| 309 | |||
| 310 | $current_timestamp = time(); |
||
| 311 | |||
| 312 | if ($force || $last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) { |
||
| 313 | Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp); |
||
| 314 | |||
| 315 | try { |
||
| 316 | $client = new Client([ |
||
| 317 | 'timeout' => self::HTTP_TIMEOUT, |
||
| 318 | ]); |
||
| 319 | |||
| 320 | $response = $client->get(self::UPDATE_URL, [ |
||
| 321 | 'query' => $this->serverParameters(), |
||
| 322 | ]); |
||
| 323 | |||
| 324 | if ($response->getStatusCode() === StatusCodeInterface::STATUS_OK) { |
||
| 325 | Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents()); |
||
| 326 | Site::setPreference('LATEST_WT_VERSION_ERROR', ''); |
||
| 327 | } else { |
||
| 328 | Site::setPreference('LATEST_WT_VERSION_ERROR', 'HTTP' . $response->getStatusCode()); |
||
| 329 | } |
||
| 330 | } catch (GuzzleException $ex) { |
||
| 331 | // Can't connect to the server? |
||
| 332 | // Use the existing information about latest versions. |
||
| 333 | Site::setPreference('LATEST_WT_VERSION_ERROR', $ex->getMessage()); |
||
| 334 | } |
||
| 335 | } |
||
| 336 | |||
| 337 | return Site::getPreference('LATEST_WT_VERSION'); |
||
| 338 | } |
||
| 339 | |||
| 340 | /** |
||
| 341 | * The upgrade server needs to know a little about this server. |
||
| 342 | * |
||
| 343 | * @return array<string,string> |
||
| 344 | */ |
||
| 345 | private function serverParameters(): array |
||
| 346 | { |
||
| 347 | $site_uuid = Site::getPreference('SITE_UUID'); |
||
| 348 | |||
| 349 | if ($site_uuid === '') { |
||
| 350 | $site_uuid = Registry::idFactory()->uuid(); |
||
| 351 | Site::setPreference('SITE_UUID', $site_uuid); |
||
| 352 | } |
||
| 353 | |||
| 354 | return [ |
||
| 355 | 'w' => Webtrees::VERSION, |
||
| 356 | 'p' => PHP_VERSION, |
||
| 357 | 's' => $site_uuid, |
||
| 358 | 'd' => DB::driverName(), |
||
| 359 | ]; |
||
| 360 | } |
||
| 361 | } |
||
| 362 |