Completed
Push — develop ( 90766b...23de6e )
by Greg
15:33 queued 06:18
created

UpgradeService::endMaintenanceMode()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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