[TASK] Update php-cs-fixer to 2.5.0
[Packages/TYPO3.CMS.git] / typo3 / sysext / documentation / Classes / Service / DocumentationService.php
1 <?php
2 namespace TYPO3\CMS\Documentation\Service;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Http\RequestFactory;
18 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
19 use TYPO3\CMS\Core\Utility\GeneralUtility;
20 use TYPO3\CMS\Documentation\Exception\Document;
21 use TYPO3\CMS\Documentation\Utility\MiscUtility;
22 use TYPO3\CMS\Lang\Exception\XmlParser;
23
24 /**
25 * Service class to connect to docs.typo3.org.
26 */
27 class DocumentationService
28 {
29 /**
30 * Returns the list of official documents on docs.typo3.org.
31 *
32 * @return array
33 */
34 public function getOfficialDocuments()
35 {
36 $documents = [];
37
38 $json = GeneralUtility::getUrl('https://docs.typo3.org/typo3cms/documents.json');
39 if ($json) {
40 $documents = json_decode($json, true);
41 foreach ($documents as &$document) {
42 $document['icon'] = MiscUtility::getIcon($document['key']);
43 }
44
45 // Cache file locally to be able to create a composer.json file when fetching a document
46 $absoluteCacheFilename = GeneralUtility::getFileAbsFileName('typo3temp/var/transient/documents.json');
47 GeneralUtility::writeFileToTypo3tempDir($absoluteCacheFilename, $json);
48 }
49 return $documents;
50 }
51
52 /**
53 * Returns the list of local extensions.
54 *
55 * @return array
56 */
57 public function getLocalExtensions()
58 {
59 $documents = [];
60
61 foreach ($GLOBALS['TYPO3_LOADED_EXT'] as $extensionKey => $extensionData) {
62 $absoluteExtensionPath = ExtensionManagementUtility::extPath($extensionKey);
63 if (is_file($absoluteExtensionPath . 'README.rst') || is_file($absoluteExtensionPath . 'Documentation' . DIRECTORY_SEPARATOR . 'Index.rst')) {
64 $metadata = MiscUtility::getExtensionMetaData($extensionKey);
65 if ($extensionData['type'] === 'S') {
66 $version = TYPO3_branch;
67 } else {
68 $version = substr($metadata['release'], -4) === '-dev' ? 'latest' : $metadata['release'];
69 }
70
71 $documentKey = 'typo3cms.extensions.' . $extensionKey;
72 $documents[] = [
73 'title' => $metadata['title'],
74 'icon' => MiscUtility::getIcon($documentKey),
75 'type' => 'Extension',
76 'key' => $documentKey,
77 'shortcut' => $extensionKey,
78 'url' => 'https://docs.typo3.org/typo3cms/extensions/' . $extensionKey . '/',
79 'version' => $version,
80 ];
81 }
82 }
83
84 return $documents;
85 }
86
87 /**
88 * Fetches the nearest version of a document from docs.typo3.org.
89 *
90 * Algorithm is as follows:
91 *
92 * 1) If exact version/language pair exists, fetch it
93 * 2) If document with version trimmed down to 2 digits and given language exists, fetch it
94 * 3) If document with version 'latest' and given language exists, fetch it
95 * 4) Restart at step 1) with language 'default'
96 *
97 * @param string $url
98 * @param string $key
99 * @param string $version
100 * @param string $language
101 * @return bool TRUE if fetch succeeded, otherwise FALSE
102 */
103 public function fetchNearestDocument($url, $key, $version = 'latest', $language = 'default')
104 {
105 // In case we could not find a working combination
106 $success = false;
107
108 $packages = $this->getAvailablePackages($url);
109 if (empty($packages)) {
110 return $success;
111 }
112
113 $languages = [$language];
114 if ($language !== 'default') {
115 $languages[] = 'default';
116 }
117 foreach ($languages as $language) {
118 // Step 1)
119 if (isset($packages[$version][$language])) {
120 $success |= $this->fetchDocument($url, $key, $version, $language);
121 // Fetch next language
122 continue;
123 }
124 if (isset($packages[$version])) {
125 foreach ($packages[$version] as $locale => $_) {
126 if (GeneralUtility::isFirstPartOfStr($locale, $language)) {
127 $success |= $this->fetchDocument($url, $key, $version, $locale);
128 // Fetch next language (jump current foreach up to the loop of $languages)
129 continue 2;
130 }
131 }
132 }
133
134 // Step 2)
135 if (preg_match('/^(\d+\.\d+)\.\d+$/', $version, $matches)) {
136 // Instead of a 3-digit version, try to get it on 2 digits
137 $shortVersion = $matches[1];
138 if (isset($packages[$shortVersion][$language])) {
139 $success |= $this->fetchDocument($url, $key, $shortVersion, $language);
140 // Fetch next language
141 continue;
142 }
143 }
144 // Step 3)
145 if ($version !== 'latest' && isset($packages['latest'][$language])) {
146 $success |= $this->fetchDocument($url, $key, 'latest', $language);
147 // Fetch next language
148 continue;
149 }
150 }
151
152 return $success;
153 }
154
155 /**
156 * Fetches a document from docs.typo3.org.
157 *
158 * @param string $url
159 * @param string $key
160 * @param string $version
161 * @param string $language
162 * @return bool TRUE if fetch succeeded, otherwise FALSE
163 */
164 public function fetchDocument($url, $key, $version = 'latest', $language = 'default')
165 {
166 $result = false;
167 $url = rtrim($url, '/') . '/';
168
169 $packagePrefix = substr($key, strrpos($key, '.') + 1);
170 $languageSegment = str_replace('_', '-', strtolower($language));
171 $packageName = sprintf('%s-%s-%s.zip', $packagePrefix, $version, $languageSegment);
172 $packageUrl = $url . 'packages/' . $packageName;
173 $absolutePathToZipFile = GeneralUtility::getFileAbsFileName('typo3temp/var/transient/' . $packageName);
174
175 $packages = $this->getAvailablePackages($url);
176 if (empty($packages) || !isset($packages[$version][$language])) {
177 return false;
178 }
179
180 // Check if a local version of the package is already present
181 $hasArchive = false;
182 if (is_file($absolutePathToZipFile)) {
183 $localMd5 = md5_file($absolutePathToZipFile);
184 $remoteMd5 = $packages[$version][$language];
185 $hasArchive = $localMd5 === $remoteMd5;
186 }
187
188 if (!$hasArchive) {
189 /** @var RequestFactory $requestFactory */
190 $requestFactory = GeneralUtility::makeInstance(RequestFactory::class);
191 $response = $requestFactory->request($packageUrl, 'GET');
192 if ($response->getStatusCode() === 200) {
193 GeneralUtility::writeFileToTypo3tempDir($absolutePathToZipFile, $response->getBody()->getContents());
194 }
195 }
196
197 if (is_file($absolutePathToZipFile)) {
198 $absoluteDocumentPath = GeneralUtility::getFileAbsFileName('typo3conf/Documentation/');
199
200 $result = $this->unzipDocumentPackage($absolutePathToZipFile, $absoluteDocumentPath);
201
202 // Create a composer.json file
203 $absoluteCacheFilename = GeneralUtility::getFileAbsFileName('typo3temp/var/transient/documents.json');
204 $documents = json_decode(file_get_contents($absoluteCacheFilename), true);
205 foreach ($documents as $document) {
206 if ($document['key'] === $key) {
207 $composerData = [
208 'name' => $document['title'],
209 'type' => 'documentation',
210 'description' => 'TYPO3 ' . $document['type'],
211 ];
212 $relativeComposerFilename = $key . '/' . $language . '/composer.json';
213 $absoluteComposerFilename = GeneralUtility::getFileAbsFileName('typo3conf/Documentation/' . $relativeComposerFilename);
214 GeneralUtility::writeFile($absoluteComposerFilename, json_encode($composerData));
215 break;
216 }
217 }
218 }
219
220 return $result;
221 }
222
223 /**
224 * Returns the available packages (version + language) for a given
225 * document on docs.typo3.org.
226 *
227 * @param string $url
228 * @return array
229 */
230 protected function getAvailablePackages($url)
231 {
232 $packages = [];
233 $url = rtrim($url, '/') . '/';
234 $indexUrl = $url . 'packages/packages.xml';
235
236 $remote = GeneralUtility::getUrl($indexUrl);
237 if ($remote) {
238 $packages = $this->parsePackagesXML($remote);
239 }
240
241 return $packages;
242 }
243
244 /**
245 * Parses content of packages.xml into a suitable array.
246 *
247 * @param string $string: XML data to parse
248 * @throws XmlParser
249 * @return array Array representation of XML data
250 */
251 protected function parsePackagesXML($string)
252 {
253 // Disables the functionality to allow external entities to be loaded when parsing the XML, must be kept
254 $previousValueOfEntityLoader = libxml_disable_entity_loader(true);
255 $data = json_decode(json_encode((array)simplexml_load_string($string)), true);
256 libxml_disable_entity_loader($previousValueOfEntityLoader);
257 if (count($data) !== 2) {
258 throw new XmlParser('Error in XML parser while decoding packages XML file.', 1374222437);
259 }
260
261 // SimpleXML does not properly handle arrays with only 1 item
262 if ($data['languagePackIndex']['languagepack'][0] === null) {
263 $data['languagePackIndex']['languagepack'] = [$data['languagePackIndex']['languagepack']];
264 }
265
266 $packages = [];
267 foreach ($data['languagePackIndex']['languagepack'] as $languagePack) {
268 $language = $languagePack['@attributes']['language'];
269 $version = $languagePack['@attributes']['version'];
270 $packages[$version][$language] = $languagePack['md5'];
271 }
272
273 return $packages;
274 }
275
276 /**
277 * Unzips a document package.
278 *
279 * @param string $file path to zip file
280 * @param string $path path to extract to
281 * @throws \TYPO3\CMS\Documentation\Exception\Document
282 * @return bool
283 */
284 protected function unzipDocumentPackage($file, $path)
285 {
286 $zip = zip_open($file);
287 if (is_resource($zip)) {
288 $result = true;
289
290 if (!is_dir($path)) {
291 GeneralUtility::mkdir_deep($path);
292 }
293
294 while (($zipEntry = zip_read($zip)) !== false) {
295 $zipEntryName = zip_entry_name($zipEntry);
296 if (strpos($zipEntryName, '/') !== false) {
297 $zipEntryPathSegments = explode('/', $zipEntryName);
298 $fileName = array_pop($zipEntryPathSegments);
299 // It is a folder, because the last segment is empty, let's create it
300 if (empty($fileName)) {
301 GeneralUtility::mkdir_deep($path, implode('/', $zipEntryPathSegments));
302 } else {
303 $absoluteTargetPath = GeneralUtility::getFileAbsFileName($path . implode('/', $zipEntryPathSegments) . '/' . $fileName);
304 if (trim($absoluteTargetPath) !== '') {
305 $return = GeneralUtility::writeFile(
306 $absoluteTargetPath,
307 zip_entry_read($zipEntry, zip_entry_filesize($zipEntry))
308 );
309 if ($return === false) {
310 throw new Document('Could not write file ' . $zipEntryName, 1374161546);
311 }
312 } else {
313 throw new Document('Could not write file ' . $zipEntryName, 1374161532);
314 }
315 }
316 } else {
317 throw new Document('Extension directory missing in zip file!', 1374161519);
318 }
319 }
320 } else {
321 throw new Document('Unable to open zip file ' . $file, 1374161508);
322 }
323
324 return $result;
325 }
326 }