[BUGFIX] Fix extraction path of language packs in LanguagePackService
[Packages/TYPO3.CMS.git] / typo3 / sysext / install / Classes / Service / LanguagePackService.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Install\Service;
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 use Symfony\Component\Finder\Finder;
19 use TYPO3\CMS\Core\Core\Environment;
20 use TYPO3\CMS\Core\Localization\Locales;
21 use TYPO3\CMS\Core\Package\PackageManager;
22 use TYPO3\CMS\Core\Registry;
23 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
24 use TYPO3\CMS\Core\Utility\GeneralUtility;
25 use TYPO3\CMS\Core\Utility\PathUtility;
26
27 /**
28 * Service class handling language pack details
29 * Used by 'manage language packs' module and 'language packs command'
30 *
31 * @internal Used by core only
32 */
33 class LanguagePackService
34 {
35 /**
36 * @var Locales
37 */
38 protected $locales;
39
40 /**
41 * @var Registry
42 */
43 protected $registry;
44
45 public function __construct()
46 {
47 $this->locales = GeneralUtility::makeInstance(Locales::class);
48 $this->registry = GeneralUtility::makeInstance(Registry::class);
49 }
50
51 /**
52 * Get list of available languages
53 *
54 * @return array iso=>name
55 */
56 public function getAvailableLanguages(): array
57 {
58 return $this->locales->getLanguages();
59 }
60
61 /**
62 * List of languages active in this instance
63 *
64 * @return array
65 */
66 public function getActiveLanguages(): array
67 {
68 return $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['lang']['availableLanguages'] ?? [];
69 }
70
71 /**
72 * Create an array with language details: active or not, iso codes, last update, ...
73 *
74 * @return array
75 */
76 public function getLanguageDetails(): array
77 {
78 $availableLanguages = $this->getAvailableLanguages();
79 $activeLanguages = $this->getActiveLanguages();
80 $languages = [];
81 foreach ($availableLanguages as $iso => $name) {
82 if ($iso === 'default') {
83 continue;
84 }
85 $lastUpdate = $this->registry->get('languagePacks', $iso);
86 $languages[] = [
87 'iso' => $iso,
88 'name' => $name,
89 'active' => in_array($iso, $activeLanguages, true),
90 'lastUpdate' => $this->getFormattedDate($lastUpdate),
91 'dependencies' => $this->locales->getLocaleDependencies($iso),
92 ];
93 }
94 usort($languages, function ($a, $b) {
95 // Sort languages by name
96 if ($a['name'] === $b['name']) {
97 return 0;
98 }
99 return $a['name'] < $b['name'] ? -1 : 1;
100 });
101 return $languages;
102 }
103
104 /**
105 * Create a list of loaded extensions and their language packs details
106 *
107 * @return array
108 */
109 public function getExtensionLanguagePackDetails(): array
110 {
111 $activeLanguages = $this->getActiveLanguages();
112 $packageManager = GeneralUtility::makeInstance(PackageManager::class);
113 $activePackages = $packageManager->getActivePackages();
114 $extensions = [];
115 $activeExtensions = [];
116 foreach ($activePackages as $package) {
117 $path = $package->getPackagePath();
118 $finder = new Finder();
119 try {
120 $files = $finder->files()->in($path . 'Resources/Private/Language/')->name('*.xlf');
121 if ($files->count() === 0) {
122 // This extension has no .xlf files
123 continue;
124 }
125 } catch (\InvalidArgumentException $e) {
126 // Dir does not exist
127 continue;
128 }
129 $key = $package->getPackageKey();
130 $activeExtensions[] = $key;
131 $title = $package->getValueFromComposerManifest('description') ?? '';
132 if (is_file($path . 'ext_emconf.php')) {
133 $_EXTKEY = $key;
134 $EM_CONF = [];
135 include $path . 'ext_emconf.php';
136 $title = $EM_CONF[$key]['title'] ?? $title;
137 }
138 $extension = [
139 'key' => $key,
140 'title' => $title,
141 'icon' => PathUtility::stripPathSitePrefix(ExtensionManagementUtility::getExtensionIcon($path, true)),
142 ];
143 $extension['packs'] = [];
144 foreach ($activeLanguages as $iso) {
145 $isLanguagePackDownloaded = is_dir(GeneralUtility::getFileAbsFileName('typo3conf/l10n/' . $iso . '/' . $key . '/'));
146 $lastUpdate = $this->registry->get('languagePacks', $iso . '-' . $key);
147 $extension['packs'][] = [
148 'iso' => $iso,
149 'exists' => $isLanguagePackDownloaded,
150 'lastUpdate' => $this->getFormattedDate($lastUpdate),
151 ];
152 }
153 $extensions[] = $extension;
154 }
155 usort($extensions, function ($a, $b) {
156 // Sort extensions by key
157 if ($a['key'] === $b['key']) {
158 return 0;
159 }
160 return $a['key'] < $b['key'] ? -1 : 1;
161 });
162 return $extensions;
163 }
164
165 /**
166 * Update main language pack download location if possible.
167 * Store to registry to be used during language pack update
168 *
169 * @return string
170 */
171 public function updateMirrorBaseUrl(): string
172 {
173 $downloadBaseUrl = false;
174 try {
175 $xmlContent = GeneralUtility::getUrl('https://repositories.typo3.org/mirrors.xml.gz');
176 $xmlContent = GeneralUtility::xml2array(@gzdecode($xmlContent));
177 if (!empty($xmlContent['mirror']['host']) && !empty($xmlContent['mirror']['path'])) {
178 $downloadBaseUrl = 'https://' . $xmlContent['mirror']['host'] . $xmlContent['mirror']['path'];
179 }
180 } catch (\Exception $e) {
181 // Catch generic exception, fallback handled below
182 }
183 if (empty($downloadBaseUrl)) {
184 // Hard coded fallback if something went wrong fetching & parsing mirror list
185 $downloadBaseUrl = 'https://typo3.org/fileadmin/ter/';
186 }
187 $this->registry->set('languagePacks', 'baseUrl', $downloadBaseUrl);
188 return $downloadBaseUrl;
189 }
190
191 /**
192 * Download and unpack a single language pack of one extension.
193 *
194 * @param string $key Extension key
195 * @param string $iso Language iso code
196 * @return string One of 'update', 'new' or 'failed'
197 * @throws \RuntimeException
198 */
199 public function languagePackDownload(string $key, string $iso): string
200 {
201 // Sanitize extension and iso code
202 $availableLanguages = $this->getAvailableLanguages();
203 $activeLanguages = $this->getActiveLanguages();
204 if (!in_array($iso, array_keys($availableLanguages), true) || !in_array($iso, $activeLanguages, true)) {
205 throw new \RuntimeException('Language iso code ' . (string)$iso . ' not available or active', 1520117054);
206 }
207 $packageManager = GeneralUtility::makeInstance(PackageManager::class);
208 $activePackages = $packageManager->getActivePackages();
209 $packageActive = false;
210 foreach ($activePackages as $package) {
211 if ($package->getPackageKey() === $key) {
212 $packageActive = true;
213 break;
214 }
215 }
216 if (!$packageActive) {
217 throw new \RuntimeException('Extension ' . (string)$key . ' not loaded', 1520117245);
218 }
219
220 $languagePackBaseUrl = $this->registry->get('languagePacks', 'baseUrl');
221 if (empty($languagePackBaseUrl)) {
222 throw new \RuntimeException('Language pack baseUrl not found', 1520169691);
223 }
224
225 $path = ExtensionManagementUtility::extPath($key);
226 $majorVersion = explode('.', TYPO3_branch)[0];
227 if (strpos($path, '/sysext/') !== false) {
228 // This is a system extension and the package URL should be adapted to have different packs per core major version
229 // https://typo3.org/fileadmin/ter/b/a/backend-l10n/backend-l10n-fr.v9.zip
230 $packageUrl = $key[0] . '/' . $key[1] . '/' . $key . '-l10n/' . $key . '-l10n-' . $iso . '.v' . $majorVersion . '.zip';
231 } else {
232 // Typical non sysext path, Hungarian:
233 // https://typo3.org/fileadmin/ter/a/n/anextension-l10n/anextension-l10n-hu.zip
234 $packageUrl = $key[0] . '/' . $key[1] . '/' . $key . '-l10n/' . $key . '-l10n-' . $iso . '.zip';
235 }
236
237 $absoluteLanguagePath = GeneralUtility::getFileAbsFileName('typo3conf/l10n/' . $iso . '/');
238 $absoluteExtractionPath = $absoluteLanguagePath . $key . '/';
239 $absolutePathToZipFile = Environment::getVarPath() . '/transient/' . $key . '-l10n-' . $iso . '.zip';
240
241 $packExists = is_dir($absoluteExtractionPath);
242
243 $packResult = $packExists ? 'update' : 'new';
244
245 $operationResult = false;
246 try {
247 $languagePackContent = GeneralUtility::getUrl($languagePackBaseUrl . $packageUrl);
248 if (!empty($languagePackContent)) {
249 $operationResult = true;
250 if ($packExists) {
251 $operationResult = GeneralUtility::rmdir($absoluteExtractionPath, true);
252 }
253 if ($operationResult) {
254 GeneralUtility::mkdir_deep(Environment::getVarPath() . '/transient/');
255 $operationResult = GeneralUtility::writeFileToTypo3tempDir($absolutePathToZipFile, $languagePackContent) === null;
256 }
257 $this->unzipTranslationFile($absolutePathToZipFile, $absoluteLanguagePath);
258 if ($operationResult) {
259 $operationResult = unlink($absolutePathToZipFile);
260 }
261 }
262 } catch (\Exception $e) {
263 $operationResult = false;
264 }
265 if (!$operationResult) {
266 $packResult = 'failed';
267 $this->registry->set('languagePacks', $iso . '-' . $key, time());
268 }
269 return $packResult;
270 }
271
272 /**
273 * Set 'last update' timestamp in registry for a series of iso codes.
274 *
275 * @param string[] $isos List of iso code timestamps to set
276 * @throws \RuntimeException
277 */
278 public function setLastUpdatedIsoCode(array $isos)
279 {
280 $activeLanguages = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['lang']['availableLanguages'] ?? [];
281 $registry = GeneralUtility::makeInstance(Registry::class);
282 foreach ($isos as $iso) {
283 if (!in_array($iso, $activeLanguages, true)) {
284 throw new \RuntimeException('Language iso code ' . (string)$iso . ' not available or active', 1520176318);
285 }
286 $registry->set('languagePacks', $iso, time());
287 }
288 }
289
290 /**
291 * Format a timestamp to a formatted date string
292 *
293 * @param $timestamp int|null
294 * @return string|null
295 */
296 protected function getFormattedDate($timestamp)
297 {
298 if (is_int($timestamp)) {
299 $date = new \DateTime('@' . $timestamp);
300 $format = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'];
301 $timestamp = $date->format($format);
302 }
303 return $timestamp;
304 }
305
306 /**
307 * Unzip an language zip file
308 *
309 * @param string $file path to zip file
310 * @param string $path path to extract to
311 * @throws \RuntimeException
312 */
313 protected function unzipTranslationFile(string $file, string $path)
314 {
315 $zip = zip_open($file);
316 if (is_resource($zip)) {
317 if (!is_dir($path)) {
318 GeneralUtility::mkdir_deep($path);
319 }
320 while (($zipEntry = zip_read($zip)) !== false) {
321 $zipEntryName = zip_entry_name($zipEntry);
322 if (strpos($zipEntryName, '/') !== false) {
323 $zipEntryPathSegments = explode('/', $zipEntryName);
324 $fileName = array_pop($zipEntryPathSegments);
325 // It is a folder, because the last segment is empty, let's create it
326 if (empty($fileName)) {
327 GeneralUtility::mkdir_deep($path . implode('/', $zipEntryPathSegments));
328 } else {
329 $absoluteTargetPath = GeneralUtility::getFileAbsFileName($path . implode('/', $zipEntryPathSegments) . '/' . $fileName);
330 if (trim($absoluteTargetPath) !== '') {
331 $return = GeneralUtility::writeFile(
332 $absoluteTargetPath,
333 zip_entry_read($zipEntry, zip_entry_filesize($zipEntry))
334 );
335 if ($return === false) {
336 throw new \RuntimeException('Could not write file ' . $zipEntryName, 1520170845);
337 }
338 } else {
339 throw new \RuntimeException('Could not write file ' . $zipEntryName, 1520170846);
340 }
341 }
342 } else {
343 throw new \RuntimeException('Extension directory missing in zip file!', 1520170847);
344 }
345 }
346 } else {
347 throw new \RuntimeException('Unable to open zip file ' . $file, 1520170848);
348 }
349 }
350 }