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