Completed
Push — master ( c92ef6...3f980f )
by
unknown
13:33
created

TerExtensionRemote::decodeExchangeData()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 13
nc 7
nop 1
dl 0
loc 19
rs 9.5222
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17
18
namespace TYPO3\CMS\Extensionmanager\Remote;
19
20
use Psr\Http\Message\ResponseInterface;
21
use TYPO3\CMS\Core\Core\Environment;
22
use TYPO3\CMS\Core\Http\RequestFactory;
23
use TYPO3\CMS\Core\Utility\GeneralUtility;
24
use TYPO3\CMS\Extensionmanager\Domain\Repository\BulkExtensionRepositoryWriter;
25
use TYPO3\CMS\Extensionmanager\Utility\FileHandlingUtility;
26
27
/**
28
 * Class for downloading .t3x files from extensions.typo3.org and validating the results.
29
 * This also includes the ListableRemoteInterface, which means it downloads extensions.xml.gz files and can import
30
 * it in the database.
31
 *
32
 * This is the only dependency for the concrete TER implementation on extensions.typo3.org and
33
 * encapsulates the definition where an extension is located in TER.
34
 *
35
 * Not responsible for:
36
 * - installing / activating an extension
37
 */
38
class TerExtensionRemote implements ExtensionDownloaderRemoteInterface, ListableRemoteInterface
39
{
40
    /**
41
     * @var string
42
     */
43
    protected $identifier;
44
45
    /**
46
     * @var string
47
     */
48
    protected $localExtensionListCacheFile;
49
50
    /**
51
     * @var string
52
     */
53
    protected $remoteBase = 'https://extensions.typo3.org/fileadmin/ter/';
54
55
    public function __construct(string $identifier, array $options = [])
56
    {
57
        $this->identifier = $identifier;
58
        $this->localExtensionListCacheFile = Environment::getVarPath() . '/extensionmanager/' . $this->identifier . '.extensions.xml.gz';
59
60
        if ($options['remoteBase'] ?? '') {
61
            $this->remoteBase = $options['remoteBase'];
62
        }
63
    }
64
65
    public function getIdentifier(): string
66
    {
67
        return $this->identifier;
68
    }
69
70
    /**
71
     * Download the xml.gz file, and extract it into the database.
72
     *
73
     * @param bool $force
74
     */
75
    public function getAvailablePackages(bool $force = false): void
76
    {
77
        if ($force || $this->needsUpdate()) {
78
            $this->fetchPackageList();
79
        }
80
    }
81
82
    public function needsUpdate(): bool
83
    {
84
        $threshold = new \DateTimeImmutable('-7 days');
85
        if ($this->getLastUpdate() < $threshold) {
86
            return true;
87
        }
88
        return $this->isDownloadedExtensionListUpToDate() !== true;
89
    }
90
91
    /**
92
     * TER provides a extensions.md5 which contains the hashsum of the current remote extensions.gz file.
93
     * Let's check if this is the same, if so, it is not needed to download a new extensions.gz.
94
     * @return bool
95
     */
96
    protected function isDownloadedExtensionListUpToDate(): bool
97
    {
98
        if (!file_exists($this->localExtensionListCacheFile)) {
99
            return false;
100
        }
101
        try {
102
            $response = $this->downloadFile('extensions.md5');
103
            $md5SumOfRemoteExtensionListFile = $response->getBody()->getContents();
104
            return hash_equals($md5SumOfRemoteExtensionListFile, md5_file($this->localExtensionListCacheFile));
105
        } catch (DownloadFailedException $exception) {
106
            return false;
107
        }
108
    }
109
110
    public function getLastUpdate(): \DateTimeInterface
111
    {
112
        if (file_exists($this->localExtensionListCacheFile) && filesize($this->localExtensionListCacheFile) > 0) {
113
            $mtime = filemtime($this->localExtensionListCacheFile);
114
            return new \DateTimeImmutable('@' . $mtime);
115
        }
116
        // Select a very old date (hint: easter egg)
117
        return new \DateTimeImmutable('1975-04-13');
118
    }
119
120
    /**
121
     * Downloads the extensions.xml.gz and imports it into the database.
122
     */
123
    protected function fetchPackageList(): void
124
    {
125
        try {
126
            $extensionListXml = $this->downloadFile('extensions.xml.gz');
127
            GeneralUtility::writeFileToTypo3tempDir($this->localExtensionListCacheFile, $extensionListXml->getBody()->getContents());
128
            GeneralUtility::makeInstance(BulkExtensionRepositoryWriter::class)->import($this->localExtensionListCacheFile, $this->identifier);
129
        } catch (DownloadFailedException $e) {
130
            // Do not update package list
131
        }
132
    }
133
134
    /**
135
     * Internal method
136
     * @param string $remotePath
137
     * @return ResponseInterface
138
     * @throws DownloadFailedException
139
     */
140
    protected function downloadFile(string $remotePath): ResponseInterface
141
    {
142
        try {
143
            $requestFactory = GeneralUtility::makeInstance(RequestFactory::class);
144
            return $requestFactory->request($this->remoteBase . $remotePath);
145
        } catch (\Throwable $e) {
146
            throw new DownloadFailedException(sprintf('The file "%s" could not be fetched. Possible reasons: network problems, allow_url_fopen is off, cURL is not available', $this->remoteBase . $remotePath), 1334426297);
147
        }
148
    }
149
150
    /**
151
     * Downloads a single extension, and extracts the t3x file into a target location folder.
152
     *
153
     * @param string $extensionKey
154
     * @param string $version
155
     * @param FileHandlingUtility $fileHandler
156
     * @param string|null $verificationHash
157
     * @param string $pathType
158
     * @throws DownloadFailedException
159
     * @throws VerificationFailedException
160
     */
161
    public function downloadExtension(string $extensionKey, string $version, FileHandlingUtility $fileHandler, string $verificationHash = null, string $pathType = 'Local'): void
162
    {
163
        $extensionPath = strtolower($extensionKey);
164
        $remotePath = $extensionPath[0] . '/' . $extensionPath[1] . '/' . $extensionPath . '_' . $version . '.t3x';
165
        try {
166
            $downloadedContent = (string)$this->downloadFile($remotePath)->getBody()->getContents();
167
        } catch (\Throwable $e) {
168
            throw new DownloadFailedException(sprintf('The T3X file "%s" could not be fetched. Possible reasons: network problems, allow_url_fopen is off, cURL is not available.', $this->remoteBase . $remotePath), 1334426097);
169
        }
170
        if ($verificationHash && !$this->isDownloadedPackageValid($verificationHash, $downloadedContent)) {
171
            throw new VerificationFailedException('MD5 hash of downloaded file not as expected: ' . md5($downloadedContent) . ' != ' . $verificationHash, 1334426098);
172
        }
173
        $extensionData = $this->decodeExchangeData($downloadedContent);
174
        if (!empty($extensionData['extKey']) && is_string($extensionData['extKey'])) {
175
            $fileHandler->unpackExtensionFromExtensionDataArray($extensionData, null, $pathType);
176
        } else {
177
            throw new VerificationFailedException('Downloaded t3x file could not be extracted', 1334426698);
178
        }
179
    }
180
181
    /**
182
     * Validates the integrity of the contents of a downloaded file.
183
     *
184
     * @param string $expectedHash
185
     * @param string $fileContents
186
     * @return bool
187
     */
188
    protected function isDownloadedPackageValid(string $expectedHash, string $fileContents): bool
189
    {
190
        return hash_equals($expectedHash, md5($fileContents));
191
    }
192
193
    /**
194
     * Decodes extension array from t3x file contents.
195
     * This kind of data is when an extension is uploaded to TER
196
     *
197
     * @param string $stream Data stream
198
     * @throws VerificationFailedException
199
     * @return array Array with result on success, otherwise an error string.
200
     */
201
    protected function decodeExchangeData(string $stream): array
202
    {
203
        [$expectedHash, $compressionType, $contents] = explode(':', $stream, 3);
204
        if ($compressionType === 'gzcompress') {
205
            if (function_exists('gzuncompress')) {
206
                $contents = gzuncompress($contents);
207
            } else {
208
                throw new VerificationFailedException('No decompressor available for compressed content. gzcompress()/gzuncompress() functions are not available', 1601370681);
209
            }
210
        }
211
        if ($this->isDownloadedPackageValid($expectedHash, $contents)) {
212
            $output = unserialize($contents, ['allowed_classes' => false]);
213
            if (!is_array($output)) {
214
                throw new VerificationFailedException('Content could not be unserialized to an array. Strange (since MD5 hashes match!)', 1601370682);
215
            }
216
        } else {
217
            throw new VerificationFailedException('MD5 mismatch. Maybe the extension file was downloaded and saved as a text file by the browser and thereby corrupted.', 1601370683);
218
        }
219
        return $output;
220
    }
221
}
222