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