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