diff --git a/config/defaults.php b/config/defaults.php index 38d46714..bb5ada18 100644 --- a/config/defaults.php +++ b/config/defaults.php @@ -48,12 +48,8 @@ $settings['locale'] = [ 'translations_path' => $settings['root_dir'] . '/resources/translations', - // Available languages in this format: ['language' => 'language_COUNTRY'] e.g. ['en' => 'en_US', 'de' => 'de_CH'] - // 'available' => [ - // 'en' => 'en_US', - // 'de' => 'de_CH', - // 'fr' => 'fr_CH', - // ], + // When adding new available locales, new translated email templates have to be added as well in their + // respective language subdirectory. 'available' => ['en_US', 'de_CH', 'fr_CH'], 'default' => 'en_US', ]; diff --git a/src/Application/Middleware/LocaleMiddleware.php b/src/Application/Middleware/LocaleMiddleware.php index b85d7061..ae6c4928 100644 --- a/src/Application/Middleware/LocaleMiddleware.php +++ b/src/Application/Middleware/LocaleMiddleware.php @@ -2,8 +2,8 @@ namespace App\Application\Middleware; -use App\Common\LocaleHelper; use App\Domain\User\Service\UserFinder; +use App\Infrastructure\Service\LocaleConfigurator; use Odan\Session\SessionInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -15,38 +15,32 @@ public function __construct( private SessionInterface $session, private UserFinder $userFinder, - private LocaleHelper $localeHelper, + private LocaleConfigurator $localeConfigurator, ) { } /** - * Locale middleware set language to default lang, browser lang or from settings. + * Sets language to the user's choice in the database or browser language. * * @param ServerRequestInterface $request * @param RequestHandlerInterface $handler * * @return ResponseInterface */ - public function process( - ServerRequestInterface $request, - RequestHandlerInterface $handler - ): ResponseInterface { + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { // Get authenticated user id from session $loggedInUserId = $this->session->get('user_id'); // If there is an authenticated user, find its language from the database $userLang = $loggedInUserId ? $this->userFinder->findUserById($loggedInUserId)->language : null; - // Remove the region from the language code en_US -> en - $userLangShort = $userLang ? explode('_', $userLang->value)[0] : null; // Get browser language. Result is something like: en-GB,en;q=0.9,de;q=0.8,de-DE;q=0.7,en-US;q=0.6,pt;q=0.5,fr;q=0.4 $browserLang = $request->getHeaderLine('Accept-Language'); // Get the first (main) language code with region e.g.: en-GB - $language = explode(',', $browserLang)[0]; - // Retrieve only the language part without region e.g.: en - $browserLangShort = explode('-', $language)[0]; + $browserLang = explode(',', $browserLang)[0]; // Set the language to the userLang if available and else to the browser language - $actualLocale = $this->localeHelper->setLanguage($userLangShort ?? $browserLangShort); + $actualLocale = $this->localeConfigurator->setLanguage($userLang->value ?? $browserLang); return $handler->handle($request); } diff --git a/src/Common/JsImportVersionAdder.php b/src/Common/JsImportVersionAdder.php index 5e4e5f94..12d012ca 100644 --- a/src/Common/JsImportVersionAdder.php +++ b/src/Common/JsImportVersionAdder.php @@ -2,7 +2,7 @@ namespace App\Common; -use App\Domain\Utility\Settings; +use App\Infrastructure\Utility\Settings; use FilesystemIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; diff --git a/src/Common/LocaleHelper.php b/src/Common/LocaleHelper.php deleted file mode 100644 index 74a6f6be..00000000 --- a/src/Common/LocaleHelper.php +++ /dev/null @@ -1,74 +0,0 @@ -localeSettings = $settings->get('locale'); - } - - /** - * Set the locale to the given lang code and bind text domain for gettext translations. - * - * @param string|false|null $locale The locale or language code (e.g. 'en_US' or 'en') - * @param string $domain The text domain (e.g. 'messages') - * - * @return false|string - */ - public function setLanguage(string|null|false $locale, string $domain = 'messages'): bool|string - { - $codeset = 'UTF-8'; - $defaultLocale = $this->localeSettings['default'] ?? 'en_US'; - // Current path src/Application/Middleware - $directory = __DIR__ . '/../../resources/translations'; - // If locale has hyphen instead of underscore, replace it - $locale = $locale ? str_replace('-', '_', $locale) : $defaultLocale; - // Get language code from locale - $langCode = explode('_', $locale)[0]; - // Get the available locale or the default one - $locale = $this->localeSettings['available'][$langCode] ?? $defaultLocale; - // Get locale with hyphen as alternative if server doesn't have the one with underscore (windows) - $localeHyphen = str_replace('_', '-', $locale); - - // Set locale information - $setLocaleResult = setlocale(LC_ALL, $locale, $localeHyphen); - // Check for existing mo file (optional) - $file = sprintf('%s/%s/LC_MESSAGES/%s_%s.mo', $directory, $locale, $domain, $locale); - if ($locale !== 'en_US' && !file_exists($file)) { - throw new \UnexpectedValueException(sprintf('File not found: %s', $file)); - } - // Generate new text domain - $textDomain = sprintf('%s_%s', $domain, $locale); - // Set base directory for all locales - bindtextdomain($textDomain, $directory); - // Set domain codeset - bind_textdomain_codeset($textDomain, $codeset); - textdomain($textDomain); - - return $setLocaleResult; - } - - /** - * Returns the current language code of the set locale with an - * added slash "/" at the end of the string if not empty. - * - * @return string language code or empty string if default or language code not found - */ - public function getLanguageCodeForPath(): string - { - // Get the key of the current locale - $localeCode = setlocale(LC_ALL, 0); - // Available locales keys are language codes ('en', 'de') and values are locale codes ('en_US', 'de_CH') - $langCode = array_search($localeCode, $this->localeSettings['available'], true) ?: ''; - - // If language code is 'en' return empty string as default email templates are in english and not in a sub folder - // If language code is not empty, add a slash to complete the path it will be inserted into - return $langCode === 'en' || $langCode === '' ? '' : $langCode . '/'; - } -} diff --git a/src/Domain/Authentication/Service/LoginMailSender.php b/src/Domain/Authentication/Service/LoginMailSender.php index 2ed03121..287e096d 100644 --- a/src/Domain/Authentication/Service/LoginMailSender.php +++ b/src/Domain/Authentication/Service/LoginMailSender.php @@ -2,9 +2,9 @@ namespace App\Domain\Authentication\Service; -use App\Common\LocaleHelper; -use App\Domain\Service\Infrastructure\Mailer; -use App\Domain\Utility\Settings; +use App\Infrastructure\Service\LocaleConfigurator; +use App\Infrastructure\Service\Mailer; +use App\Infrastructure\Utility\Settings; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; @@ -18,7 +18,7 @@ class LoginMailSender public function __construct( private readonly Mailer $mailer, - private readonly LocaleHelper $localeHelper, + private readonly LocaleConfigurator $localeConfigurator, Settings $settings ) { $settings = $settings->get('public')['email']; @@ -45,7 +45,7 @@ public function sendInfoToUnverifiedUser(string $email, string $fullName, array $this->email->subject(__('Login failed because your account is unverified')) ->html( $this->mailer->getContentFromTemplate( - 'authentication/email/' . $this->localeHelper->getLanguageCodeForPath() . + 'authentication/email/' . $this->localeConfigurator->getLanguageCodeForPath() . 'login-but-unverified.email.php', ['userFullName' => $fullName, 'queryParams' => $queryParamsWithToken] ) @@ -55,7 +55,7 @@ public function sendInfoToUnverifiedUser(string $email, string $fullName, array } /** - * When a user tries to log in but his status is suspended. + * When a user tries to log in but their status is suspended. * * @param string $email * @param string $fullName @@ -68,7 +68,7 @@ public function sendInfoToSuspendedUser(string $email, string $fullName): void $this->email->subject(__('Login failed because your account is suspended')) ->html( $this->mailer->getContentFromTemplate( - 'authentication/email/' . $this->localeHelper->getLanguageCodeForPath() . + 'authentication/email/' . $this->localeConfigurator->getLanguageCodeForPath() . 'login-but-suspended.email.php', ['userFullName' => $fullName] ) @@ -92,7 +92,7 @@ public function sendInfoToLockedUser(string $email, string $fullName, array $que $this->email->subject(__('Login failed because your account is locked')) ->html( $this->mailer->getContentFromTemplate( - 'authentication/email/' . $this->localeHelper->getLanguageCodeForPath() . + 'authentication/email/' . $this->localeConfigurator->getLanguageCodeForPath() . 'login-but-locked.email.php', ['userFullName' => $fullName, 'queryParams' => $queryParamsWithToken] ) diff --git a/src/Domain/Authentication/Service/LoginNonActiveUserHandler.php b/src/Domain/Authentication/Service/LoginNonActiveUserHandler.php index a082a1d7..17e652d2 100644 --- a/src/Domain/Authentication/Service/LoginNonActiveUserHandler.php +++ b/src/Domain/Authentication/Service/LoginNonActiveUserHandler.php @@ -2,12 +2,12 @@ namespace App\Domain\Authentication\Service; -use App\Common\LocaleHelper; use App\Domain\Authentication\Exception\UnableToLoginStatusNotActiveException; use App\Domain\Security\Service\SecurityEmailChecker; use App\Domain\User\Data\UserData; use App\Domain\User\Enum\UserStatus; -use App\Domain\Utility\Settings; +use App\Infrastructure\Service\LocaleConfigurator; +use App\Infrastructure\Utility\Settings; use Psr\Log\LoggerInterface; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; @@ -23,7 +23,7 @@ class LoginNonActiveUserHandler public function __construct( private readonly VerificationTokenCreator $verificationTokenCreator, private readonly LoginMailSender $loginMailer, - private readonly LocaleHelper $localeHelper, + private readonly LocaleConfigurator $localeConfigurator, private readonly SecurityEmailChecker $securityEmailChecker, private readonly LoggerInterface $logger, private readonly AuthenticationLogger $authenticationLogger, @@ -73,7 +73,7 @@ public function handleLoginAttemptFromNonActiveUser( // Change language to the one the user selected in settings (in case it differs from browser lang) $originalLocale = setlocale(LC_ALL, 0); - $this->localeHelper->setLanguage($dbUser->language->value); + $this->localeConfigurator->setLanguage($dbUser->language->value); if ($dbUser->status === UserStatus::Unverified) { // Inform user via email that account is unverified, and he should click on the link in his inbox @@ -96,10 +96,10 @@ public function handleLoginAttemptFromNonActiveUser( throw $unableToLoginException; } // Reset locale if sending the mail was successful - $this->localeHelper->setLanguage($originalLocale); + $this->localeConfigurator->setLanguage($originalLocale); } catch (TransportException $transportException) { // If exception is thrown reset locale as well. If $unableToLoginException - $this->localeHelper->setLanguage($originalLocale); + $this->localeConfigurator->setLanguage($originalLocale); // Exception while sending email throw new UnableToLoginStatusNotActiveException( 'Unable to login at the moment and there was an error when sending an email to you.' . @@ -108,7 +108,7 @@ public function handleLoginAttemptFromNonActiveUser( } // Catch exception to reset locale before throwing it again to be caught in the action catch (UnableToLoginStatusNotActiveException $unableToLoginStatusNotActiveException) { // Reset locale - $this->localeHelper->setLanguage($originalLocale); + $this->localeConfigurator->setLanguage($originalLocale); throw $unableToLoginStatusNotActiveException; } diff --git a/src/Domain/Authentication/Service/PasswordRecoveryEmailSender.php b/src/Domain/Authentication/Service/PasswordRecoveryEmailSender.php index 40a41de7..512e552b 100644 --- a/src/Domain/Authentication/Service/PasswordRecoveryEmailSender.php +++ b/src/Domain/Authentication/Service/PasswordRecoveryEmailSender.php @@ -2,14 +2,14 @@ namespace App\Domain\Authentication\Service; -use App\Common\LocaleHelper; use App\Domain\Exception\DomainRecordNotFoundException; use App\Domain\Security\Service\SecurityEmailChecker; -use App\Domain\Service\Infrastructure\Mailer; use App\Domain\User\Repository\UserFinderRepository; use App\Domain\User\Service\UserValidator; -use App\Domain\Utility\Settings; use App\Domain\Validation\ValidationException; +use App\Infrastructure\Service\LocaleConfigurator; +use App\Infrastructure\Service\Mailer; +use App\Infrastructure\Utility\Settings; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; @@ -28,7 +28,7 @@ public function __construct( private readonly VerificationTokenCreator $verificationTokenCreator, Settings $settings, private readonly SecurityEmailChecker $securityEmailChecker, - private readonly LocaleHelper $localeHelper, + private readonly LocaleConfigurator $localeConfigurator, ) { $settings = $settings->get('public')['email']; // Create email object @@ -63,12 +63,12 @@ public function sendPasswordRecoveryEmail(array $userValues, ?string $captcha = // Change language to one the user chose in settings $originalLocale = setlocale(LC_ALL, 0); - $this->localeHelper->setLanguage($dbUser->language->value); + $this->localeConfigurator->setLanguage($dbUser->language->value); // Send verification mail $this->email->subject(__('Reset password'))->html( $this->mailer->getContentFromTemplate( - 'authentication/email/' . $this->localeHelper->getLanguageCodeForPath() . + 'authentication/email/' . $this->localeConfigurator->getLanguageCodeForPath() . 'password-reset.email.php', ['user' => $dbUser, 'queryParams' => $queryParamsWithToken] ) @@ -76,7 +76,7 @@ public function sendPasswordRecoveryEmail(array $userValues, ?string $captcha = // Send email $this->mailer->send($this->email); // Reset locale - $this->localeHelper->setLanguage($originalLocale); + $this->localeConfigurator->setLanguage($originalLocale); // User activity entry is done when user verification token is created return; diff --git a/src/Domain/Authentication/Service/RegistrationMailSender.php b/src/Domain/Authentication/Service/RegistrationMailSender.php index 97ae9e53..dfcfb589 100644 --- a/src/Domain/Authentication/Service/RegistrationMailSender.php +++ b/src/Domain/Authentication/Service/RegistrationMailSender.php @@ -2,9 +2,9 @@ namespace App\Domain\Authentication\Service; -use App\Common\LocaleHelper; -use App\Domain\Service\Infrastructure\Mailer; -use App\Domain\Utility\Settings; +use App\Infrastructure\Service\LocaleConfigurator; +use App\Infrastructure\Service\Mailer; +use App\Infrastructure\Utility\Settings; use Symfony\Component\Mailer\Exception\TransportExceptionInterface; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; @@ -23,7 +23,7 @@ class RegistrationMailSender public function __construct( private readonly Mailer $mailer, - private readonly LocaleHelper $localeHelper, + private readonly LocaleConfigurator $localeConfigurator, Settings $settings ) { $settings = $settings->get('public')['email']; @@ -49,13 +49,13 @@ public function sendRegisterVerificationToken(string $email, string $fullName, s { // Change language to one the user is being registered with $originalLocale = setlocale(LC_ALL, 0); - $this->localeHelper->setLanguage($language); + $this->localeConfigurator->setLanguage($language); // Send verification mail in the language that was selected for the user $this->email->subject(__('Account created')) ->html( $this->mailer->getContentFromTemplate( - 'authentication/email/' . $this->localeHelper->getLanguageCodeForPath() . + 'authentication/email/' . $this->localeConfigurator->getLanguageCodeForPath() . 'new-account.email.php', ['userFullName' => $fullName, 'queryParams' => $queryParams] ) @@ -64,6 +64,6 @@ public function sendRegisterVerificationToken(string $email, string $fullName, s $this->mailer->send($this->email); // Reset locale - $this->localeHelper->setLanguage($originalLocale); + $this->localeConfigurator->setLanguage($originalLocale); } } diff --git a/src/Domain/Security/Service/EmailRequestFinder.php b/src/Domain/Security/Service/EmailRequestFinder.php index de02431c..d8012ae4 100644 --- a/src/Domain/Security/Service/EmailRequestFinder.php +++ b/src/Domain/Security/Service/EmailRequestFinder.php @@ -3,7 +3,7 @@ namespace App\Domain\Security\Service; use App\Domain\Security\Repository\EmailLogFinderRepository; -use App\Domain\Utility\Settings; +use App\Infrastructure\Utility\Settings; class EmailRequestFinder { diff --git a/src/Domain/Security/Service/LoginRequestFinder.php b/src/Domain/Security/Service/LoginRequestFinder.php index 85b8e7a2..16cbcfce 100644 --- a/src/Domain/Security/Service/LoginRequestFinder.php +++ b/src/Domain/Security/Service/LoginRequestFinder.php @@ -4,7 +4,7 @@ use App\Application\Data\UserNetworkSessionData; use App\Domain\Security\Repository\LoginLogFinderRepository; -use App\Domain\Utility\Settings; +use App\Infrastructure\Utility\Settings; class LoginRequestFinder { diff --git a/src/Domain/Security/Service/SecurityCaptchaVerifier.php b/src/Domain/Security/Service/SecurityCaptchaVerifier.php index ff621ba6..566d8242 100644 --- a/src/Domain/Security/Service/SecurityCaptchaVerifier.php +++ b/src/Domain/Security/Service/SecurityCaptchaVerifier.php @@ -4,7 +4,7 @@ use App\Domain\Security\Enum\SecurityType; use App\Domain\Security\Exception\SecurityException; -use App\Domain\Utility\Settings; +use App\Infrastructure\Utility\Settings; class SecurityCaptchaVerifier { diff --git a/src/Domain/Security/Service/SecurityEmailChecker.php b/src/Domain/Security/Service/SecurityEmailChecker.php index 54ac204e..76c3daca 100644 --- a/src/Domain/Security/Service/SecurityEmailChecker.php +++ b/src/Domain/Security/Service/SecurityEmailChecker.php @@ -5,7 +5,7 @@ use App\Domain\Security\Enum\SecurityType; use App\Domain\Security\Exception\SecurityException; use App\Domain\Security\Repository\EmailLogFinderRepository; -use App\Domain\Utility\Settings; +use App\Infrastructure\Utility\Settings; class SecurityEmailChecker { diff --git a/src/Domain/Security/Service/SecurityLoginChecker.php b/src/Domain/Security/Service/SecurityLoginChecker.php index dd9f5dc3..d9003624 100644 --- a/src/Domain/Security/Service/SecurityLoginChecker.php +++ b/src/Domain/Security/Service/SecurityLoginChecker.php @@ -5,7 +5,7 @@ use App\Domain\Security\Enum\SecurityType; use App\Domain\Security\Exception\SecurityException; use App\Domain\Security\Repository\LoginLogFinderRepository; -use App\Domain\Utility\Settings; +use App\Infrastructure\Utility\Settings; use App\Test\Unit\Security\SecurityLoginCheckerTest; class SecurityLoginChecker diff --git a/src/Infrastructure/Service/LocaleConfigurator.php b/src/Infrastructure/Service/LocaleConfigurator.php new file mode 100644 index 00000000..1cc40a6c --- /dev/null +++ b/src/Infrastructure/Service/LocaleConfigurator.php @@ -0,0 +1,127 @@ +localeSettings = $settings->get('locale'); + } + + /** + * Sets the locale and language settings for the application. + * + * @param string|null|false $locale The locale or language code (e.g. 'en_US' or 'en'). + * If null or false, the default locale from the settings is used. + * @param string $domain The text domain (default 'messages') for gettext translations. + * + * @return false|string The new locale string, or false on failure. + * + * @throws \UnexpectedValueException If the locale is not 'en_US' and no translation file exists for the locale. + */ + public function setLanguage(string|null|false $locale, string $domain = 'messages'): bool|string + { + $codeset = 'UTF-8'; + $defaultLocale = $this->localeSettings['default'] ?? 'en_US'; + $directory = $this->localeSettings['translations_path']; + // If locale has hyphen instead of underscore, replace it + $locale = $locale && str_contains($locale, '-') ? str_replace('-', '_', $locale) : $locale; + // Get an available locale. Either input locale, the locale for another region or default + $locale = $this->getAvailableLocale($locale); + + // Get locale with hyphen as an alternative if server doesn't have the one with underscore (windows) + $localeWithHyphen = str_replace('_', '-', $locale); + + // Set locale information + $setLocaleResult = setlocale(LC_ALL, $locale, $localeWithHyphen); + // Check for existing mo file (optional) + $file = sprintf('%s/%s/LC_MESSAGES/%s_%s.mo', $directory, $locale, $domain, $locale); + if ($locale !== 'en_US' && !file_exists($file)) { + throw new \UnexpectedValueException(sprintf('File not found: %s', $file)); + } + // Generate new text domain + $textDomain = sprintf('%s_%s', $domain, $locale); + // Set base directory for all locales + bindtextdomain($textDomain, $directory); + // Set domain codeset + bind_textdomain_codeset($textDomain, $codeset); + textdomain($textDomain); + + return $setLocaleResult; + } + + /** + * Returns the current language code of the set locale with an + * added slash "/" at the end of the string if not empty. + * + * When using this function, a subdirectory for the language has to exist in templates. + * + * @return string language code or empty string if default or language code not found + */ + public function getLanguageCodeForPath(): string + { + // Get the key of the current locale which has to be an available locale + $currentLocale = setlocale(LC_ALL, 0); + $langCode = $this->getLanguageCodeFromLocale($currentLocale); + + // If language code is 'en' return an empty string as the default email templates are in english and not in a + // subdirectory. + // If language code is not empty, add a slash to complete the path it will be inserted into + return $langCode === 'en' || $langCode === '' ? '' : $langCode . '/'; + } + + /** + * Locale has a language code and a country code. + * If the language exists but with another country code, this function + * returns the locale with the same language - or the default one + * if the language is not available. + * + * @param false|string|null $locale + * @return string + */ + private function getAvailableLocale(null|false|string $locale): string + { + $availableLocales = $this->localeSettings['available']; + + // If locale is in available locales, return it + if (in_array($locale, $availableLocales, true)) { + return $locale; + } + + // If locale was not found in the available locales, check if the language from another country is available + $localesMappedByLanguage = []; + foreach ($availableLocales as $availableLocale) { + $languageCode = $this->getLanguageCodeFromLocale($availableLocale); + // If the language code is already in the result array, skip it (the first locale of the + // language should be default) + if (!array_key_exists($languageCode, $localesMappedByLanguage)) { + $localesMappedByLanguage[$languageCode] = $availableLocale; + } + } + // Get the language code from the "target" locale + $localeLanguageCode = $this->getLanguageCodeFromLocale($locale); + // Take the locale from the same language if available or the default one + return $localesMappedByLanguage[$localeLanguageCode] ?? $this->localeSettings['default'] ?? 'en_US'; + } + + /** + * Get the language code part of a locale. + * + * @param string|null $locale e.g. 'en_US' + * @return string|null e.g. 'en' + */ + private function getLanguageCodeFromLocale(string|null $locale): ?string + { + // If locale has hyphen instead of underscore, replace it + if ($locale && str_contains($locale, '-')) { + $locale = str_replace('-', '_', $locale); + } + // The language code is the first part of the locale string + return $locale ? explode('_', $locale)[0] : null; + } +}