xref: /webtrees/app/Services/UpgradeService.php (revision b5c8fd7e66957665381ee23f19cf39bda22bc768)
10d11ac7eSGreg Roach<?php
23976b470SGreg Roach
30d11ac7eSGreg Roach/**
40d11ac7eSGreg Roach * webtrees: online genealogy
58fcd0d32SGreg Roach * Copyright (C) 2019 webtrees development team
60d11ac7eSGreg Roach * This program is free software: you can redistribute it and/or modify
70d11ac7eSGreg Roach * it under the terms of the GNU General Public License as published by
80d11ac7eSGreg Roach * the Free Software Foundation, either version 3 of the License, or
90d11ac7eSGreg Roach * (at your option) any later version.
100d11ac7eSGreg Roach * This program is distributed in the hope that it will be useful,
110d11ac7eSGreg Roach * but WITHOUT ANY WARRANTY; without even the implied warranty of
120d11ac7eSGreg Roach * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
130d11ac7eSGreg Roach * GNU General Public License for more details.
140d11ac7eSGreg Roach * You should have received a copy of the GNU General Public License
150d11ac7eSGreg Roach * along with this program. If not, see <http://www.gnu.org/licenses/>.
160d11ac7eSGreg Roach */
17fcfa147eSGreg Roach
18e7f56f2aSGreg Roachdeclare(strict_types=1);
19e7f56f2aSGreg Roach
200d11ac7eSGreg Roachnamespace Fisharebest\Webtrees\Services;
210d11ac7eSGreg Roach
226ccdf4f0SGreg Roachuse Fig\Http\Message\StatusCodeInterface;
234459dc9aSGreg Roachuse Fisharebest\Webtrees\Carbon;
24d501c45dSGreg Roachuse Fisharebest\Webtrees\Exceptions\HttpServerErrorException;
257def76c7SGreg Roachuse Fisharebest\Webtrees\I18N;
260d11ac7eSGreg Roachuse Fisharebest\Webtrees\Site;
278d0ebef0SGreg Roachuse Fisharebest\Webtrees\Webtrees;
280d11ac7eSGreg Roachuse GuzzleHttp\Client;
290d11ac7eSGreg Roachuse GuzzleHttp\Exception\RequestException;
307def76c7SGreg Roachuse Illuminate\Support\Collection;
317def76c7SGreg Roachuse League\Flysystem\Cached\CachedAdapter;
327def76c7SGreg Roachuse League\Flysystem\Cached\Storage\Memory;
337def76c7SGreg Roachuse League\Flysystem\Filesystem;
34e37157fcSGreg Roachuse League\Flysystem\FilesystemInterface;
357def76c7SGreg Roachuse League\Flysystem\ZipArchive\ZipArchiveAdapter;
367def76c7SGreg Roachuse ZipArchive;
373976b470SGreg Roach
38fbb4b472SGreg Roachuse function rewind;
390d11ac7eSGreg Roach
400d11ac7eSGreg Roach/**
410d11ac7eSGreg Roach * Automatic upgrades.
420d11ac7eSGreg Roach */
430d11ac7eSGreg Roachclass UpgradeService
440d11ac7eSGreg Roach{
457def76c7SGreg Roach    // Options for fetching files using GuzzleHTTP
467def76c7SGreg Roach    private const GUZZLE_OPTIONS = [
477def76c7SGreg Roach        'connect_timeout' => 25,
487def76c7SGreg Roach        'read_timeout'    => 25,
497def76c7SGreg Roach        'timeout'         => 55,
507def76c7SGreg Roach    ];
517def76c7SGreg Roach
527def76c7SGreg Roach    // Transfer stream data in blocks of this number of bytes.
537def76c7SGreg Roach    private const READ_BLOCK_SIZE = 65535;
547def76c7SGreg Roach
557def76c7SGreg Roach    // Only check the webtrees server once per day.
5616d6367aSGreg Roach    private const CHECK_FOR_UPDATE_INTERVAL = 24 * 60 * 60;
570d11ac7eSGreg Roach
580d11ac7eSGreg Roach    // Fetch information about upgrades from here.
590d11ac7eSGreg Roach    // Note: earlier versions of webtrees used svn.webtrees.net, so we must maintain both URLs.
6016d6367aSGreg Roach    private const UPDATE_URL = 'https://dev.webtrees.net/build/latest-version.txt';
610d11ac7eSGreg Roach
620d11ac7eSGreg Roach    // If the update server doesn't respond after this time, give up.
6316d6367aSGreg Roach    private const HTTP_TIMEOUT = 3.0;
640d11ac7eSGreg Roach
657def76c7SGreg Roach    /** @var TimeoutService */
667def76c7SGreg Roach    private $timeout_service;
677def76c7SGreg Roach
687def76c7SGreg Roach    /**
697def76c7SGreg Roach     * UpgradeService constructor.
707def76c7SGreg Roach     *
717def76c7SGreg Roach     * @param TimeoutService $timeout_service
727def76c7SGreg Roach     */
737def76c7SGreg Roach    public function __construct(TimeoutService $timeout_service)
747def76c7SGreg Roach    {
757def76c7SGreg Roach        $this->timeout_service = $timeout_service;
767def76c7SGreg Roach    }
777def76c7SGreg Roach
787def76c7SGreg Roach    /**
797def76c7SGreg Roach     * Unpack webtrees.zip.
807def76c7SGreg Roach     *
817def76c7SGreg Roach     * @param string $zip_file
827def76c7SGreg Roach     * @param string $target_folder
8325d7fe95SGreg Roach     *
8425d7fe95SGreg Roach     * @return void
857def76c7SGreg Roach     */
8625d7fe95SGreg Roach    public function extractWebtreesZip(string $zip_file, string $target_folder): void
877def76c7SGreg Roach    {
887def76c7SGreg Roach        // The Flysystem ZIP archive adapter is painfully slow, so use the native PHP library.
897def76c7SGreg Roach        $zip = new ZipArchive();
907def76c7SGreg Roach
91320f6a24SGreg Roach        if ($zip->open($zip_file) === true) {
927def76c7SGreg Roach            $zip->extractTo($target_folder);
937def76c7SGreg Roach            $zip->close();
947def76c7SGreg Roach        } else {
95d501c45dSGreg Roach            throw new HttpServerErrorException('Cannot read ZIP file. Is it corrupt?');
967def76c7SGreg Roach        }
977def76c7SGreg Roach    }
987def76c7SGreg Roach
997def76c7SGreg Roach    /**
1007def76c7SGreg Roach     * Create a list of all the files in a webtrees .ZIP archive
1017def76c7SGreg Roach     *
1024db4b4a9SGreg Roach     * @param string $zip_file
1034db4b4a9SGreg Roach     *
104*b5c8fd7eSGreg Roach     * @return Collection<string>
1057def76c7SGreg Roach     */
1064db4b4a9SGreg Roach    public function webtreesZipContents(string $zip_file): Collection
10723de9ab7SGreg Roach    {
1087def76c7SGreg Roach        $zip_adapter    = new ZipArchiveAdapter($zip_file, null, 'webtrees');
1097def76c7SGreg Roach        $zip_filesystem = new Filesystem(new CachedAdapter($zip_adapter, new Memory()));
1107def76c7SGreg Roach        $paths          = new Collection($zip_filesystem->listContents('', true));
1117def76c7SGreg Roach
1120b5fd0a6SGreg Roach        return $paths->filter(static function (array $path): bool {
1137def76c7SGreg Roach            return $path['type'] === 'file';
1147def76c7SGreg Roach        })
1150b5fd0a6SGreg Roach            ->map(static function (array $path): string {
1167def76c7SGreg Roach                return $path['path'];
1177def76c7SGreg Roach            });
1187def76c7SGreg Roach    }
1197def76c7SGreg Roach
1207def76c7SGreg Roach    /**
1217def76c7SGreg Roach     * Fetch a file from a URL and save it in a filesystem.
1227def76c7SGreg Roach     * Use streams so that we can copy files larger than our available memory.
1237def76c7SGreg Roach     *
1247def76c7SGreg Roach     * @param string              $url
125e37157fcSGreg Roach     * @param FilesystemInterface $filesystem
1267def76c7SGreg Roach     * @param string              $path
1277def76c7SGreg Roach     *
1287def76c7SGreg Roach     * @return int The number of bytes downloaded
1297def76c7SGreg Roach     */
130e37157fcSGreg Roach    public function downloadFile(string $url, FilesystemInterface $filesystem, string $path): int
1317def76c7SGreg Roach    {
1327def76c7SGreg Roach        // Overwrite any previous/partial/failed download.
1337def76c7SGreg Roach        if ($filesystem->has($path)) {
1347def76c7SGreg Roach            $filesystem->delete($path);
1357def76c7SGreg Roach        }
1367def76c7SGreg Roach
1377def76c7SGreg Roach        // We store the data in PHP temporary storage.
138e364afe4SGreg Roach        $tmp = fopen('php://temp', 'wb+');
1397def76c7SGreg Roach
1407def76c7SGreg Roach        // Read from the URL
1417def76c7SGreg Roach        $client   = new Client();
1427def76c7SGreg Roach        $response = $client->get($url, self::GUZZLE_OPTIONS);
1437def76c7SGreg Roach        $stream   = $response->getBody();
1447def76c7SGreg Roach
1457def76c7SGreg Roach        // Download the file to temporary storage.
1467def76c7SGreg Roach        while (!$stream->eof()) {
1477def76c7SGreg Roach            fwrite($tmp, $stream->read(self::READ_BLOCK_SIZE));
1487def76c7SGreg Roach
149fbb4b472SGreg Roach            if ($this->timeout_service->isTimeNearlyUp()) {
150d501c45dSGreg Roach                throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.'));
1517def76c7SGreg Roach            }
1527def76c7SGreg Roach        }
1537def76c7SGreg Roach
1547def76c7SGreg Roach        if (is_resource($stream)) {
1557def76c7SGreg Roach            fclose($stream);
1567def76c7SGreg Roach        }
1577def76c7SGreg Roach
1587def76c7SGreg Roach        // Copy from temporary storage to the file.
159fbb4b472SGreg Roach        $bytes = ftell($tmp);
1607def76c7SGreg Roach        rewind($tmp);
1617def76c7SGreg Roach        $filesystem->writeStream($path, $tmp);
1627def76c7SGreg Roach        fclose($tmp);
1637def76c7SGreg Roach
1647def76c7SGreg Roach        return $bytes;
1657def76c7SGreg Roach    }
1667def76c7SGreg Roach
1677def76c7SGreg Roach    /**
1687def76c7SGreg Roach     * Move (copy and delete) all files from one filesystem to another.
1697def76c7SGreg Roach     *
170e37157fcSGreg Roach     * @param FilesystemInterface $source
171e37157fcSGreg Roach     * @param FilesystemInterface $destination
17225d7fe95SGreg Roach     *
17325d7fe95SGreg Roach     * @return void
1747def76c7SGreg Roach     */
175e37157fcSGreg Roach    public function moveFiles(FilesystemInterface $source, FilesystemInterface $destination): void
17623de9ab7SGreg Roach    {
17783f80ddbSGreg Roach        foreach ($source->listContents('', true) as $path) {
1787def76c7SGreg Roach            if ($path['type'] === 'file') {
1797def76c7SGreg Roach                $destination->put($path['path'], $source->read($path['path']));
1807def76c7SGreg Roach                $source->delete($path['path']);
1817def76c7SGreg Roach
182fbb4b472SGreg Roach                if ($this->timeout_service->isTimeNearlyUp()) {
183d501c45dSGreg Roach                    throw new HttpServerErrorException(I18N::translate('The server’s time limit has been reached.'));
1847def76c7SGreg Roach                }
1857def76c7SGreg Roach            }
1867def76c7SGreg Roach        }
1877def76c7SGreg Roach    }
1887def76c7SGreg Roach
1890d11ac7eSGreg Roach    /**
190fbb4b472SGreg Roach     * Delete files in $destination that aren't in $source.
191fbb4b472SGreg Roach     *
192e37157fcSGreg Roach     * @param FilesystemInterface $filesystem
193fbb4b472SGreg Roach     * @param Collection          $folders_to_clean
194fbb4b472SGreg Roach     * @param Collection          $files_to_keep
19525d7fe95SGreg Roach     *
19625d7fe95SGreg Roach     * @return void
197fbb4b472SGreg Roach     */
198e37157fcSGreg Roach    public function cleanFiles(FilesystemInterface $filesystem, Collection $folders_to_clean, Collection $files_to_keep): void
199fbb4b472SGreg Roach    {
200fbb4b472SGreg Roach        foreach ($folders_to_clean as $folder_to_clean) {
201fbb4b472SGreg Roach            foreach ($filesystem->listContents($folder_to_clean, true) as $path) {
202fbb4b472SGreg Roach                if ($path['type'] === 'file' && !$files_to_keep->contains($path['path'])) {
203fbb4b472SGreg Roach                    $filesystem->delete($path['path']);
204fbb4b472SGreg Roach                }
205fbb4b472SGreg Roach
206fbb4b472SGreg Roach                // If we run out of time, then just stop.
207fbb4b472SGreg Roach                if ($this->timeout_service->isTimeNearlyUp()) {
208fbb4b472SGreg Roach                    return;
209fbb4b472SGreg Roach                }
210fbb4b472SGreg Roach            }
211fbb4b472SGreg Roach        }
212fbb4b472SGreg Roach    }
213fbb4b472SGreg Roach
214fbb4b472SGreg Roach    /**
2150d11ac7eSGreg Roach     * @return bool
2160d11ac7eSGreg Roach     */
2170d11ac7eSGreg Roach    public function isUpgradeAvailable(): bool
2180d11ac7eSGreg Roach    {
2190d11ac7eSGreg Roach        // If the latest version is unavailable, we will have an empty sting which equates to version 0.
2200d11ac7eSGreg Roach
2218d0ebef0SGreg Roach        return version_compare(Webtrees::VERSION, $this->fetchLatestVersion()) < 0;
2220d11ac7eSGreg Roach    }
2230d11ac7eSGreg Roach
2240d11ac7eSGreg Roach    /**
2250d11ac7eSGreg Roach     * What is the latest version of webtrees.
2260d11ac7eSGreg Roach     *
2270d11ac7eSGreg Roach     * @return string
2280d11ac7eSGreg Roach     */
2290d11ac7eSGreg Roach    public function latestVersion(): string
2300d11ac7eSGreg Roach    {
2310d11ac7eSGreg Roach        $latest_version = $this->fetchLatestVersion();
2320d11ac7eSGreg Roach
23365e02381SGreg Roach        [$version] = explode('|', $latest_version);
2340d11ac7eSGreg Roach
2350d11ac7eSGreg Roach        return $version;
2360d11ac7eSGreg Roach    }
2370d11ac7eSGreg Roach
2380d11ac7eSGreg Roach    /**
2390d11ac7eSGreg Roach     * Where can we download the latest version of webtrees.
2400d11ac7eSGreg Roach     *
2410d11ac7eSGreg Roach     * @return string
2420d11ac7eSGreg Roach     */
2430d11ac7eSGreg Roach    public function downloadUrl(): string
2440d11ac7eSGreg Roach    {
2450d11ac7eSGreg Roach        $latest_version = $this->fetchLatestVersion();
2460d11ac7eSGreg Roach
24765e02381SGreg Roach        [, , $url] = explode('|', $latest_version . '||');
2480d11ac7eSGreg Roach
2490d11ac7eSGreg Roach        return $url;
2500d11ac7eSGreg Roach    }
2510d11ac7eSGreg Roach
2527def76c7SGreg Roach    public function startMaintenanceMode(): void
2537def76c7SGreg Roach    {
2547def76c7SGreg Roach        $message = I18N::translate('This website is being upgraded. Try again in a few minutes.');
2557def76c7SGreg Roach
256f397d0fdSGreg Roach        file_put_contents(Webtrees::OFFLINE_FILE, $message);
2577def76c7SGreg Roach    }
2587def76c7SGreg Roach
2597def76c7SGreg Roach    public function endMaintenanceMode(): void
2607def76c7SGreg Roach    {
261f397d0fdSGreg Roach        if (file_exists(Webtrees::OFFLINE_FILE)) {
262f397d0fdSGreg Roach            unlink(Webtrees::OFFLINE_FILE);
2637def76c7SGreg Roach        }
2647def76c7SGreg Roach    }
2657def76c7SGreg Roach
2660d11ac7eSGreg Roach    /**
2670d11ac7eSGreg Roach     * Check with the webtrees.net server for the latest version of webtrees.
2680d11ac7eSGreg Roach     * Fetching the remote file can be slow, so check infrequently, and cache the result.
2690d11ac7eSGreg Roach     * Pass the current versions of webtrees, PHP and MySQL, as the response
2700d11ac7eSGreg Roach     * may be different for each. The server logs are used to generate
2710d11ac7eSGreg Roach     * installation statistics which can be found at http://dev.webtrees.net/statistics.html
2720d11ac7eSGreg Roach     *
2730d11ac7eSGreg Roach     * @return string
2740d11ac7eSGreg Roach     */
2750d11ac7eSGreg Roach    private function fetchLatestVersion(): string
2760d11ac7eSGreg Roach    {
2770d11ac7eSGreg Roach        $last_update_timestamp = (int) Site::getPreference('LATEST_WT_VERSION_TIMESTAMP');
2780d11ac7eSGreg Roach
2794459dc9aSGreg Roach        $current_timestamp = Carbon::now()->unix();
280bf57b580SGreg Roach
281bf57b580SGreg Roach        if ($last_update_timestamp < $current_timestamp - self::CHECK_FOR_UPDATE_INTERVAL) {
2820d11ac7eSGreg Roach            try {
2830d11ac7eSGreg Roach                $client = new Client([
2840d11ac7eSGreg Roach                    'timeout' => self::HTTP_TIMEOUT,
2850d11ac7eSGreg Roach                ]);
2860d11ac7eSGreg Roach
2870d11ac7eSGreg Roach                $response = $client->get(self::UPDATE_URL, [
2880d11ac7eSGreg Roach                    'query' => $this->serverParameters(),
2890d11ac7eSGreg Roach                ]);
2900d11ac7eSGreg Roach
2916ccdf4f0SGreg Roach                if ($response->getStatusCode() === StatusCodeInterface::STATUS_OK) {
2920d11ac7eSGreg Roach                    Site::setPreference('LATEST_WT_VERSION', $response->getBody()->getContents());
293bf57b580SGreg Roach                    Site::setPreference('LATEST_WT_VERSION_TIMESTAMP', (string) $current_timestamp);
2940d11ac7eSGreg Roach                }
2950d11ac7eSGreg Roach            } catch (RequestException $ex) {
296c6a0ce5cSGreg Roach                // Can't connect to the server?
297c6a0ce5cSGreg Roach                // Use the existing information about latest versions.
2980d11ac7eSGreg Roach            }
2990d11ac7eSGreg Roach        }
3000d11ac7eSGreg Roach
3010d11ac7eSGreg Roach        return Site::getPreference('LATEST_WT_VERSION');
3020d11ac7eSGreg Roach    }
3030d11ac7eSGreg Roach
3040d11ac7eSGreg Roach    /**
3050d11ac7eSGreg Roach     * The upgrade server needs to know a little about this server.
3060d11ac7eSGreg Roach     */
3078f53f488SRico Sonntag    private function serverParameters(): array
3080d11ac7eSGreg Roach    {
3090d11ac7eSGreg Roach        $operating_system = DIRECTORY_SEPARATOR === '/' ? 'u' : 'w';
3100d11ac7eSGreg Roach
3110d11ac7eSGreg Roach        return [
3128d0ebef0SGreg Roach            'w' => Webtrees::VERSION,
3130d11ac7eSGreg Roach            'p' => PHP_VERSION,
3140d11ac7eSGreg Roach            'o' => $operating_system,
3150d11ac7eSGreg Roach        ];
3160d11ac7eSGreg Roach    }
3170d11ac7eSGreg Roach}
318