From 68ffccc8b5cde2752986643ded208719869d273a Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 2 Sep 2025 12:14:46 +0400 Subject: [PATCH 1/8] EventLogManager --- config/services/managers.yml | 12 ++- config/services/repositories.yml | 15 ++- .../Model/Filter/EventLogFilter.php | 33 +++++++ .../Repository/EventLogRepository.php | 37 ++++++++ .../Service/Manager/EventLogManager.php | 54 +++++++++++ .../Service/Manager/EventLogManagerTest.php | 94 +++++++++++++++++++ 6 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 src/Domain/Configuration/Model/Filter/EventLogFilter.php create mode 100644 src/Domain/Configuration/Service/Manager/EventLogManager.php create mode 100644 tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index 5ef215b3..22dbe066 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -4,6 +4,14 @@ services: autoconfigure: true public: false + PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager: + autowire: true + autoconfigure: true + PhpList\Core\Domain\Identity\Service\SessionManager: autowire: true autoconfigure: true @@ -80,10 +88,6 @@ services: autowire: true autoconfigure: true - PhpList\Core\Domain\Configuration\Service\Manager\ConfigManager: - autowire: true - autoconfigure: true - PhpList\Core\Domain\Messaging\Service\Manager\BounceRuleManager: autowire: true autoconfigure: true diff --git a/config/services/repositories.yml b/config/services/repositories.yml index 82ae6a82..1289bea7 100644 --- a/config/services/repositories.yml +++ b/config/services/repositories.yml @@ -1,4 +1,14 @@ services: + PhpList\Core\Domain\Configuration\Repository\ConfigRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\Config + + PhpList\Core\Domain\Configuration\Repository\EventLogRepository: + parent: PhpList\Core\Domain\Common\Repository\AbstractRepository + arguments: + - PhpList\Core\Domain\Configuration\Model\EventLog + PhpList\Core\Domain\Identity\Repository\AdministratorRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: @@ -66,11 +76,6 @@ services: arguments: - PhpList\Core\Domain\Messaging\Model\TemplateImage - PhpList\Core\Domain\Configuration\Repository\ConfigRepository: - parent: PhpList\Core\Domain\Common\Repository\AbstractRepository - arguments: - - PhpList\Core\Domain\Configuration\Model\Config - PhpList\Core\Domain\Messaging\Repository\UserMessageBounceRepository: parent: PhpList\Core\Domain\Common\Repository\AbstractRepository arguments: diff --git a/src/Domain/Configuration/Model/Filter/EventLogFilter.php b/src/Domain/Configuration/Model/Filter/EventLogFilter.php new file mode 100644 index 00000000..12a824ca --- /dev/null +++ b/src/Domain/Configuration/Model/Filter/EventLogFilter.php @@ -0,0 +1,33 @@ +page; + } + + public function getDateFrom(): ?DateTimeInterface + { + return $this->dateFrom; + } + + public function getDateTo(): ?DateTimeInterface + { + return $this->dateTo; + } +} diff --git a/src/Domain/Configuration/Repository/EventLogRepository.php b/src/Domain/Configuration/Repository/EventLogRepository.php index 7caf5462..47640007 100644 --- a/src/Domain/Configuration/Repository/EventLogRepository.php +++ b/src/Domain/Configuration/Repository/EventLogRepository.php @@ -4,11 +4,48 @@ namespace PhpList\Core\Domain\Configuration\Repository; +use InvalidArgumentException; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Configuration\Model\Filter\EventLogFilter; +use PhpList\Core\Domain\Configuration\Model\EventLog; class EventLogRepository extends AbstractRepository implements PaginatableRepositoryInterface { use CursorPaginationTrait; + + /** + * @return EventLog[] + * @throws InvalidArgumentException + */ + public function getFilteredAfterId(int $lastId, int $limit, ?FilterRequestInterface $filter = null): array + { + $queryBuilder = $this->createQueryBuilder('e') + ->andWhere('e.id > :lastId') + ->setParameter('lastId', $lastId) + ->orderBy('e.id', 'ASC') + ->setMaxResults($limit); + + if ($filter === null) { + return $queryBuilder->getQuery()->getResult(); + } + + if (!$filter instanceof EventLogFilter) { + throw new InvalidArgumentException('Expected EventLogFilter.'); + } + + if ($filter->getPage() !== null) { + $queryBuilder->andWhere('e.page = :page')->setParameter('page', $filter->getPage()); + } + if ($filter->getDateFrom() !== null) { + $queryBuilder->andWhere('e.entered >= :dateFrom')->setParameter('dateFrom', $filter->getDateFrom()); + } + if ($filter->getDateTo() !== null) { + $queryBuilder->andWhere('e.entered <= :dateTo')->setParameter('dateTo', $filter->getDateTo()); + } + + return $queryBuilder->getQuery()->getResult(); + } } diff --git a/src/Domain/Configuration/Service/Manager/EventLogManager.php b/src/Domain/Configuration/Service/Manager/EventLogManager.php new file mode 100644 index 00000000..374db7ed --- /dev/null +++ b/src/Domain/Configuration/Service/Manager/EventLogManager.php @@ -0,0 +1,54 @@ +repository = $repository; + } + + public function log(string $page, string $entry): EventLog + { + $log = (new EventLog()) + ->setEntered(new DateTimeImmutable()) + ->setPage($page) + ->setEntry($entry); + + $this->repository->save($log); + + return $log; + } + + /** + * Get event logs with optional filters (page and date range) and cursor pagination. + * + * @return EventLog[] + */ + public function get( + int $lastId = 0, + int $limit = 50, + ?string $page = null, + ?DateTimeInterface $dateFrom = null, + ?DateTimeInterface $dateTo = null + ): array { + $filter = new EventLogFilter($page, $dateFrom, $dateTo); + return $this->repository->getFilteredAfterId($lastId, $limit, $filter); + } + + public function delete(EventLog $log): void + { + $this->repository->remove($log); + } +} diff --git a/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php new file mode 100644 index 00000000..818b8de0 --- /dev/null +++ b/tests/Unit/Domain/Configuration/Service/Manager/EventLogManagerTest.php @@ -0,0 +1,94 @@ +repository = $this->createMock(EventLogRepository::class); + $this->manager = new EventLogManager($this->repository); + } + + public function testLogCreatesAndPersists(): void + { + $this->repository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(EventLog::class)); + + $log = $this->manager->log('dashboard', 'Viewed dashboard'); + + $this->assertInstanceOf(EventLog::class, $log); + $this->assertSame('dashboard', $log->getPage()); + $this->assertSame('Viewed dashboard', $log->getEntry()); + $this->assertNotNull($log->getEntered()); + $this->assertInstanceOf(DateTimeImmutable::class, $log->getEntered()); + } + + public function testDelete(): void + { + $log = new EventLog(); + $this->repository->expects($this->once()) + ->method('remove') + ->with($log); + + $this->manager->delete($log); + } + + public function testGetWithFiltersDelegatesToRepository(): void + { + $expected = [new EventLog(), new EventLog()]; + + $this->repository->expects($this->once()) + ->method('getFilteredAfterId') + ->with( + 100, + 25, + $this->callback(function (EventLogFilter $filter) { + // Use getters to validate + return method_exists($filter, 'getPage') + && $filter->getPage() === 'settings' + && $filter->getDateFrom() instanceof DateTimeImmutable + && $filter->getDateTo() instanceof DateTimeImmutable + && $filter->getDateFrom() <= $filter->getDateTo(); + }) + ) + ->willReturn($expected); + + $from = new DateTimeImmutable('-2 days'); + $to = new DateTimeImmutable('now'); + $result = $this->manager->get(lastId: 100, limit: 25, page: 'settings', dateFrom: $from, dateTo: $to); + + $this->assertSame($expected, $result); + } + + public function testGetWithoutFiltersDefaults(): void + { + $expected = []; + + $this->repository->expects($this->once()) + ->method('getFilteredAfterId') + ->with( + 0, + 50, + $this->anything() + ) + ->willReturn($expected); + + $result = $this->manager->get(); + $this->assertSame($expected, $result); + } +} From 71f0d376ef90c7f2fec92beab1018a7bc21edea5 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 2 Sep 2025 12:39:56 +0400 Subject: [PATCH 2/8] Log failed logins + translate messages --- config/services/services.yml | 7 +++ resources/translations/messages.en.php | 10 ++++ src/Domain/Common/I18n/Messages.php | 21 +++++++ src/Domain/Common/I18n/SimpleTranslator.php | 60 +++++++++++++++++++ .../Common/I18n/TranslatorInterface.php | 16 +++++ .../Identity/Service/SessionManager.php | 21 ++++++- .../Identity/Service/SessionManagerTest.php | 28 ++++++++- 7 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 resources/translations/messages.en.php create mode 100644 src/Domain/Common/I18n/Messages.php create mode 100644 src/Domain/Common/I18n/SimpleTranslator.php create mode 100644 src/Domain/Common/I18n/TranslatorInterface.php diff --git a/config/services/services.yml b/config/services/services.yml index 19caddd8..1f509787 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -107,3 +107,10 @@ services: PhpList\Core\Domain\Messaging\Service\BounceActionResolver: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } + + # I18n + PhpList\Core\Domain\Common\I18n\SimpleTranslator: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Common\I18n\TranslatorInterface: '@PhpList\Core\Domain\Common\I18n\SimpleTranslator' diff --git a/resources/translations/messages.en.php b/resources/translations/messages.en.php new file mode 100644 index 00000000..e3ed9978 --- /dev/null +++ b/resources/translations/messages.en.php @@ -0,0 +1,10 @@ + 'Not authorized', + Messages::AUTH_LOGIN_FAILED => "Failed admin login attempt for '{login}'", + Messages::AUTH_LOGIN_DISABLED => "Login attempt for disabled admin '{login}'", +]; diff --git a/src/Domain/Common/I18n/Messages.php b/src/Domain/Common/I18n/Messages.php new file mode 100644 index 00000000..c31abe47 --- /dev/null +++ b/src/Domain/Common/I18n/Messages.php @@ -0,0 +1,21 @@ +> */ + private array $catalogues = []; + + public function __construct(string $defaultLocale = 'en') + { + $this->defaultLocale = $defaultLocale; + } + + public function translate(string $key, array $params = [], ?string $locale = null): string + { + $loc = $locale ?? $this->defaultLocale; + $messages = $this->loadCatalogue($loc); + $message = $messages[$key] ?? $key; + + $replacements = []; + foreach ($params as $name => $value) { + $replacements['{' . $name . '}'] = (string)$value; + } + + return strtr($message, $replacements); + } + + /** + * @return array + */ + private function loadCatalogue(string $locale): array + { + if (!isset($this->catalogues[$locale])) { + $pathPhp = __DIR__ . '/../../../../resources/translations/messages.' . $locale . '.php'; + if (is_file($pathPhp)) { + /** @var array $messages */ + $messages = include $pathPhp; + } else { + $fallback = __DIR__ . '/../../../../resources/translations/messages.en.php'; + if (is_file($fallback)) { + /** @var array $messages */ + $messages = include $fallback; + } else { + $messages = []; + } + } + $this->catalogues[$locale] = $messages; + } + return $this->catalogues[$locale]; + } +} diff --git a/src/Domain/Common/I18n/TranslatorInterface.php b/src/Domain/Common/I18n/TranslatorInterface.php new file mode 100644 index 00000000..2f842967 --- /dev/null +++ b/src/Domain/Common/I18n/TranslatorInterface.php @@ -0,0 +1,16 @@ + $params Placeholder values (e.g., ['login' => 'admin']) + * @param string|null $locale Optional locale (defaults to environment/app locale) + */ + public function translate(string $key, array $params = [], ?string $locale = null): string; +} diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index 52daafa3..c7e95f9e 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Domain\Identity\Service; +use PhpList\Core\Domain\Common\I18n\Messages; +use PhpList\Core\Domain\Common\I18n\TranslatorInterface; +use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; @@ -13,24 +16,36 @@ class SessionManager { private AdministratorTokenRepository $tokenRepository; private AdministratorRepository $administratorRepository; + private EventLogManager $eventLogManager; + private TranslatorInterface $translator; public function __construct( AdministratorTokenRepository $tokenRepository, - AdministratorRepository $administratorRepository + AdministratorRepository $administratorRepository, + EventLogManager $eventLogManager, + TranslatorInterface $translator ) { $this->tokenRepository = $tokenRepository; $this->administratorRepository = $administratorRepository; + $this->eventLogManager = $eventLogManager; + $this->translator = $translator; } public function createSession(string $loginName, string $password): AdministratorToken { $administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password); if ($administrator === null) { - throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567098); + $entry = $this->translator->translate(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]); + $this->eventLogManager->log('login', $entry); + $message = $this->translator->translate(Messages::AUTH_NOT_AUTHORIZED); + throw new UnauthorizedHttpException('', $message, null, 1500567098); } if ($administrator->isDisabled()) { - throw new UnauthorizedHttpException('', 'Not authorized', null, 1500567099); + $entry = $this->translator->translate(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]); + $this->eventLogManager->log('login', $entry); + $message = $this->translator->translate(Messages::AUTH_NOT_AUTHORIZED); + throw new UnauthorizedHttpException('', $message, null, 1500567099); } $token = new AdministratorToken(); diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index 44072452..d4e1cc80 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -4,6 +4,9 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; +use PhpList\Core\Domain\Common\I18n\Messages; +use PhpList\Core\Domain\Common\I18n\TranslatorInterface; +use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; use PhpList\Core\Domain\Identity\Repository\AdministratorTokenRepository; @@ -13,7 +16,7 @@ class SessionManagerTest extends TestCase { - public function testCreateSessionWithInvalidCredentialsThrowsException(): void + public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): void { $adminRepo = $this->createMock(AdministratorRepository::class); $adminRepo->expects(self::once()) @@ -24,7 +27,24 @@ public function testCreateSessionWithInvalidCredentialsThrowsException(): void $tokenRepo = $this->createMock(AdministratorTokenRepository::class); $tokenRepo->expects(self::never())->method('save'); - $manager = new SessionManager($tokenRepo, $adminRepo); + $eventLogManager = $this->createMock(EventLogManager::class); + $eventLogManager->expects(self::once()) + ->method('log') + ->with('login', $this->stringContains('admin')); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->expects(self::exactly(2)) + ->method('translate') + ->withConsecutive( + [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']], + [Messages::AUTH_NOT_AUTHORIZED, []] + ) + ->willReturnOnConsecutiveCalls( + "Failed admin login attempt for 'admin'", + 'Not authorized' + ); + + $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator); $this->expectException(UnauthorizedHttpException::class); $this->expectExceptionMessage('Not authorized'); @@ -42,8 +62,10 @@ public function testDeleteSessionCallsRemove(): void ->with($token); $adminRepo = $this->createMock(AdministratorRepository::class); + $eventLogManager = $this->createMock(EventLogManager::class); + $translator = $this->createMock(TranslatorInterface::class); - $manager = new SessionManager($tokenRepo, $adminRepo); + $manager = new SessionManager($tokenRepo, $adminRepo, $eventLogManager, $translator); $manager->deleteSession($token); } } From 5f9bf2ddeef9891d67a00b9ddab657103f028450 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 3 Sep 2025 13:48:46 +0400 Subject: [PATCH 3/8] weblate --- .github/workflows/l10n-validate.yml | 21 +++++++ .weblate | 39 ++++++++++++ config/config.yml | 5 +- resources/translations/messages.en.php | 10 ---- resources/translations/messages.en.xlf | 24 ++++++++ src/Domain/Common/I18n/SimpleTranslator.php | 60 ------------------- .../Common/I18n/TranslatorInterface.php | 16 ----- 7 files changed, 88 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/l10n-validate.yml create mode 100644 .weblate delete mode 100644 resources/translations/messages.en.php create mode 100644 resources/translations/messages.en.xlf delete mode 100644 src/Domain/Common/I18n/SimpleTranslator.php delete mode 100644 src/Domain/Common/I18n/TranslatorInterface.php diff --git a/.github/workflows/l10n-validate.yml b/.github/workflows/l10n-validate.yml new file mode 100644 index 00000000..eba9441e --- /dev/null +++ b/.github/workflows/l10n-validate.yml @@ -0,0 +1,21 @@ +name: L10n Validate + +on: + pull_request: + paths: + - 'translations/**/*.xlf' + +jobs: + validate-xliff: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: php-actions/composer@v6 + - name: Validate XLIFF XML + run: | + sudo apt-get update && sudo apt-get install -y libxml2-utils + find translations -name '*.xlf' -print0 | xargs -0 -n1 xmllint --noout + - name: Symfony translation sanity (extract dry-run) + run: | + composer install --no-interaction --no-progress + php bin/console translation:extract en --format=xlf --domain=messages --no-interaction diff --git a/.weblate b/.weblate new file mode 100644 index 00000000..4a774cfd --- /dev/null +++ b/.weblate @@ -0,0 +1,39 @@ +# .weblate +--- +projects: + - slug: phplist-core + name: phpList core + components: + - slug: messages + name: Messages + files: + # {language} is Weblate’s placeholder (e.g., fr, de, es) + - src: translations/messages.en.xlf + template: true + # Where localized files live (mirrors Symfony layout) + target: translations/messages.{language}.xlf + file_format: xliff + language_code_style: bcp + # Ensure placeholders like %name% are preserved + parse_file_headers: true + check_flags: + - xml-invalid + - placeholders + - urls + - accelerated + - slug: validators + name: Validators + files: + - src: translations/validators.en.xlf + template: true + target: translations/validators.{language}.xlf + file_format: xliff + language_code_style: bcp + - slug: security + name: Security + files: + - src: translations/security.en.xlf + template: true + target: translations/security.{language}.xlf + file_format: xliff + language_code_style: bcp diff --git a/config/config.yml b/config/config.yml index e235f999..7de6dca6 100644 --- a/config/config.yml +++ b/config/config.yml @@ -10,7 +10,10 @@ parameters: framework: #esi: ~ - #translator: { fallbacks: ['%locale%'] } + translator: + default_path: '%kernel.project_dir%/resources/translations' + fallbacks: ['%locale%'] + secret: '%secret%' router: resource: '%kernel.project_dir%/config/routing.yml' diff --git a/resources/translations/messages.en.php b/resources/translations/messages.en.php deleted file mode 100644 index e3ed9978..00000000 --- a/resources/translations/messages.en.php +++ /dev/null @@ -1,10 +0,0 @@ - 'Not authorized', - Messages::AUTH_LOGIN_FAILED => "Failed admin login attempt for '{login}'", - Messages::AUTH_LOGIN_DISABLED => "Login attempt for disabled admin '{login}'", -]; diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf new file mode 100644 index 00000000..1d5f2850 --- /dev/null +++ b/resources/translations/messages.en.xlf @@ -0,0 +1,24 @@ + + + + + + + + Not authorized + Not authorized + + + + Failed admin login attempt for '%login%' + Failed admin login attempt for '%login%' + + + + Login attempt for disabled admin '%login%' + Login attempt for disabled admin '%login%' + + + + + diff --git a/src/Domain/Common/I18n/SimpleTranslator.php b/src/Domain/Common/I18n/SimpleTranslator.php deleted file mode 100644 index 3579216c..00000000 --- a/src/Domain/Common/I18n/SimpleTranslator.php +++ /dev/null @@ -1,60 +0,0 @@ -> */ - private array $catalogues = []; - - public function __construct(string $defaultLocale = 'en') - { - $this->defaultLocale = $defaultLocale; - } - - public function translate(string $key, array $params = [], ?string $locale = null): string - { - $loc = $locale ?? $this->defaultLocale; - $messages = $this->loadCatalogue($loc); - $message = $messages[$key] ?? $key; - - $replacements = []; - foreach ($params as $name => $value) { - $replacements['{' . $name . '}'] = (string)$value; - } - - return strtr($message, $replacements); - } - - /** - * @return array - */ - private function loadCatalogue(string $locale): array - { - if (!isset($this->catalogues[$locale])) { - $pathPhp = __DIR__ . '/../../../../resources/translations/messages.' . $locale . '.php'; - if (is_file($pathPhp)) { - /** @var array $messages */ - $messages = include $pathPhp; - } else { - $fallback = __DIR__ . '/../../../../resources/translations/messages.en.php'; - if (is_file($fallback)) { - /** @var array $messages */ - $messages = include $fallback; - } else { - $messages = []; - } - } - $this->catalogues[$locale] = $messages; - } - return $this->catalogues[$locale]; - } -} diff --git a/src/Domain/Common/I18n/TranslatorInterface.php b/src/Domain/Common/I18n/TranslatorInterface.php deleted file mode 100644 index 2f842967..00000000 --- a/src/Domain/Common/I18n/TranslatorInterface.php +++ /dev/null @@ -1,16 +0,0 @@ - $params Placeholder values (e.g., ['login' => 'admin']) - * @param string|null $locale Optional locale (defaults to environment/app locale) - */ - public function translate(string $key, array $params = [], ?string $locale = null): string; -} From 165d36307cd48b8aa58c256201280a9775aa7537 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Sep 2025 10:25:55 +0400 Subject: [PATCH 4/8] test fix --- src/Domain/Identity/Service/SessionManager.php | 10 +++++----- .../Domain/Identity/Service/SessionManagerTest.php | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Domain/Identity/Service/SessionManager.php b/src/Domain/Identity/Service/SessionManager.php index c7e95f9e..82f52af1 100644 --- a/src/Domain/Identity/Service/SessionManager.php +++ b/src/Domain/Identity/Service/SessionManager.php @@ -5,7 +5,7 @@ namespace PhpList\Core\Domain\Identity\Service; use PhpList\Core\Domain\Common\I18n\Messages; -use PhpList\Core\Domain\Common\I18n\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; @@ -35,16 +35,16 @@ public function createSession(string $loginName, string $password): Administrato { $administrator = $this->administratorRepository->findOneByLoginCredentials($loginName, $password); if ($administrator === null) { - $entry = $this->translator->translate(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]); + $entry = $this->translator->trans(Messages::AUTH_LOGIN_FAILED, ['login' => $loginName]); $this->eventLogManager->log('login', $entry); - $message = $this->translator->translate(Messages::AUTH_NOT_AUTHORIZED); + $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); throw new UnauthorizedHttpException('', $message, null, 1500567098); } if ($administrator->isDisabled()) { - $entry = $this->translator->translate(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]); + $entry = $this->translator->trans(Messages::AUTH_LOGIN_DISABLED, ['login' => $loginName]); $this->eventLogManager->log('login', $entry); - $message = $this->translator->translate(Messages::AUTH_NOT_AUTHORIZED); + $message = $this->translator->trans(Messages::AUTH_NOT_AUTHORIZED); throw new UnauthorizedHttpException('', $message, null, 1500567099); } diff --git a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php index d4e1cc80..14419b0e 100644 --- a/tests/Unit/Domain/Identity/Service/SessionManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/SessionManagerTest.php @@ -5,7 +5,6 @@ namespace PhpList\Core\Tests\Unit\Domain\Identity\Service; use PhpList\Core\Domain\Common\I18n\Messages; -use PhpList\Core\Domain\Common\I18n\TranslatorInterface; use PhpList\Core\Domain\Configuration\Service\Manager\EventLogManager; use PhpList\Core\Domain\Identity\Model\AdministratorToken; use PhpList\Core\Domain\Identity\Repository\AdministratorRepository; @@ -13,6 +12,7 @@ use PhpList\Core\Domain\Identity\Service\SessionManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class SessionManagerTest extends TestCase { @@ -34,7 +34,7 @@ public function testCreateSessionWithInvalidCredentialsThrowsExceptionAndLogs(): $translator = $this->createMock(TranslatorInterface::class); $translator->expects(self::exactly(2)) - ->method('translate') + ->method('trans') ->withConsecutive( [Messages::AUTH_LOGIN_FAILED, ['login' => 'admin']], [Messages::AUTH_NOT_AUTHORIZED, []] From da34245a2239e4e99343c1989e66c0bccc6c3f94 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Sep 2025 11:30:48 +0400 Subject: [PATCH 5/8] Use translations --- .github/workflows/l10n-validate.yml | 2 +- resources/translations/messages.en.xlf | 20 +++++++++++++++++++ src/Domain/Common/I18n/Messages.php | 8 ++++++++ .../Identity/Service/PasswordManager.php | 10 ++++++++-- .../Service/Manager/SubscriptionManager.php | 16 +++++++++++---- .../Identity/Service/PasswordManagerTest.php | 4 +++- .../Manager/SubscriptionManagerTest.php | 11 +++++++--- 7 files changed, 60 insertions(+), 11 deletions(-) diff --git a/.github/workflows/l10n-validate.yml b/.github/workflows/l10n-validate.yml index eba9441e..e0068baa 100644 --- a/.github/workflows/l10n-validate.yml +++ b/.github/workflows/l10n-validate.yml @@ -3,7 +3,7 @@ name: L10n Validate on: pull_request: paths: - - 'translations/**/*.xlf' + - 'resources/translations/**/*.xlf' jobs: validate-xliff: diff --git a/resources/translations/messages.en.xlf b/resources/translations/messages.en.xlf index 1d5f2850..7e176e3e 100644 --- a/resources/translations/messages.en.xlf +++ b/resources/translations/messages.en.xlf @@ -19,6 +19,26 @@ Login attempt for disabled admin '%login%' + + + Administrator not found + Administrator not found + + + + + Subscriber list not found. + Subscriber list not found. + + + Subscriber does not exists. + Subscriber does not exists. + + + Subscription not found for this subscriber and list. + Subscription not found for this subscriber and list. + + diff --git a/src/Domain/Common/I18n/Messages.php b/src/Domain/Common/I18n/Messages.php index c31abe47..f9e8822f 100644 --- a/src/Domain/Common/I18n/Messages.php +++ b/src/Domain/Common/I18n/Messages.php @@ -15,6 +15,14 @@ final class Messages public const AUTH_LOGIN_FAILED = 'auth.login_failed'; public const AUTH_LOGIN_DISABLED = 'auth.login_disabled'; + // Identity + public const IDENTITY_ADMIN_NOT_FOUND = 'identity.admin_not_found'; + + // Subscription + public const SUBSCRIPTION_LIST_NOT_FOUND = 'subscription.list_not_found'; + public const SUBSCRIPTION_SUBSCRIBER_NOT_FOUND = 'subscription.subscriber_not_found'; + public const SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER = 'subscription.not_found_for_list_and_subscriber'; + private function __construct() { } diff --git a/src/Domain/Identity/Service/PasswordManager.php b/src/Domain/Identity/Service/PasswordManager.php index f6ad2a9e..2c7ebe1e 100644 --- a/src/Domain/Identity/Service/PasswordManager.php +++ b/src/Domain/Identity/Service/PasswordManager.php @@ -5,6 +5,7 @@ namespace PhpList\Core\Domain\Identity\Service; use DateTime; +use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Identity\Model\AdminPasswordRequest; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Repository\AdminPasswordRequestRepository; @@ -13,6 +14,7 @@ use PhpList\Core\Security\HashGenerator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Messenger\MessageBusInterface; +use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManager { @@ -22,17 +24,20 @@ class PasswordManager private AdministratorRepository $administratorRepository; private HashGenerator $hashGenerator; private MessageBusInterface $messageBus; + private TranslatorInterface $translator; public function __construct( AdminPasswordRequestRepository $passwordRequestRepository, AdministratorRepository $administratorRepository, HashGenerator $hashGenerator, - MessageBusInterface $messageBus + MessageBusInterface $messageBus, + TranslatorInterface $translator ) { $this->passwordRequestRepository = $passwordRequestRepository; $this->administratorRepository = $administratorRepository; $this->hashGenerator = $hashGenerator; $this->messageBus = $messageBus; + $this->translator = $translator; } /** @@ -47,7 +52,8 @@ public function generatePasswordResetToken(string $email): string { $administrator = $this->administratorRepository->findOneBy(['email' => $email]); if ($administrator === null) { - throw new NotFoundHttpException('Administrator not found', null, 1500567100); + $message = $this->translator->trans(Messages::IDENTITY_ADMIN_NOT_FOUND); + throw new NotFoundHttpException($message, null, 1500567100); } $existingRequests = $this->passwordRequestRepository->findByAdmin($administrator); diff --git a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php index bb3a0e14..764106ec 100644 --- a/src/Domain/Subscription/Service/Manager/SubscriptionManager.php +++ b/src/Domain/Subscription/Service/Manager/SubscriptionManager.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Subscription\Service\Manager; +use PhpList\Core\Domain\Common\I18n\Messages; use PhpList\Core\Domain\Subscription\Exception\SubscriptionCreationException; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -11,21 +12,25 @@ use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriberRepository; use PhpList\Core\Domain\Subscription\Repository\SubscriptionRepository; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriptionManager { private SubscriptionRepository $subscriptionRepository; private SubscriberRepository $subscriberRepository; private SubscriberListRepository $subscriberListRepository; + private TranslatorInterface $translator; public function __construct( SubscriptionRepository $subscriptionRepository, SubscriberRepository $subscriberRepository, - SubscriberListRepository $subscriberListRepository + SubscriberListRepository $subscriberListRepository, + TranslatorInterface $translator ) { $this->subscriptionRepository = $subscriptionRepository; $this->subscriberRepository = $subscriberRepository; $this->subscriberListRepository = $subscriberListRepository; + $this->translator = $translator; } public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subscription @@ -37,7 +42,8 @@ public function addSubscriberToAList(Subscriber $subscriber, int $listId): Subsc } $subscriberList = $this->subscriberListRepository->find($listId); if (!$subscriberList) { - throw new SubscriptionCreationException('Subscriber list not found.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_LIST_NOT_FOUND); + throw new SubscriptionCreationException($message, 404); } $subscription = new Subscription(); @@ -64,7 +70,8 @@ private function createSubscription(SubscriberList $subscriberList, string $emai { $subscriber = $this->subscriberRepository->findOneBy(['email' => $email]); if (!$subscriber) { - throw new SubscriptionCreationException('Subscriber does not exists.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_SUBSCRIBER_NOT_FOUND); + throw new SubscriptionCreationException($message, 404); } $existingSubscription = $this->subscriptionRepository @@ -101,7 +108,8 @@ private function deleteSubscription(SubscriberList $subscriberList, string $emai ->findOneBySubscriberEmailAndListId($subscriberList->getId(), $email); if (!$subscription) { - throw new SubscriptionCreationException('Subscription not found for this subscriber and list.', 404); + $message = $this->translator->trans(Messages::SUBSCRIPTION_NOT_FOUND_FOR_LIST_AND_SUBSCRIBER); + throw new SubscriptionCreationException($message, 404); } $this->subscriptionRepository->remove($subscription); diff --git a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php index 85e02f81..59ace13d 100644 --- a/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php +++ b/tests/Unit/Domain/Identity/Service/PasswordManagerTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Contracts\Translation\TranslatorInterface; class PasswordManagerTest extends TestCase { @@ -36,7 +37,8 @@ protected function setUp(): void passwordRequestRepository: $this->passwordRequestRepository, administratorRepository: $this->administratorRepository, hashGenerator: $this->hashGenerator, - messageBus: $this->messageBus + messageBus: $this->messageBus, + translator: $this->createMock(TranslatorInterface::class) ); } diff --git a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php index e535a7fe..f0c1d3af 100644 --- a/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php +++ b/tests/Unit/Domain/Subscription/Service/Manager/SubscriptionManagerTest.php @@ -14,11 +14,13 @@ use PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Contracts\Translation\TranslatorInterface; class SubscriptionManagerTest extends TestCase { private SubscriptionRepository&MockObject $subscriptionRepository; private SubscriberRepository&MockObject $subscriberRepository; + private TranslatorInterface&MockObject $translator; private SubscriptionManager $manager; protected function setUp(): void @@ -26,10 +28,12 @@ protected function setUp(): void $this->subscriptionRepository = $this->createMock(SubscriptionRepository::class); $this->subscriberRepository = $this->createMock(SubscriberRepository::class); $subscriberListRepository = $this->createMock(SubscriberListRepository::class); + $this->translator = $this->createMock(TranslatorInterface::class); $this->manager = new SubscriptionManager( - $this->subscriptionRepository, - $this->subscriberRepository, - $subscriberListRepository + subscriptionRepository: $this->subscriptionRepository, + subscriberRepository: $this->subscriberRepository, + subscriberListRepository: $subscriberListRepository, + translator: $this->translator, ); } @@ -51,6 +55,7 @@ public function testCreateSubscriptionWhenSubscriberExists(): void public function testCreateSubscriptionThrowsWhenSubscriberMissing(): void { + $this->translator->method('trans')->willReturn('Subscriber does not exists.'); $this->expectException(SubscriptionCreationException::class); $this->expectExceptionMessage('Subscriber does not exists.'); From 36f9af3f3e8d44d76aaffbd6e0b3727c7cb520b0 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Sep 2025 11:35:37 +0400 Subject: [PATCH 6/8] Fix pipeline --- .github/workflows/i18n-validate.yml | 69 +++++++++++++++++++++++++++++ .github/workflows/l10n-validate.yml | 21 --------- 2 files changed, 69 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/i18n-validate.yml delete mode 100644 .github/workflows/l10n-validate.yml diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml new file mode 100644 index 00000000..4e49efa8 --- /dev/null +++ b/.github/workflows/i18n-validate.yml @@ -0,0 +1,69 @@ +name: I18n Validate + +on: + pull_request: + paths: + - 'resources/translations/**/*.xlf' + - 'composer.lock' + - 'composer.json' + +jobs: + validate-xliff: + runs-on: ubuntu-22.04 + + strategy: + fail-fast: false + matrix: + php: ['8.1'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: imap, zip + tools: composer:v2 + coverage: none + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: | + ~/.composer/cache/files + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php }}- + + - name: Install dependencies (no dev autoloader scripts) + run: | + set -euo pipefail + composer install --no-interaction --no-progress --prefer-dist + + - name: Lint XLIFF with Symfony + run: | + set -euo pipefail + # Adjust the directory to match your repo layout + php bin/console lint:xliff resources/translations + + - name: Validate XLIFF XML with xmllint + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends libxml2-utils + # Adjust root dir; prune vendor; accept spaces/newlines safely + find resources/translations -type f -name '*.xlf' -not -path '*/vendor/*' -print0 \ + | xargs -0 -n1 xmllint --noout + + - name: Symfony translation sanity (extract dry-run) + run: | + set -euo pipefail + # Show what would be created/updated without writing files + php bin/console translation:extract en \ + --format=xlf \ + --domain=messages \ + --dump-messages \ + --no-interaction + # Note: omit --force to keep this a dry-run diff --git a/.github/workflows/l10n-validate.yml b/.github/workflows/l10n-validate.yml deleted file mode 100644 index e0068baa..00000000 --- a/.github/workflows/l10n-validate.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: L10n Validate - -on: - pull_request: - paths: - - 'resources/translations/**/*.xlf' - -jobs: - validate-xliff: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - - uses: php-actions/composer@v6 - - name: Validate XLIFF XML - run: | - sudo apt-get update && sudo apt-get install -y libxml2-utils - find translations -name '*.xlf' -print0 | xargs -0 -n1 xmllint --noout - - name: Symfony translation sanity (extract dry-run) - run: | - composer install --no-interaction --no-progress - php bin/console translation:extract en --format=xlf --domain=messages --no-interaction From 8c789d8e9856fd9dfca172d1c086fbb8c2e2f81e Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Sep 2025 11:59:53 +0400 Subject: [PATCH 7/8] Weblate --- .weblate | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/.weblate b/.weblate index 4a774cfd..5917a8b8 100644 --- a/.weblate +++ b/.weblate @@ -8,10 +8,10 @@ projects: name: Messages files: # {language} is Weblate’s placeholder (e.g., fr, de, es) - - src: translations/messages.en.xlf + - src: resources/translations/messages.en.xlf template: true # Where localized files live (mirrors Symfony layout) - target: translations/messages.{language}.xlf + target: resources/translations/messages.{language}.xlf file_format: xliff language_code_style: bcp # Ensure placeholders like %name% are preserved @@ -21,19 +21,3 @@ projects: - placeholders - urls - accelerated - - slug: validators - name: Validators - files: - - src: translations/validators.en.xlf - template: true - target: translations/validators.{language}.xlf - file_format: xliff - language_code_style: bcp - - slug: security - name: Security - files: - - src: translations/security.en.xlf - template: true - target: translations/security.{language}.xlf - file_format: xliff - language_code_style: bcp From 787df5b0085832c8b1d059df189f570f48f3e317 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 4 Sep 2025 12:12:56 +0400 Subject: [PATCH 8/8] Deprecate DB translation table --- src/Domain/Configuration/Model/I18n.php | 5 +++++ src/Domain/Configuration/Repository/I18nRepository.php | 1 + 2 files changed, 6 insertions(+) diff --git a/src/Domain/Configuration/Model/I18n.php b/src/Domain/Configuration/Model/I18n.php index bffed897..b8eefd63 100644 --- a/src/Domain/Configuration/Model/I18n.php +++ b/src/Domain/Configuration/Model/I18n.php @@ -8,6 +8,11 @@ use PhpList\Core\Domain\Common\Model\Interfaces\DomainModel; use PhpList\Core\Domain\Configuration\Repository\I18nRepository; +/** + * @deprecated + * + * Symfony\Contracts\Translation will be used instead. + */ #[ORM\Entity(repositoryClass: I18nRepository::class)] #[ORM\Table(name: 'phplist_i18n')] #[ORM\UniqueConstraint(name: 'lanorigunq', columns: ['lan', 'original'])] diff --git a/src/Domain/Configuration/Repository/I18nRepository.php b/src/Domain/Configuration/Repository/I18nRepository.php index f4465103..33fa599a 100644 --- a/src/Domain/Configuration/Repository/I18nRepository.php +++ b/src/Domain/Configuration/Repository/I18nRepository.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; +/** @deprecated */ class I18nRepository extends AbstractRepository { }