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