[FEATURE] Use dynamic path for typo3temp/var/
[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 $absoluteExtractionPath = GeneralUtility::getFileAbsFileName('typo3conf/l10n/' . $iso . '/' . $key . '/');
238 $absolutePathToZipFile = Environment::getVarPath() . '/transient/' . $key . '-l10n-' . $iso . '.zip';
239
240 $packExists = is_dir($absoluteExtractionPath);
241
242 $packResult = $packExists ? 'update' : 'new';
243
244 $operationResult = false;
245 try {
246 $languagePackContent = GeneralUtility::getUrl($languagePackBaseUrl . $packageUrl);
247 if (!empty($languagePackContent)) {
248 $operationResult = true;
249 if ($packExists) {
250 $operationResult = GeneralUtility::rmdir($absoluteExtractionPath, true);
251 }
252 if ($operationResult) {
253 GeneralUtility::mkdir_deep(Environment::getVarPath() . '/transient/');
254 $operationResult = GeneralUtility::writeFileToTypo3tempDir($absolutePathToZipFile, $languagePackContent) === null;
255 }
256 $this->unzipTranslationFile($absolutePathToZipFile, $absoluteExtractionPath);
257 if ($operationResult) {
258 $operationResult = unlink($absolutePathToZipFile);
259 }
260 }
261 } catch (\Exception $e) {
262 $operationResult = false;
263 }
264 if (!$operationResult) {
265 $packResult = 'failed';
266 $this->registry->set('languagePacks', $iso . '-' . $key, time());
267 }
268 return $packResult;
269 }
270
271 /**
272 * Set 'last update' timestamp in registry for a series of iso codes.
273 *
274 * @param string[] $isos List of iso code timestamps to set
275 * @throws \RuntimeException
276 */
277 public function setLastUpdatedIsoCode(array $isos)
278 {
279 $activeLanguages = $GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['lang']['availableLanguages'] ?? [];
280 $registry = GeneralUtility::makeInstance(Registry::class);
281 foreach ($isos as $iso) {
282 if (!in_array($iso, $activeLanguages, true)) {
283 throw new \RuntimeException('Language iso code ' . (string)$iso . ' not available or active', 1520176318);
284 }
285 $registry->set('languagePacks', $iso, time());
286 }
287 }
288
289 /**
290 * Format a timestamp to a formatted date string
291 *
292 * @param $timestamp int|null
293 * @return string|null
294 */
295 protected function getFormattedDate($timestamp)
296 {
297 if (is_int($timestamp)) {
298 $date = new \DateTime('@' . $timestamp);
299 $format = $GLOBALS['TYPO3_CONF_VARS']['SYS']['ddmmyy'] . ' ' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['hhmm'];
300 $timestamp = $date->format($format);
301 }
302 return $timestamp;
303 }
304
305 /**
306 * Unzip an language zip file
307 *
308 * @param string $file path to zip file
309 * @param string $path path to extract to
310 * @throws \RuntimeException
311 */
312 protected function unzipTranslationFile(string $file, string $path)
313 {
314 $zip = zip_open($file);
315 if (is_resource($zip)) {
316 if (!is_dir($path)) {
317 GeneralUtility::mkdir_deep($path);
318 }
319 while (($zipEntry = zip_read($zip)) !== false) {
320 $zipEntryName = zip_entry_name($zipEntry);
321 if (strpos($zipEntryName, '/') !== false) {
322 $zipEntryPathSegments = explode('/', $zipEntryName);
323 $fileName = array_pop($zipEntryPathSegments);
324 // It is a folder, because the last segment is empty, let's create it
325 if (empty($fileName)) {
326 GeneralUtility::mkdir_deep($path . implode('/', $zipEntryPathSegments));
327 } else {
328 $absoluteTargetPath = GeneralUtility::getFileAbsFileName($path . implode('/', $zipEntryPathSegments) . '/' . $fileName);
329 if (trim($absoluteTargetPath) !== '') {
330 $return = GeneralUtility::writeFile(
331 $absoluteTargetPath,
332 zip_entry_read($zipEntry, zip_entry_filesize($zipEntry))
333 );
334 if ($return === false) {
335 throw new \RuntimeException('Could not write file ' . $zipEntryName, 1520170845);
336 }
337 } else {
338 throw new \RuntimeException('Could not write file ' . $zipEntryName, 1520170846);
339 }
340 }
341 } else {
342 throw new \RuntimeException('Extension directory missing in zip file!', 1520170847);
343 }
344 }
345 } else {
346 throw new \RuntimeException('Unable to open zip file ' . $file, 1520170848);
347 }
348 }
349 }