From 6c03609c1dc51da83cd0865bf110a97a686772ee Mon Sep 17 00:00:00 2001 From: sergiu Date: Tue, 1 Jul 2025 19:12:17 +0300 Subject: [PATCH 1/4] Issue #16: Implemented core and sending email functionality Signed-off-by: sergiu --- composer.json | 21 +- config/autoload/.gitignore | 1 + config/autoload/local.php.dist | 38 +- config/autoload/mail.global.php.dist | 80 ++++ config/autoload/templates.global.php | 43 ++ config/config.php | 30 +- phpcs.xml.dist => phpcs.xml | 12 +- phpstan.neon | 2 - src/App/ConfigProvider.php | 14 +- src/App/Message/ExampleMessageHandler.php | 73 +++- .../Message/ExampleMessageHandlerFactory.php | 15 - src/Core/src/Admin/src/ConfigProvider.php | 89 +++++ .../src/DBAL/Types/AdminRoleEnumType.php | 23 ++ .../src/DBAL/Types/AdminStatusEnumType.php | 23 ++ src/Core/src/Admin/src/Entity/Admin.php | 244 ++++++++++++ .../src/Admin/src/Entity/AdminIdentity.php | 65 ++++ src/Core/src/Admin/src/Entity/AdminLogin.php | 333 ++++++++++++++++ src/Core/src/Admin/src/Entity/AdminRole.php | 68 ++++ src/Core/src/Admin/src/Enum/AdminRoleEnum.php | 21 + .../src/Admin/src/Enum/AdminStatusEnum.php | 34 ++ .../src/Repository/AdminLoginRepository.php | 72 ++++ .../Admin/src/Repository/AdminRepository.php | 68 ++++ .../src/Repository/AdminRoleRepository.php | 48 +++ .../src/App/src/Command/RouteListCommand.php | 111 ++++++ src/Core/src/App/src/ConfigProvider.php | 209 ++++++++++ .../App/src/DBAL/Types/AbstractEnumType.php | 60 +++ .../src/DBAL/Types/SuccessFailureEnumType.php | 22 ++ .../src/App/src/DBAL/Types/YesNoEnumType.php | 22 ++ .../src/App/src/Entity/AbstractEntity.php | 64 +++ .../src/App/src/Entity/EntityInterface.php | 23 ++ src/Core/src/App/src/Entity/Guest.php | 59 +++ src/Core/src/App/src/Entity/PasswordTrait.php | 30 ++ src/Core/src/App/src/Entity/RoleInterface.php | 28 ++ .../src/App/src/Entity/TimestampsTrait.php | 53 +++ .../src/App/src/Enum/SuccessFailureEnum.php | 21 + src/Core/src/App/src/Enum/YesNoEnum.php | 21 + .../Factory/EntityListenerResolverFactory.php | 16 + src/Core/src/App/src/Fixture/AdminLoader.php | 52 +++ .../src/App/src/Fixture/AdminRoleLoader.php | 25 ++ .../src/App/src/Fixture/OAuthClientLoader.php | 38 ++ .../src/App/src/Fixture/OAuthScopeLoader.php | 21 + src/Core/src/App/src/Fixture/UserLoader.php | 59 +++ .../src/App/src/Fixture/UserRoleLoader.php | 25 ++ src/Core/src/App/src/Helper/Paginator.php | 144 +++++++ .../src/InputFilter/AbstractInputFilter.php | 15 + src/Core/src/App/src/Message.php | 231 +++++++++++ .../src/Migration/Version20250407142911.php | 233 +++++++++++ .../App/src/Repository/AbstractRepository.php | 32 ++ .../src/Resolver/EntityListenerResolver.php | 27 ++ .../AuthenticationServiceInterface.php | 20 + src/Core/src/App/src/Service/IpService.php | 63 +++ src/Core/src/App/src/Service/MailService.php | 127 ++++++ .../NotificationSystem/src/ConfigProvider.php | 41 ++ .../src/Service/NotificationService.php | 52 +++ src/Core/src/Security/src/ConfigProvider.php | 86 ++++ .../Security/src/Entity/OAuthAccessToken.php | 255 ++++++++++++ .../src/Security/src/Entity/OAuthAuthCode.php | 178 +++++++++ .../src/Security/src/Entity/OAuthClient.php | 146 +++++++ .../Security/src/Entity/OAuthRefreshToken.php | 98 +++++ .../src/Security/src/Entity/OAuthScope.php | 138 +++++++ .../Repository/OAuthAccessTokenRepository.php | 98 +++++ .../Repository/OAuthAuthCodeRepository.php | 51 +++ .../src/Repository/OAuthClientRepository.php | 71 ++++ .../OAuthRefreshTokenRepository.php | 51 +++ .../src/Repository/OAuthScopeRepository.php | 43 ++ src/Core/src/Setting/src/ConfigProvider.php | 83 ++++ .../DBAL/Types/SettingIdentifierEnumType.php | 23 ++ src/Core/src/Setting/src/Entity/Setting.php | 103 +++++ .../src/Enum/SettingIdentifierEnum.php | 22 ++ .../src/Repository/SettingRepository.php | 14 + src/Core/src/User/src/ConfigProvider.php | 98 +++++ .../Types/UserResetPasswordStatusEnumType.php | 23 ++ .../User/src/DBAL/Types/UserRoleEnumType.php | 23 ++ .../src/DBAL/Types/UserStatusEnumType.php | 23 ++ src/Core/src/User/src/Entity/User.php | 368 ++++++++++++++++++ src/Core/src/User/src/Entity/UserAvatar.php | 98 +++++ src/Core/src/User/src/Entity/UserDetail.php | 129 ++++++ .../src/User/src/Entity/UserResetPassword.php | 146 +++++++ src/Core/src/User/src/Entity/UserRole.php | 68 ++++ .../src/Enum/UserResetPasswordStatusEnum.php | 11 + src/Core/src/User/src/Enum/UserRoleEnum.php | 21 + src/Core/src/User/src/Enum/UserStatusEnum.php | 44 +++ .../EventListener/UserAvatarEventListener.php | 56 +++ .../src/Repository/UserAvatarRepository.php | 14 + .../src/Repository/UserDetailRepository.php | 14 + .../User/src/Repository/UserRepository.php | 139 +++++++ .../UserResetPasswordRepository.php | 14 + .../src/Repository/UserRoleRepository.php | 48 +++ src/Core/src/User/src/UserIdentity.php | 60 +++ src/Swoole/Command/StartCommand.php | 5 - src/Swoole/ServerFactory.php | 2 +- templates/welcome.html.twig | 3 + 92 files changed, 6179 insertions(+), 49 deletions(-) create mode 100644 config/autoload/mail.global.php.dist create mode 100644 config/autoload/templates.global.php rename phpcs.xml.dist => phpcs.xml (70%) delete mode 100644 src/App/Message/ExampleMessageHandlerFactory.php create mode 100644 src/Core/src/Admin/src/ConfigProvider.php create mode 100644 src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php create mode 100644 src/Core/src/Admin/src/DBAL/Types/AdminStatusEnumType.php create mode 100644 src/Core/src/Admin/src/Entity/Admin.php create mode 100644 src/Core/src/Admin/src/Entity/AdminIdentity.php create mode 100644 src/Core/src/Admin/src/Entity/AdminLogin.php create mode 100644 src/Core/src/Admin/src/Entity/AdminRole.php create mode 100644 src/Core/src/Admin/src/Enum/AdminRoleEnum.php create mode 100644 src/Core/src/Admin/src/Enum/AdminStatusEnum.php create mode 100644 src/Core/src/Admin/src/Repository/AdminLoginRepository.php create mode 100644 src/Core/src/Admin/src/Repository/AdminRepository.php create mode 100644 src/Core/src/Admin/src/Repository/AdminRoleRepository.php create mode 100644 src/Core/src/App/src/Command/RouteListCommand.php create mode 100644 src/Core/src/App/src/ConfigProvider.php create mode 100644 src/Core/src/App/src/DBAL/Types/AbstractEnumType.php create mode 100644 src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php create mode 100644 src/Core/src/App/src/DBAL/Types/YesNoEnumType.php create mode 100644 src/Core/src/App/src/Entity/AbstractEntity.php create mode 100644 src/Core/src/App/src/Entity/EntityInterface.php create mode 100644 src/Core/src/App/src/Entity/Guest.php create mode 100644 src/Core/src/App/src/Entity/PasswordTrait.php create mode 100644 src/Core/src/App/src/Entity/RoleInterface.php create mode 100644 src/Core/src/App/src/Entity/TimestampsTrait.php create mode 100644 src/Core/src/App/src/Enum/SuccessFailureEnum.php create mode 100644 src/Core/src/App/src/Enum/YesNoEnum.php create mode 100644 src/Core/src/App/src/Factory/EntityListenerResolverFactory.php create mode 100644 src/Core/src/App/src/Fixture/AdminLoader.php create mode 100644 src/Core/src/App/src/Fixture/AdminRoleLoader.php create mode 100644 src/Core/src/App/src/Fixture/OAuthClientLoader.php create mode 100644 src/Core/src/App/src/Fixture/OAuthScopeLoader.php create mode 100644 src/Core/src/App/src/Fixture/UserLoader.php create mode 100644 src/Core/src/App/src/Fixture/UserRoleLoader.php create mode 100644 src/Core/src/App/src/Helper/Paginator.php create mode 100644 src/Core/src/App/src/InputFilter/AbstractInputFilter.php create mode 100644 src/Core/src/App/src/Message.php create mode 100644 src/Core/src/App/src/Migration/Version20250407142911.php create mode 100644 src/Core/src/App/src/Repository/AbstractRepository.php create mode 100644 src/Core/src/App/src/Resolver/EntityListenerResolver.php create mode 100644 src/Core/src/App/src/Service/AuthenticationServiceInterface.php create mode 100644 src/Core/src/App/src/Service/IpService.php create mode 100644 src/Core/src/App/src/Service/MailService.php create mode 100644 src/Core/src/NotificationSystem/src/ConfigProvider.php create mode 100644 src/Core/src/NotificationSystem/src/Service/NotificationService.php create mode 100644 src/Core/src/Security/src/ConfigProvider.php create mode 100644 src/Core/src/Security/src/Entity/OAuthAccessToken.php create mode 100644 src/Core/src/Security/src/Entity/OAuthAuthCode.php create mode 100644 src/Core/src/Security/src/Entity/OAuthClient.php create mode 100644 src/Core/src/Security/src/Entity/OAuthRefreshToken.php create mode 100644 src/Core/src/Security/src/Entity/OAuthScope.php create mode 100644 src/Core/src/Security/src/Repository/OAuthAccessTokenRepository.php create mode 100644 src/Core/src/Security/src/Repository/OAuthAuthCodeRepository.php create mode 100644 src/Core/src/Security/src/Repository/OAuthClientRepository.php create mode 100644 src/Core/src/Security/src/Repository/OAuthRefreshTokenRepository.php create mode 100644 src/Core/src/Security/src/Repository/OAuthScopeRepository.php create mode 100644 src/Core/src/Setting/src/ConfigProvider.php create mode 100644 src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php create mode 100644 src/Core/src/Setting/src/Entity/Setting.php create mode 100644 src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php create mode 100644 src/Core/src/Setting/src/Repository/SettingRepository.php create mode 100644 src/Core/src/User/src/ConfigProvider.php create mode 100644 src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php create mode 100644 src/Core/src/User/src/DBAL/Types/UserRoleEnumType.php create mode 100644 src/Core/src/User/src/DBAL/Types/UserStatusEnumType.php create mode 100644 src/Core/src/User/src/Entity/User.php create mode 100644 src/Core/src/User/src/Entity/UserAvatar.php create mode 100644 src/Core/src/User/src/Entity/UserDetail.php create mode 100644 src/Core/src/User/src/Entity/UserResetPassword.php create mode 100644 src/Core/src/User/src/Entity/UserRole.php create mode 100644 src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php create mode 100644 src/Core/src/User/src/Enum/UserRoleEnum.php create mode 100644 src/Core/src/User/src/Enum/UserStatusEnum.php create mode 100644 src/Core/src/User/src/EventListener/UserAvatarEventListener.php create mode 100644 src/Core/src/User/src/Repository/UserAvatarRepository.php create mode 100644 src/Core/src/User/src/Repository/UserDetailRepository.php create mode 100644 src/Core/src/User/src/Repository/UserRepository.php create mode 100644 src/Core/src/User/src/Repository/UserResetPasswordRepository.php create mode 100644 src/Core/src/User/src/Repository/UserRoleRepository.php create mode 100644 src/Core/src/User/src/UserIdentity.php create mode 100644 templates/welcome.html.twig diff --git a/composer.json b/composer.json index 3876e10..d72ce58 100644 --- a/composer.json +++ b/composer.json @@ -44,12 +44,23 @@ }, "require": { "php": "~8.2.0 || ~8.3.0 || ~8.4", + "ext-sockets": "*", + "clue/socket-raw": "^v1.6.0", + "dotkernel/dot-cache": "^4.3", "dotkernel/dot-cli": "^3.9", - "dotkernel/dot-errorhandler": "^4.2.1", + "dotkernel/dot-data-fixtures": "^1.4.0", + "dotkernel/dot-dependency-injection": "^1.2", + "dotkernel/dot-errorhandler": "^4.0.0", + "dotkernel/dot-mail": "^5.3.0", + "dotkernel/dot-twigrenderer": "3.6.0", "laminas/laminas-component-installer": "^3.5", "laminas/laminas-config-aggregator": "^1.18", "mezzio/mezzio": "^3.20", + "mezzio/mezzio-authentication-oauth2": "^2.11", "netglue/laminas-messenger": "^2.3.0", + "ramsey/uuid": "^4.5.0", + "ramsey/uuid-doctrine": "^2.1.0", + "roave/psr-container-doctrine": "^5.2.2", "symfony/redis-messenger": "^v7.2.3" }, "require-dev": { @@ -63,7 +74,13 @@ }, "autoload": { "psr-4": { - "Queue\\": "src/" + "Queue\\": "src/", + "Core\\Admin\\": "src/Core/src/Admin/src", + "Core\\App\\": "src/Core/src/App/src", + "Core\\Security\\": "src/Core/src/Security/src", + "Core\\Setting\\": "src/Core/src/Setting/src", + "Core\\User\\": "src/Core/src/User/src", + "Core\\NotificationSystem\\": "src/Core/src/NotificationSystem/src" } }, "autoload-dev": { diff --git a/config/autoload/.gitignore b/config/autoload/.gitignore index 1a83fda..54b84da 100644 --- a/config/autoload/.gitignore +++ b/config/autoload/.gitignore @@ -1,2 +1,3 @@ local.php *.local.php +mail.global.php diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index db858d7..e9123a0 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -9,5 +9,41 @@ declare(strict_types=1); -return [ +$baseUrl = 'http://localhost:8080'; + +$databases = [ + 'default' => [ + 'host' => '', + 'dbname' => '', + 'user' => '', + 'password' => '', + 'port' => , + 'driver' => 'pdo_mysql', + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_general_ci', + ], + // you can add more database connections into this array ]; + +return [ + 'application' => [ + 'name' => $app['name'] ?? '', + 'url' => $baseUrl, + ], + 'databases' => $databases, + 'doctrine' => [ + 'connection' => [ + 'orm_default' => [ + 'params' => $databases['default'], + ], + ], + ], + 'notification' => [ + 'server' => [ + 'protocol' => '', + 'host' => '', + 'port' => '', + 'eof' => "\n", + ], + ], +]; \ No newline at end of file diff --git a/config/autoload/mail.global.php.dist b/config/autoload/mail.global.php.dist new file mode 100644 index 0000000..a0e2d90 --- /dev/null +++ b/config/autoload/mail.global.php.dist @@ -0,0 +1,80 @@ + [ + //the key is the mail service name, this is the default one, which does not extend any configuration + 'default' => [ + //message configuration + 'message_options' => [ + //from email address of the email + 'from' => '', + //from name to be displayed instead of from address + 'from_name' => '', + //reply-to email address of the email + 'reply_to' => '', + //replyTo name to be displayed instead of the address + 'reply_to_name' => '', + //destination email address as string or a list of email addresses + 'to' => [], + //copy destination addresses + 'cc' => [], + //hidden copy destination addresses + 'bcc' => [], + //email subject + 'subject' => '', + //body options - content can be plain text, HTML + 'body' => [ + 'content' => '', + 'charset' => 'utf-8', + ], + //attachments config + 'attachments' => [ + 'files' => [], + 'dir' => [ + 'iterate' => false, + 'path' => 'data/mail/attachments', + 'recursive' => false, + ], + ], + ], + /** + * the mail transport to use can be any class implementing + * Symfony\Component\Mailer\Transport\TransportInterface + * + * for standard mail transports, you can use these aliases: + * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport + * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport + * + * defaults to sendmail + **/ + 'transport' => 'esmtp', + //options that will be used only if esmtp adapter is used + 'smtp_options' => [ + //hostname or IP address of the mail server + 'host' => '', + //port of the mail server - 587 or 465 for secure connections + 'port' => 587, + 'connection_config' => [ + //the smtp authentication identity + 'username' => '', + //the smtp authentication credential + 'password' => '', + //to disable auto_tls set tls key to false + //it's not recommended to disable TLS while connecting to an SMTP server + 'tls' => null, + ], + ], + ], + // option to log the SENT emails + 'log' => [ + 'sent' => getcwd() . '/log/mail/sent.log', + ], + ], +]; diff --git a/config/autoload/templates.global.php b/config/autoload/templates.global.php new file mode 100644 index 0000000..6b1f459 --- /dev/null +++ b/config/autoload/templates.global.php @@ -0,0 +1,43 @@ + [ + 'factories' => [ + DateExtension::class => InvokableFactory::class, + Environment::class => TwigEnvironmentFactory::class, + TemplateRendererInterface::class => TwigRendererFactory::class, + TranslationExtension::class => InvokableFactory::class, + ], + ], + 'debug' => false, + 'templates' => [ + 'extension' => 'html.twig', + ], + 'twig' => [ + 'assets_url' => '/', + 'assets_version' => null, + 'auto_reload' => true, + 'autoescape' => 'html', + 'cache_dir' => 'data/cache/twig', + 'extensions' => [ + DateExtension::class, + TranslationExtension::class, + ], + 'globals' => [ + 'appName' => $app['name'] ?? '', + ], + 'optimizations' => -1, + 'runtime_loaders' => [], +// 'timezone' => '', + ], +]; diff --git a/config/config.php b/config/config.php index 2862384..e0d8feb 100644 --- a/config/config.php +++ b/config/config.php @@ -15,15 +15,31 @@ $aggregator = new ConfigAggregator([ // Include cache configuration new ArrayProvider($cacheConfig), - \Mezzio\ConfigProvider::class, - \Dot\ErrorHandler\ConfigProvider::class, - \Dot\Log\ConfigProvider::class, - \Dot\Cli\ConfigProvider::class, - \Netglue\PsrContainer\Messenger\ConfigProvider::class, + Mezzio\ConfigProvider::class, + Mezzio\Twig\ConfigProvider::class, + Netglue\PsrContainer\Messenger\ConfigProvider::class, // Default App module config - \Queue\App\ConfigProvider::class, - \Queue\Swoole\ConfigProvider::class, + Queue\App\ConfigProvider::class, + Queue\Swoole\ConfigProvider::class, + + // Dotkernel packages + Dot\Log\ConfigProvider::class, + Dot\Cli\ConfigProvider::class, + Dot\ErrorHandler\ConfigProvider::class, + Dot\DataFixtures\ConfigProvider::class, + Dot\DependencyInjection\ConfigProvider::class, + Dot\Mail\ConfigProvider::class, + Dot\Twig\ConfigProvider::class, + Dot\Cache\ConfigProvider::class, + + // Core modules + Core\Admin\ConfigProvider::class, + Core\App\ConfigProvider::class, + Core\Security\ConfigProvider::class, + Core\Setting\ConfigProvider::class, + Core\User\ConfigProvider::class, + Core\NotificationSystem\ConfigProvider::class, // Load application config in a pre-defined order in such a way that local settings // overwrite global settings. (Loaded as first to last): diff --git a/phpcs.xml.dist b/phpcs.xml similarity index 70% rename from phpcs.xml.dist rename to phpcs.xml index 0cf5b74..1295591 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml @@ -19,14 +19,10 @@ config/routes.php - - - - src/ConfigProvider.*.php - - - + + + + src/Core/src/App/src/Migration/* config/pipeline.php - src/MezzioInstaller/Resources/config/routes-*.php diff --git a/phpstan.neon b/phpstan.neon index db8d511..ed056f0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,5 +7,3 @@ parameters: - src - test treatPhpDocTypesAsCertain: false - ignoreErrors: - - '#Constant Queue\\Swoole\\Command\\StartCommand::PROGRAMMATIC_CONFIG_FILES is unused#' diff --git a/src/App/ConfigProvider.php b/src/App/ConfigProvider.php index 2cc0d51..64a05f7 100644 --- a/src/App/ConfigProvider.php +++ b/src/App/ConfigProvider.php @@ -4,6 +4,7 @@ namespace Queue\App; +use Dot\DependencyInjection\Factory\AttributedServiceFactory; use Netglue\PsrContainer\Messenger\Container\MessageBusStaticFactory; use Netglue\PsrContainer\Messenger\Container\Middleware\BusNameStampMiddlewareStaticFactory; use Netglue\PsrContainer\Messenger\Container\Middleware\MessageHandlerMiddlewareStaticFactory; @@ -11,7 +12,6 @@ use Netglue\PsrContainer\Messenger\HandlerLocator\OneToManyFqcnContainerHandlerLocator; use Queue\App\Message\ExampleMessage; use Queue\App\Message\ExampleMessageHandler; -use Queue\App\Message\ExampleMessageHandlerFactory; use Symfony\Component\Messenger\MessageBusInterface; class ConfigProvider @@ -25,6 +25,7 @@ public function __invoke(): array 'buses' => $this->busConfig(), ], ], + 'templates' => $this->getTemplates(), ]; } @@ -36,7 +37,7 @@ private function getDependencies(): array "message_bus_stamp_middleware" => [BusNameStampMiddlewareStaticFactory::class, "message_bus"], "message_bus_sender_middleware" => [MessageSenderMiddlewareStaticFactory::class, "message_bus"], "message_bus_handler_middleware" => [MessageHandlerMiddlewareStaticFactory::class, "message_bus"], - ExampleMessageHandler::class => ExampleMessageHandlerFactory::class, + ExampleMessageHandler::class => AttributedServiceFactory::class, ], "aliases" => [ MessageBusInterface::class => "message_bus", @@ -44,6 +45,15 @@ private function getDependencies(): array ]; } + public function getTemplates(): array + { + return [ + 'paths' => [ + 'notification-email' => [__DIR__ . '/../../templates'], + ], + ]; + } + private function busConfig(): array { return [ diff --git a/src/App/Message/ExampleMessageHandler.php b/src/App/Message/ExampleMessageHandler.php index b77beba..237a641 100644 --- a/src/App/Message/ExampleMessageHandler.php +++ b/src/App/Message/ExampleMessageHandler.php @@ -4,20 +4,81 @@ namespace Queue\App\Message; +use Core\User\Repository\UserRepository; +use Dot\DependencyInjection\Attribute\Inject; use Dot\Log\Logger; -use Psr\Container\ContainerInterface; +use Dot\Mail\Exception\MailException; +use Dot\Mail\Service\MailService; +use Exception; +use Mezzio\Template\TemplateRendererInterface; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; + +use function json_decode; class ExampleMessageHandler { - public function __construct(private readonly ContainerInterface $container) + protected array $args = []; + + #[Inject( + MailService::class, + TemplateRendererInterface::class, + UserRepository::class, + 'dot-log.queue-log', + 'config', + )] + public function __construct( + protected MailService $mailService, + protected TemplateRendererInterface $templateRenderer, + protected UserRepository $userRepository, + protected Logger $logger, + protected array $config, + ) { + } + + public function __invoke(ExampleMessage $message): void { + $payload = json_decode($message->getPayload()['foo'], true); + + if ($payload !== null && isset($payload['userUuid'])) { + $this->logger->info("message: " . $payload['userUuid']); + $this->args = $payload; + } + + try { + $this->perform(); + } catch (Exception $exception) { + } } - public function __invoke(ExampleMessage $message) + /** + * @throws MailException + */ + public function perform(): void { - /** @var Logger $logger */ - $logger = $this->container->get("dot-log.queue-log"); + $this->sendWelcomeMail(); + } + + /** + * @throws MailException + */ + public function sendWelcomeMail(): bool + { + $user = $this->userRepository->find($this->args['userUuid']); + $this->mailService->getMessage()->addTo('sergiubota@rospace.com', 'sergiu'); + $this->mailService->setSubject('Welcome to ' . $this->config['application']['name']); + $body = $this->templateRenderer->render('notification-email::welcome', [ + 'user' => $user, + 'config' => $this->config, + ]); + + $this->mailService->setBody($body); + + try { + return $this->mailService->send()->isValid(); + } catch (MailException | TransportExceptionInterface $exception) { + $this->logger->notice($exception->getMessage()); + } - $logger->info("message: " . $message->getPayload()['foo']); + return false; } } diff --git a/src/App/Message/ExampleMessageHandlerFactory.php b/src/App/Message/ExampleMessageHandlerFactory.php deleted file mode 100644 index 900ae44..0000000 --- a/src/App/Message/ExampleMessageHandlerFactory.php +++ /dev/null @@ -1,15 +0,0 @@ -, + * }, + * AdminEntities: array{ + * class: class-string, + * cache: non-empty-string, + * paths: non-empty-string[], + * }, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ +class ConfigProvider +{ + /** + * @return ConfigType + */ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + 'doctrine' => $this->getDoctrineConfig(), + ]; + } + + /** + * @return DependenciesType + */ + private function getDependencies(): array + { + return [ + 'factories' => [ + AdminRepository::class => AttributedRepositoryFactory::class, + AdminLoginRepository::class => AttributedRepositoryFactory::class, + AdminRoleRepository::class => AttributedRepositoryFactory::class, + ], + ]; + } + + /** + * @return DoctrineConfigType + */ + private function getDoctrineConfig(): array + { + return [ + 'driver' => [ + 'orm_default' => [ + 'drivers' => [ + 'Core\Admin\Entity' => 'AdminEntities', + ], + ], + 'AdminEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => [__DIR__ . '/Entity'], + ], + ], + 'types' => [ + AdminRoleEnumType::NAME => AdminRoleEnumType::class, + AdminStatusEnumType::NAME => AdminStatusEnumType::class, + ], + ]; + } +} diff --git a/src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php b/src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php new file mode 100644 index 0000000..3900773 --- /dev/null +++ b/src/Core/src/Admin/src/DBAL/Types/AdminRoleEnumType.php @@ -0,0 +1,23 @@ + AdminStatusEnum::Active] + )] + protected AdminStatusEnum $status = AdminStatusEnum::Active; + + /** @var Collection $roles */ + #[ORM\ManyToMany(targetEntity: AdminRole::class)] + #[ORM\JoinTable(name: 'admin_roles')] + #[ORM\JoinColumn(name: 'userUuid', referencedColumnName: 'uuid')] + #[ORM\InverseJoinColumn(name: 'roleUuid', referencedColumnName: 'uuid')] + protected Collection $roles; + + /** @var Collection $settings */ + #[ORM\OneToMany(targetEntity: Setting::class, mappedBy: 'admin')] + protected Collection $settings; + + public function __construct() + { + parent::__construct(); + + $this->created(); + $this->roles = new ArrayCollection(); + $this->settings = new ArrayCollection(); + } + + public function getIdentity(): ?string + { + return $this->identity; + } + + public function hasIdentity(): bool + { + return $this->identity !== null; + } + + /** + * @param non-empty-string $identity + */ + public function setIdentity(string $identity): self + { + $this->identity = $identity; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): self + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): self + { + $this->lastName = $lastName; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + public function getStatus(): AdminStatusEnum + { + return $this->status; + } + + public function setStatus(AdminStatusEnum $status): self + { + $this->status = $status; + + return $this; + } + + /** + * @return RoleInterface[] + */ + public function getRoles(): array + { + return $this->roles->toArray(); + } + + /** + * @param RoleInterface[] $roles + */ + public function setRoles(array $roles): self + { + foreach ($roles as $role) { + $this->roles->add($role); + } + + return $this; + } + + public function addRole(RoleInterface $role): self + { + if (! $this->roles->contains($role)) { + $this->roles->add($role); + } + + return $this; + } + + public function hasRole(RoleInterface $role): bool + { + return $this->roles->contains($role); + } + + public function hasRoles(): bool + { + return $this->roles->count() > 0; + } + + public function removeRole(RoleInterface $role): self + { + if ($this->roles->contains($role)) { + $this->roles->removeElement($role); + } + + return $this; + } + + public function resetRoles(): self + { + $this->roles = new ArrayCollection(); + + return $this; + } + + public function activate(): self + { + $this->status = AdminStatusEnum::Active; + + return $this; + } + + public function deactivate(): self + { + $this->status = AdminStatusEnum::Inactive; + + return $this; + } + + public function isActive(): bool + { + return $this->status === AdminStatusEnum::Active; + } + + public function getIdentifier(): string + { + return (string) $this->identity; + } + + /** + * @return array{ + * uuid: non-empty-string, + * identity: non-empty-string|null, + * firstName: string|null, + * lastName: string|null, + * status: non-empty-string, + * roles: iterable, + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null, + * } + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'identity' => $this->identity, + 'firstName' => $this->firstName, + 'lastName' => $this->lastName, + 'status' => $this->status->value, + 'roles' => array_map(fn (RoleInterface $role): array => $role->getArrayCopy(), $this->roles->toArray()), + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/Admin/src/Entity/AdminIdentity.php b/src/Core/src/Admin/src/Entity/AdminIdentity.php new file mode 100644 index 0000000..dc3f50d --- /dev/null +++ b/src/Core/src/Admin/src/Entity/AdminIdentity.php @@ -0,0 +1,65 @@ + $details + */ + public function __construct( + public string $uuid, + public string $identity, + public AdminStatusEnum $status, + public array $roles = [], + public array $details = [], + ) { + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getIdentity(): string + { + return $this->identity; + } + + public function getStatus(): AdminStatusEnum + { + return $this->status; + } + + public function isActive(): bool + { + return $this->getStatus() === AdminStatusEnum::Active; + } + + public function getRoles(): iterable + { + return $this->roles; + } + + /** + * @psalm-return array + */ + public function getDetails(): array + { + return $this->details; + } + + /** + * @param mixed|null $default + */ + public function getDetail(string $name, $default = null): mixed + { + return $this->details[$name] ?? $default; + } +} diff --git a/src/Core/src/Admin/src/Entity/AdminLogin.php b/src/Core/src/Admin/src/Entity/AdminLogin.php new file mode 100644 index 0000000..46b1790 --- /dev/null +++ b/src/Core/src/Admin/src/Entity/AdminLogin.php @@ -0,0 +1,333 @@ +created(); + } + + public function getIdentity(): ?string + { + return $this->identity; + } + + public function setIdentity(string $identity): self + { + $this->identity = $identity; + + return $this; + } + + public function getAdminIp(): ?string + { + return $this->adminIp; + } + + public function setAdminIp(?string $adminIp): self + { + $this->adminIp = $adminIp; + + return $this; + } + + public function getCountry(): ?string + { + return $this->country; + } + + public function setCountry(?string $country): self + { + $this->country = $country; + + return $this; + } + + public function getContinent(): ?string + { + return $this->continent; + } + + public function setContinent(?string $continent): self + { + $this->continent = $continent; + + return $this; + } + + public function getOrganization(): ?string + { + return $this->organization; + } + + public function setOrganization(?string $organization): self + { + $this->organization = $organization; + + return $this; + } + + public function getDeviceType(): ?string + { + return $this->deviceType; + } + + public function setDeviceType(?string $deviceType): self + { + $this->deviceType = $deviceType; + + return $this; + } + + public function getDeviceBrand(): ?string + { + return $this->deviceBrand; + } + + public function setDeviceBrand(?string $deviceBrand): self + { + $this->deviceBrand = $deviceBrand; + + return $this; + } + + public function getDeviceModel(): ?string + { + return $this->deviceModel; + } + + public function setDeviceModel(?string $deviceModel): self + { + $this->deviceModel = $deviceModel; + + return $this; + } + + public function getIsMobile(): ?YesNoEnum + { + return $this->isMobile; + } + + public function setIsMobile(YesNoEnum $isMobile): self + { + $this->isMobile = $isMobile; + + return $this; + } + + public function getOsName(): ?string + { + return $this->osName; + } + + public function setOsName(?string $osName): self + { + $this->osName = $osName; + + return $this; + } + + public function getOsVersion(): ?string + { + return $this->osVersion; + } + + public function setOsVersion(?string $osVersion): self + { + $this->osVersion = $osVersion; + + return $this; + } + + public function getOsPlatform(): ?string + { + return $this->osPlatform; + } + + public function setOsPlatform(?string $osPlatform): self + { + $this->osPlatform = $osPlatform; + + return $this; + } + + public function getClientType(): ?string + { + return $this->clientType; + } + + public function setClientType(?string $clientType): self + { + $this->clientType = $clientType; + + return $this; + } + + public function getClientName(): ?string + { + return $this->clientName; + } + + public function setClientName(?string $clientName): self + { + $this->clientName = $clientName; + + return $this; + } + + public function getClientEngine(): ?string + { + return $this->clientEngine; + } + + public function setClientEngine(?string $clientEngine): self + { + $this->clientEngine = $clientEngine; + + return $this; + } + + public function getClientVersion(): ?string + { + return $this->clientVersion; + } + + public function setClientVersion(?string $clientVersion): self + { + $this->clientVersion = $clientVersion; + + return $this; + } + + public function getLoginStatus(): ?SuccessFailureEnum + { + return $this->loginStatus; + } + + public function setLoginStatus(SuccessFailureEnum $loginStatus): self + { + $this->loginStatus = $loginStatus; + + return $this; + } + + /** + * @return array{ + * uuid: non-empty-string, + * identity: string|null, + * adminIp: string|null, + * country: string|null, + * continent: string|null, + * organization: string|null, + * deviceType: string|null, + * deviceBrand: string|null, + * deviceModel: string|null, + * isMobile: string, + * osName: string|null, + * osVersion: string|null, + * osPlatform: string|null, + * clientType: string|null, + * clientName: string|null, + * clientEngine: string|null, + * clientVersion: string|null, + * loginStatus: string, + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null, + * } + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'identity' => $this->identity, + 'adminIp' => $this->adminIp, + 'country' => $this->country, + 'continent' => $this->continent, + 'organization' => $this->organization, + 'deviceType' => $this->deviceType, + 'deviceBrand' => $this->deviceBrand, + 'deviceModel' => $this->deviceModel, + 'isMobile' => $this->isMobile->value, + 'osName' => $this->osName, + 'osVersion' => $this->osVersion, + 'osPlatform' => $this->osPlatform, + 'clientType' => $this->clientType, + 'clientName' => $this->clientName, + 'clientEngine' => $this->clientEngine, + 'clientVersion' => $this->clientVersion, + 'loginStatus' => $this->loginStatus->value, + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/Admin/src/Entity/AdminRole.php b/src/Core/src/Admin/src/Entity/AdminRole.php new file mode 100644 index 0000000..fb36102 --- /dev/null +++ b/src/Core/src/Admin/src/Entity/AdminRole.php @@ -0,0 +1,68 @@ + AdminRoleEnum::Admin] + )] + protected AdminRoleEnum $name = AdminRoleEnum::Admin; + + public function __construct() + { + parent::__construct(); + + $this->created(); + } + + public function getName(): AdminRoleEnum + { + return $this->name; + } + + /** + * @param AdminRoleEnum $name + */ + public function setName(BackedEnum $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return RoleType + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'name' => $this->name->value, + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/Admin/src/Enum/AdminRoleEnum.php b/src/Core/src/Admin/src/Enum/AdminRoleEnum.php new file mode 100644 index 0000000..58426f9 --- /dev/null +++ b/src/Core/src/Admin/src/Enum/AdminRoleEnum.php @@ -0,0 +1,21 @@ + + */ + public static function toArray(): array + { + return array_reduce(self::cases(), function (array $collector, self $enum): array { + $collector[$enum->value] = $enum->name; + + return $collector; + }, []); + } +} diff --git a/src/Core/src/Admin/src/Repository/AdminLoginRepository.php b/src/Core/src/Admin/src/Repository/AdminLoginRepository.php new file mode 100644 index 0000000..ade964e --- /dev/null +++ b/src/Core/src/Admin/src/Repository/AdminLoginRepository.php @@ -0,0 +1,72 @@ +getQueryBuilder() + ->select('DISTINCT adminLogin.identity') + ->from(AdminLogin::class, 'adminLogin') + ->orderBy('adminLogin.identity', 'ASC') + ->getQuery()->getResult(); + + return array_column($results, 'identity'); + } + + /** + * @param array $params + * @param array $filters + */ + public function getAdminLogins(array $params = [], array $filters = []): QueryBuilder + { + $queryBuilder = $this + ->getQueryBuilder() + ->select(['login']) + ->from(AdminLogin::class, 'login'); + + if ( + array_key_exists('identity', $filters) + && is_string($filters['identity']) + && strlen($filters['identity']) > 0 + ) { + $queryBuilder + ->andWhere($queryBuilder->expr()->like('login.identity', ':search')) + ->setParameter('search', '%' . $filters['identity'] . '%'); + } + if ( + array_key_exists('status', $filters) + && is_string($filters['status']) + && strlen($filters['status']) > 0 + ) { + $queryBuilder + ->andWhere('login.loginStatus = :status') + ->setParameter('status', $filters['status']); + } + + $queryBuilder + ->orderBy($params['sort'], $params['dir']) + ->setFirstResult($params['offset']) + ->setMaxResults($params['limit']); + $queryBuilder->getQuery()->useQueryCache(true); + + return $queryBuilder; + } +} diff --git a/src/Core/src/Admin/src/Repository/AdminRepository.php b/src/Core/src/Admin/src/Repository/AdminRepository.php new file mode 100644 index 0000000..f366ac5 --- /dev/null +++ b/src/Core/src/Admin/src/Repository/AdminRepository.php @@ -0,0 +1,68 @@ + $params + * @param array $filters + */ + public function getAdmins(array $params = [], array $filters = []): QueryBuilder + { + $queryBuilder = $this + ->getQueryBuilder() + ->select(['admin']) + ->from(Admin::class, 'admin') + ->leftJoin('admin.roles', 'role'); + + if ( + array_key_exists('identity', $filters) + && is_string($filters['identity']) + && strlen($filters['identity']) > 0 + ) { + $queryBuilder + ->andWhere($queryBuilder->expr()->like('admin.identity', ':identity')) + ->setParameter('identity', '%' . $filters['identity'] . '%'); + } + if ( + array_key_exists('status', $filters) + && is_string($filters['status']) + && strlen($filters['status']) > 0 + ) { + $queryBuilder + ->andWhere('admin.status = :status') + ->setParameter('status', $filters['status']); + } + if ( + array_key_exists('role', $filters) + && is_string($filters['role']) + && strlen($filters['role']) > 0 + ) { + $queryBuilder + ->andWhere('role.name = :role') + ->setParameter('role', $filters['role']); + } + + $queryBuilder + ->orderBy($params['sort'], $params['dir']) + ->setFirstResult($params['offset']) + ->setMaxResults($params['limit']) + ->groupBy('admin.uuid'); + $queryBuilder->getQuery()->useQueryCache(true); + + return $queryBuilder; + } +} diff --git a/src/Core/src/Admin/src/Repository/AdminRoleRepository.php b/src/Core/src/Admin/src/Repository/AdminRoleRepository.php new file mode 100644 index 0000000..59b02fa --- /dev/null +++ b/src/Core/src/Admin/src/Repository/AdminRoleRepository.php @@ -0,0 +1,48 @@ + $params + * @param array $filters + */ + public function getAdminRoles(array $params = [], array $filters = []): QueryBuilder + { + $queryBuilder = $this + ->getQueryBuilder() + ->select(['role']) + ->from(AdminRole::class, 'role'); + + if ( + array_key_exists('name', $filters) + && is_string($filters['name']) + && strlen($filters['name']) > 0 + ) { + $queryBuilder + ->andWhere('role.name = :name') + ->setParameter('name', $filters['name']); + } + + $queryBuilder + ->orderBy($params['sort'], $params['dir']) + ->setFirstResult($params['offset']) + ->setMaxResults($params['limit']); + $queryBuilder->getQuery()->useQueryCache(true); + + return $queryBuilder; + } +} diff --git a/src/Core/src/App/src/Command/RouteListCommand.php b/src/Core/src/App/src/Command/RouteListCommand.php new file mode 100644 index 0000000..e932657 --- /dev/null +++ b/src/Core/src/App/src/Command/RouteListCommand.php @@ -0,0 +1,111 @@ +setName(self::$defaultName) + ->setDescription('List application routes.') + ->addUsage('[-i|--name[=NAME]] [-p|--path[=PATH]] [-m|--method[=METHOD]]') + ->addOption('name', 'i', InputOption::VALUE_OPTIONAL, 'Filter routes by name') + ->addOption('path', 'p', InputOption::VALUE_OPTIONAL, 'Filter routes by path') + ->addOption('method', 'm', InputOption::VALUE_OPTIONAL, 'Filter routes by method'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $nameFilter = (string) $input->getOption('name'); + $pathFilter = (string) $input->getOption('path'); + $methodFilter = (string) $input->getOption('method'); + + $routes = []; + foreach ($this->application->getRoutes() as $route) { + $methods = $route->getAllowedMethods(); + if (empty($methods)) { + $methods = [ + RequestMethodInterface::METHOD_DELETE, + RequestMethodInterface::METHOD_GET, + RequestMethodInterface::METHOD_PATCH, + RequestMethodInterface::METHOD_POST, + RequestMethodInterface::METHOD_PUT, + ]; + } + + foreach ($methods as $method) { + if (! str_contains($route->getName(), $nameFilter)) { + continue; + } + if (! str_contains($route->getPath(), $pathFilter)) { + continue; + } + if (! str_contains($method, $methodFilter)) { + continue; + } + + $routes[sprintf('%s-%s', $route->getPath(), $method)] = [ + 'name' => $route->getName(), + 'path' => $route->getPath(), + 'method' => $method, + ]; + } + } + ksort($routes); + + $index = 1; + $table = (new Table($output)) + ->setHeaders([' #', 'Request method', 'Route name', 'Route path']) + ->setHeaderTitle(sprintf('%d Routes', count($routes))); + foreach ($routes as $route) { + $table->addRow([ + str_pad((string) $index++, 4, ' ', STR_PAD_LEFT), + $route['method'], + $route['name'], + str_replace(ConfigProvider::REGEXP_UUID, '{uuid}', $route['path']), + ]); + } + $table->render(); + + return Command::SUCCESS; + } +} diff --git a/src/Core/src/App/src/ConfigProvider.php b/src/Core/src/App/src/ConfigProvider.php new file mode 100644 index 0000000..7b1e445 --- /dev/null +++ b/src/Core/src/App/src/ConfigProvider.php @@ -0,0 +1,209 @@ +, + * }, + * filesystem: array{ + * class: class-string, + * directory: non-empty-string, + * namespace: non-empty-string, + * }, + * }, + * configuration: array{ + * orm_default: array{ + * entity_listener_resolver: class-string, + * result_cache: non-empty-string, + * metadata_cache: non-empty-string, + * query_cache: non-empty-string, + * hydration_cache: non-empty-string, + * typed_field_mapper: non-empty-string|null, + * second_level_cache: array{ + * enabled: bool, + * default_lifetime: int, + * default_lock_lifetime: int, + * file_lock_region_directory: string, + * regions: non-empty-string[], + * }, + * }, + * }, + * connection: array{ + * orm_default: array{ + * doctrine_mapping_types: array, + * }, + * }, + * driver: array{ + * orm_default: array{ + * class: class-string, + * }, + * }, + * fixtures: non-empty-string, + * migrations: array{ + * table_storage: array{ + * table_name: non-empty-string, + * version_column_name: non-empty-string, + * version_column_length: int, + * executed_at_column_name: non-empty-string, + * execution_time_column_name: non-empty-string, + * }, + * migrations_paths: array, + * all_or_nothing: bool, + * check_database_platform: bool, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * aliases: array, + * } + */ +class ConfigProvider +{ + public const REGEXP_UUID = '{uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}}'; + + /** + * @return ConfigType + */ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + 'doctrine' => $this->getDoctrineConfig(), + 'resultCacheLifetime' => 600, + ]; + } + + /** + * @return DependenciesType + */ + private function getDependencies(): array + { + return [ + 'factories' => [ + 'doctrine.entity_manager.orm_default' => EntityManagerFactory::class, + 'dot-mail.options.default' => MailOptionsAbstractFactory::class, + 'dot-mail.service.default' => MailServiceAbstractFactory::class, + EntityListenerResolver::class => EntityListenerResolverFactory::class, + MailService::class => AttributedServiceFactory::class, + RouteListCommand::class => AttributedServiceFactory::class, + ], + 'aliases' => [ + DotMailService::class => 'dot-mail.service.default', + EntityManager::class => 'doctrine.entity_manager.orm_default', + EntityManagerInterface::class => 'doctrine.entity_manager.orm_default', + ErrorHandlerInterface::class => LogErrorHandler::class, + ], + ]; + } + + /** + * @return DoctrineConfigType + */ + private function getDoctrineConfig(): array + { + return [ + 'cache' => [ + 'array' => [ + 'class' => ArrayAdapter::class, + ], + 'filesystem' => [ + 'class' => FilesystemAdapter::class, + 'directory' => getcwd() . '/data/cache', + 'namespace' => 'doctrine', + ], + ], + 'configuration' => [ + 'orm_default' => [ + 'entity_listener_resolver' => EntityListenerResolver::class, + 'result_cache' => 'filesystem', + 'metadata_cache' => 'filesystem', + 'query_cache' => 'filesystem', + 'hydration_cache' => 'array', + 'typed_field_mapper' => null, + 'second_level_cache' => [ + 'enabled' => true, + 'default_lifetime' => 3600, + 'default_lock_lifetime' => 60, + 'file_lock_region_directory' => '', + 'regions' => [], + ], + ], + ], + 'connection' => [ + 'orm_default' => [ + 'doctrine_mapping_types' => [ + UuidBinaryType::NAME => 'binary', + UuidBinaryOrderedTimeType::NAME => 'binary', + ], + ], + ], + 'driver' => [ + // The default metadata driver aggregates all other drivers into a single one. + // Override `orm_default` only if you know what you're doing. + 'orm_default' => [ + 'class' => MappingDriverChain::class, + ], + ], + 'fixtures' => getcwd() . '/src/Core/src/App/src/Fixture', + 'migrations' => [ + 'table_storage' => [ + 'table_name' => 'doctrine_migration_versions', + 'version_column_name' => 'version', + 'version_column_length' => 191, + 'executed_at_column_name' => 'executed_at', + 'execution_time_column_name' => 'execution_time', + ], + 'migrations_paths' => [ + 'Core\App\Migration' => 'src/Core/src/App/src/Migration', + ], + 'all_or_nothing' => true, + 'check_database_platform' => true, + ], + 'types' => [ + UuidType::NAME => UuidType::class, + UuidBinaryType::NAME => UuidBinaryType::class, + UuidBinaryOrderedTimeType::NAME => UuidBinaryOrderedTimeType::class, + SuccessFailureEnumType::NAME => SuccessFailureEnumType::class, + YesNoEnumType::NAME => YesNoEnumType::class, + ], + ]; + } +} diff --git a/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php b/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php new file mode 100644 index 0000000..c62b044 --- /dev/null +++ b/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php @@ -0,0 +1,60 @@ + "'$case->value'", $this->getEnumValues()); + + return sprintf('ENUM(%s)', implode(', ', $values)); + } + + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed + { + return $this->getValue($value); + } + + public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed + { + return $this->getValue($value); + } + + /** + * @return class-string + */ + abstract protected function getEnumClass(): string; + + /** + * @return BackedEnum[] + */ + private function getEnumValues(): array + { + return $this->getEnumClass()::cases(); + } + + private function getValue(mixed $value): mixed + { + if (! $value instanceof BackedEnum) { + return $value; + } + + return $value->value; + } +} diff --git a/src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php b/src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php new file mode 100644 index 0000000..4950088 --- /dev/null +++ b/src/Core/src/App/src/DBAL/Types/SuccessFailureEnumType.php @@ -0,0 +1,22 @@ +uuid = Uuid::uuid7(); + } + + public function getUuid(): UuidInterface + { + return $this->uuid; + } + + /** + * Override this method in soft-deletable entities + */ + public function isDeleted(): bool + { + return false; + } + + /** + * @param array $array + */ + public function exchangeArray(array $array): void + { + foreach ($array as $property => $values) { + if (is_array($values)) { + $method = 'add' . ucfirst($property); + if (! method_exists($this, $method)) { + continue; + } + foreach ($values as $value) { + $this->$method($value); + } + } else { + $method = 'set' . ucfirst($property); + if (! method_exists($this, $method)) { + continue; + } + $this->$method($values); + } + } + } +} diff --git a/src/Core/src/App/src/Entity/EntityInterface.php b/src/Core/src/App/src/Entity/EntityInterface.php new file mode 100644 index 0000000..80ce609 --- /dev/null +++ b/src/Core/src/App/src/Entity/EntityInterface.php @@ -0,0 +1,23 @@ +value; + + /** @var Collection */ + protected Collection $roles; + + public function __construct() + { + $this->roles = new ArrayCollection(); + + $this->roles->add( + (new UserRole())->setName(UserRoleEnum::Guest) + ); + } + + public function getIdentity(): string + { + return $this->identity; + } + + public function setIdentity(string $identity): self + { + $this->identity = $identity; + + return $this; + } + + /** + * @return RoleInterface[] + */ + public function getRoles(): array + { + return $this->roles->toArray(); + } + + /** + * @param RoleInterface[] $roles + */ + public function setRoles(array $roles): self + { + foreach ($roles as $role) { + $this->roles->add($role); + } + + return $this; + } +} diff --git a/src/Core/src/App/src/Entity/PasswordTrait.php b/src/Core/src/App/src/Entity/PasswordTrait.php new file mode 100644 index 0000000..412652e --- /dev/null +++ b/src/Core/src/App/src/Entity/PasswordTrait.php @@ -0,0 +1,30 @@ +password = $this->hashPassword($password); + + return $this; + } + + public function hashPassword(string $password): string + { + return password_hash($password, PASSWORD_DEFAULT); + } + + public function verifyPassword(string $password): bool + { + return password_verify($password, (string) $this->password); + } +} diff --git a/src/Core/src/App/src/Entity/RoleInterface.php b/src/Core/src/App/src/Entity/RoleInterface.php new file mode 100644 index 0000000..2a2e138 --- /dev/null +++ b/src/Core/src/App/src/Entity/RoleInterface.php @@ -0,0 +1,28 @@ +created; + } + + public function getCreatedFormatted(string $dateFormat = 'Y-m-d H:i:s'): string + { + return $this->created->format($dateFormat); + } + + public function getUpdated(): ?DateTimeImmutable + { + return $this->updated; + } + + public function getUpdatedFormatted(string $dateFormat = 'Y-m-d H:i:s'): ?string + { + if ($this->updated instanceof DateTimeImmutable) { + return $this->updated->format($dateFormat); + } + + return null; + } + + #[ORM\PrePersist] + public function created(): void + { + $this->created = new DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function touch(): void + { + $this->updated = new DateTimeImmutable(); + } +} diff --git a/src/Core/src/App/src/Enum/SuccessFailureEnum.php b/src/Core/src/App/src/Enum/SuccessFailureEnum.php new file mode 100644 index 0000000..a1bbc7e --- /dev/null +++ b/src/Core/src/App/src/Enum/SuccessFailureEnum.php @@ -0,0 +1,21 @@ +getRepository(AdminRole::class); + + $adminRole = $adminRoleRepository->findOneBy([ + 'name' => AdminRoleEnum::Admin, + ]); + assert($adminRole instanceof AdminRole); + + $superUserRole = $adminRoleRepository->findOneBy([ + 'name' => AdminRoleEnum::Superuser, + ]); + assert($superUserRole instanceof AdminRole); + + $admin = (new Admin()) + ->setIdentity('admin') + ->usePassword('dotadmin') + ->setFirstName('Dotkernel') + ->setLastName('Admin') + ->setStatus(AdminStatusEnum::Active) + ->addRole($adminRole) + ->addRole($superUserRole); + + $manager->persist($admin); + $manager->flush(); + } + + public function getDependencies(): array + { + return [ + AdminRoleLoader::class, + ]; + } +} diff --git a/src/Core/src/App/src/Fixture/AdminRoleLoader.php b/src/Core/src/App/src/Fixture/AdminRoleLoader.php new file mode 100644 index 0000000..6c25083 --- /dev/null +++ b/src/Core/src/App/src/Fixture/AdminRoleLoader.php @@ -0,0 +1,25 @@ +persist( + (new AdminRole())->setName(AdminRoleEnum::Superuser) + ); + $manager->persist( + (new AdminRole())->setName(AdminRoleEnum::Admin) + ); + + $manager->flush(); + } +} diff --git a/src/Core/src/App/src/Fixture/OAuthClientLoader.php b/src/Core/src/App/src/Fixture/OAuthClientLoader.php new file mode 100644 index 0000000..a971c41 --- /dev/null +++ b/src/Core/src/App/src/Fixture/OAuthClientLoader.php @@ -0,0 +1,38 @@ +setName('frontend') + ->setSecret(password_hash('frontend', PASSWORD_DEFAULT)) + ->setRedirect('/') + ->setIsConfidential(false) + ->setIsRevoked(false); + $manager->persist($oauthClientFrontend); + + $oauthClientAdmin = (new OAuthClient()) + ->setName('admin') + ->setSecret(password_hash('admin', PASSWORD_DEFAULT)) + ->setRedirect('/') + ->setIsConfidential(false) + ->setIsRevoked(false); + $manager->persist($oauthClientAdmin); + + $manager->flush(); + } +} diff --git a/src/Core/src/App/src/Fixture/OAuthScopeLoader.php b/src/Core/src/App/src/Fixture/OAuthScopeLoader.php new file mode 100644 index 0000000..96ea80e --- /dev/null +++ b/src/Core/src/App/src/Fixture/OAuthScopeLoader.php @@ -0,0 +1,21 @@ +persist( + (new OAuthScope())->setScope('api') + ); + + $manager->flush(); + } +} diff --git a/src/Core/src/App/src/Fixture/UserLoader.php b/src/Core/src/App/src/Fixture/UserLoader.php new file mode 100644 index 0000000..5906fc2 --- /dev/null +++ b/src/Core/src/App/src/Fixture/UserLoader.php @@ -0,0 +1,59 @@ +getRepository(UserRole::class); + + $guestRole = $userRoleRepository->findOneBy([ + 'name' => UserRoleEnum::Guest, + ]); + assert($guestRole instanceof UserRole); + + $userRole = $userRoleRepository->findOneBy([ + 'name' => UserRoleEnum::User, + ]); + assert($userRole instanceof UserRole); + + $user = (new User()) + ->setIdentity('test@dotkernel.com') + ->usePassword('dotkernel') + ->setStatus(UserStatusEnum::Active) + ->setHash(User::generateHash()) + ->addRole($guestRole) + ->addRole($userRole); + $manager->persist($user); + + $userDetail = (new UserDetail()) + ->setUser($user) + ->setFirstName('Test') + ->setLastName('Account') + ->setEmail('test@dotkernel.com'); + $manager->persist($userDetail); + + $manager->flush(); + } + + public function getDependencies(): array + { + return [ + UserRoleLoader::class, + ]; + } +} diff --git a/src/Core/src/App/src/Fixture/UserRoleLoader.php b/src/Core/src/App/src/Fixture/UserRoleLoader.php new file mode 100644 index 0000000..19f5f3e --- /dev/null +++ b/src/Core/src/App/src/Fixture/UserRoleLoader.php @@ -0,0 +1,25 @@ +persist( + (new UserRole())->setName(UserRoleEnum::User) + ); + $manager->persist( + (new UserRole())->setName(UserRoleEnum::Guest) + ); + + $manager->flush(); + } +} diff --git a/src/Core/src/App/src/Helper/Paginator.php b/src/Core/src/App/src/Helper/Paginator.php new file mode 100644 index 0000000..7b62ac9 --- /dev/null +++ b/src/Core/src/App/src/Helper/Paginator.php @@ -0,0 +1,144 @@ + $params + * @param non-empty-string $sort + * @param non-empty-string $dir + * @return array{ + * offset: int, + * limit: int, + * page: int, + * sort: non-empty-string, + * dir: non-empty-string + * } + */ + public static function getParams(array $params, string $sort, string $dir = 'desc'): array + { + $offset = 0; + $limit = 10; + $page = 1; + + if (array_key_exists('sort', $params) && is_string($params['sort']) && strlen($params['sort']) > 0) { + $sort = $params['sort']; + } + + if (array_key_exists('dir', $params) && in_array($params['dir'], ['asc', 'desc'], true)) { + $dir = $params['dir']; + } + + if (array_key_exists('all', $params)) { + return [ + 'offset' => $offset, + 'limit' => 1_000, + 'page' => $page, + 'sort' => $sort, + 'dir' => $dir, + ]; + } + + if (array_key_exists('limit', $params) && is_numeric($params['limit']) && $params['limit'] > 0) { + $limit = (int) $params['limit']; + } + + if (array_key_exists('offset', $params) && is_numeric($params['offset']) && $params['offset'] > 0) { + $offset = (int) $params['offset']; + $page = ($offset + $limit) / $limit; + } + + if (array_key_exists('page', $params) && is_numeric($params['page']) && $params['page'] > 0) { + $page = (int) $params['page']; + $offset = ($page - 1) * $limit; + } + + return [ + 'offset' => $offset, + 'limit' => $limit, + 'page' => $page, + 'sort' => $sort, + 'dir' => $dir, + ]; + } + + /** + * @param DoctrinePaginator $paginator + * @param array $params + * @param array $filters + * @return array + */ + public static function wrapper(DoctrinePaginator $paginator, array $params = [], array $filters = []): array + { + $params['count'] = $paginator->count(); + $params['items'] = $paginator->getQuery()->getResult(); + $params['filters'] = $filters; + + $params['currentPage'] = (int) ceil($params['offset'] / $params['limit']) + 1; + $params['firstPage'] = 1; + $params['previousPage'] = max($params['currentPage'] - 1, 1); + $params['lastPage'] = $params['count'] > 0 + ? (int) ceil($params['count'] / $params['limit']) + : $params['firstPage']; + $params['isOutOfBounds'] = $params['currentPage'] > $params['lastPage']; + $params['nextPage'] = min($params['currentPage'] + 1, $params['lastPage']); + $params['isFirstPage'] = $params['page'] === $params['firstPage']; + $params['isLastPage'] = $params['currentPage'] === $params['lastPage']; + $params['hasPreviousPage'] = $params['currentPage'] > $params['firstPage']; + $params['hasNextPage'] = $params['currentPage'] < $params['lastPage']; + if ($params['isOutOfBounds']) { + $params['previousPage'] = max($params['lastPage'], 1); + $params['hasNextPage'] = false; + $params['isLastPage'] = true; + } + + $params['firstOffset'] = 0; + $params['previousOffset'] = max(0, $params['offset'] - $params['limit']); + $params['lastOffset'] = ($params['lastPage'] - 1) * $params['limit']; + $params['nextOffset'] = min($params['offset'] + $params['limit'], $params['lastOffset']); + if ($params['isOutOfBounds']) { + $params['previousOffset'] = $params['lastOffset']; + } + + $params['range'] = 5; + if ($params['isOutOfBounds']) { + $params['pages'] = range( + max(1, $params['lastPage'] - $params['range']), + min($params['lastPage'], $params['currentPage'] + $params['range']) + ); + } else { + $params['pages'] = range( + max(1, $params['currentPage'] - $params['range']), + min($params['lastPage'], $params['currentPage'] + $params['range']) + ); + } + + $params['queryParams'] = [ + 'filters' => $params['filters'], + 'offset' => $params['offset'], + 'limit' => $params['limit'], + 'sort' => $params['sort'], + 'dir' => $params['dir'], + ]; + + return $params; + } +} diff --git a/src/Core/src/App/src/InputFilter/AbstractInputFilter.php b/src/Core/src/App/src/InputFilter/AbstractInputFilter.php new file mode 100644 index 0000000..23d7fcc --- /dev/null +++ b/src/Core/src/App/src/InputFilter/AbstractInputFilter.php @@ -0,0 +1,15 @@ + + */ +abstract class AbstractInputFilter extends InputFilter +{ +} diff --git a/src/Core/src/App/src/Message.php b/src/Core/src/App/src/Message.php new file mode 100644 index 0000000..1ef9f49 --- /dev/null +++ b/src/Core/src/App/src/Message.php @@ -0,0 +1,231 @@ + Accepted mim type(s): %s'; + public const RESTRICTION_ROLES = 'At least one role is required.'; + public const ROLE_NOT_FOUND = 'Role not found.'; + public const SERVICE_NOT_FOUND = 'Service %s not found in the container.'; + public const SETTING_NOT_FOUND = 'Setting "%s" not found.'; + public const TEMPLATE_NOT_FOUND = 'Template "%s" not found.'; + public const UNSUPPORTED_MEDIA_TYPE = 'Unsupported Media Type.'; + public const USER_ACTIVATED = 'User account has been activated.'; + public const USER_ALREADY_ACTIVATED = 'User account is already active.'; + public const USER_ALREADY_DEACTIVATED = 'User account is already inactive.'; + public const USER_AVATAR_MISSING = 'User avatar not found.'; + public const USER_AVATAR_UPDATED = 'User avatar updated successfully.'; + public const USER_CONFIRM_DELETION = 'Please confirm the user deletion.'; + public const USER_CREATED = 'User created successfully.'; + public const USER_DEACTIVATED = 'User account has been deactivated.'; + public const USER_DELETED = 'User account deleted successfully.'; + public const USER_NOT_ACTIVATED = 'User account must be activated first.'; + public const USER_NOT_FOUND = 'User not found.'; + public const USER_UPDATED = 'User updated successfully.'; + public const VALIDATOR_INVALID_CHARACTERS = 'The value specified contains invalid characters.'; + public const VALIDATOR_INVALID_DATA = 'The submitted request contains invalid data.'; + public const VALIDATOR_INVALID_EMAIL = 'The value specified must be a valid email address.'; + public const VALIDATOR_LENGTH_MAX = 'The value specified must have at most %d characters.'; + public const VALIDATOR_LENGTH_MIN = 'The value specified must have at least %d characters.'; + public const VALIDATOR_LENGTH_MIN_MAX = 'The value specified must have between %d and %d characters.'; + public const VALIDATOR_MISMATCH = '"%s" and "%s" do not match.'; + public const VALIDATOR_REQUIRED_FIELD = 'This field is required and cannot be empty.'; + public const VALIDATOR_REQUIRED_UPLOAD = 'A file must be uploaded first.'; + + /** + * @return non-empty-string + */ + public static function invalidConfig(string $config): string + { + return sprintf(self::INVALID_CONFIG, $config); + } + + /** + * @return non-empty-string + */ + public static function invalidValue(string $value): string + { + return sprintf(self::INVALID_VALUE, $value); + } + + /** + * @return non-empty-string + */ + public static function missingConfig(string $config): string + { + return sprintf(self::MISSING_CONFIG, $config); + } + + /** + * @return non-empty-string + */ + public static function mailNotSentTo(string $email): string + { + return sprintf(self::MAIL_NOT_SENT_TO, $email); + } + + /** + * @return non-empty-string + */ + public static function mailSentUserActivation(string $email): string + { + return sprintf(self::MAIL_SENT_USER_ACTIVATION, $email); + } + + /** + * @param string[] $types + * @return non-empty-string + */ + public static function notAcceptable(array $types = []): string + { + if (count($types) === 0) { + return self::NOT_ACCEPTABLE; + } + + return sprintf('%s Supported types: %s', self::NOT_ACCEPTABLE, implode(', ', $types)); + } + + /** + * @return non-empty-string + */ + public static function resourceAlreadyRegistered(string $resource): string + { + return sprintf(self::RESOURCE_ALREADY_REGISTERED, $resource); + } + + /** + * @return non-empty-string + */ + public static function resourceNotFound(string $resource = 'Resource'): string + { + return sprintf(self::RESOURCE_NOT_FOUND, $resource); + } + + /** + * @return non-empty-string + */ + public static function restrictionDeprecation(string $first, string $second): string + { + return sprintf(self::RESTRICTION_DEPRECATION, $first, $second); + } + + /** + * @param string[] $mimeTypes + * @return non-empty-string + */ + public static function restrictionImage(array $mimeTypes): string + { + return sprintf(self::RESTRICTION_IMAGE, implode(',', $mimeTypes)); + } + + /** + * @return non-empty-string + */ + public static function serviceNotFound(string $service): string + { + return sprintf(self::SERVICE_NOT_FOUND, $service); + } + + /** + * @return non-empty-string + */ + public static function settingNotFound(string $identifier): string + { + return sprintf(self::SETTING_NOT_FOUND, $identifier); + } + + /** + * @return non-empty-string + */ + public static function templateNotFound(string $template): string + { + return sprintf(self::TEMPLATE_NOT_FOUND, $template); + } + + /** + * @param string[] $types + * @return non-empty-string + */ + public static function unsupportedMediaType(array $types = []): string + { + if (count($types) === 0) { + return self::UNSUPPORTED_MEDIA_TYPE; + } + + return sprintf('%s Supported types: %s', self::UNSUPPORTED_MEDIA_TYPE, implode(', ', $types)); + } + + /** + * @return non-empty-string + */ + public static function validatorLengthMax(int $max): string + { + return sprintf(self::VALIDATOR_LENGTH_MAX, $max); + } + + /** + * @return non-empty-string + */ + public static function validatorLengthMin(int $min): string + { + return sprintf(self::VALIDATOR_LENGTH_MIN, $min); + } + + /** + * @return non-empty-string + */ + public static function validatorLengthMinMax(int $min, int $max): string + { + return sprintf(self::VALIDATOR_LENGTH_MIN_MAX, $min, $max); + } + + /** + * @return non-empty-string + */ + public static function validatorMismatch(string $first, string $second): string + { + return sprintf(self::VALIDATOR_MISMATCH, $first, $second); + } +} diff --git a/src/Core/src/App/src/Migration/Version20250407142911.php b/src/Core/src/App/src/Migration/Version20250407142911.php new file mode 100644 index 0000000..0aaae5a --- /dev/null +++ b/src/Core/src/App/src/Migration/Version20250407142911.php @@ -0,0 +1,233 @@ +addSql(<<<'SQL' + CREATE TABLE admin (identity VARCHAR(191) NOT NULL, firstName VARCHAR(191) DEFAULT NULL, lastName VARCHAR(191) DEFAULT NULL, password VARCHAR(191) NOT NULL, status ENUM('active', 'inactive') DEFAULT 'active' NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_880E0D766A95E9C4 (identity), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE admin_roles (userUuid BINARY(16) NOT NULL, roleUuid BINARY(16) NOT NULL, INDEX IDX_1614D53DD73087E9 (userUuid), INDEX IDX_1614D53D88446210 (roleUuid), PRIMARY KEY(userUuid, roleUuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE admin_login (identity VARCHAR(191) DEFAULT NULL, adminIp VARCHAR(191) DEFAULT NULL, country VARCHAR(191) DEFAULT NULL, continent VARCHAR(191) DEFAULT NULL, organization VARCHAR(191) DEFAULT NULL, deviceType VARCHAR(191) DEFAULT NULL, deviceBrand VARCHAR(191) DEFAULT NULL, deviceModel VARCHAR(40) DEFAULT NULL, isMobile ENUM('yes', 'no') DEFAULT NULL, osName VARCHAR(191) DEFAULT NULL, osVersion VARCHAR(191) DEFAULT NULL, osPlatform VARCHAR(191) DEFAULT NULL, clientType VARCHAR(191) DEFAULT NULL, clientName VARCHAR(191) DEFAULT NULL, clientEngine VARCHAR(191) DEFAULT NULL, clientVersion VARCHAR(191) DEFAULT NULL, loginStatus ENUM('success', 'fail') DEFAULT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE admin_role (name ENUM('admin', 'superuser') DEFAULT 'admin' NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_7770088A5E237E06 (name), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_access_tokens (id INT UNSIGNED AUTO_INCREMENT NOT NULL, user_id VARCHAR(25) DEFAULT NULL, token VARCHAR(100) NOT NULL, revoked TINYINT(1) DEFAULT 0 NOT NULL, expires_at DATETIME NOT NULL, client_id INT UNSIGNED DEFAULT NULL, INDEX IDX_CA42527C19EB6921 (client_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_access_token_scopes (access_token_id INT UNSIGNED NOT NULL, scope_id INT UNSIGNED NOT NULL, INDEX IDX_9FDF62E92CCB2688 (access_token_id), INDEX IDX_9FDF62E9682B5931 (scope_id), PRIMARY KEY(access_token_id, scope_id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_auth_codes (id INT UNSIGNED AUTO_INCREMENT NOT NULL, revoked TINYINT(1) DEFAULT 0 NOT NULL, expiresDatetime DATETIME DEFAULT NULL, client_id INT UNSIGNED DEFAULT NULL, INDEX IDX_BB493F8319EB6921 (client_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_auth_code_scopes (auth_code_id INT UNSIGNED NOT NULL, scope_id INT UNSIGNED NOT NULL, INDEX IDX_988BFFBF69FEDEE4 (auth_code_id), INDEX IDX_988BFFBF682B5931 (scope_id), PRIMARY KEY(auth_code_id, scope_id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_clients (id INT UNSIGNED AUTO_INCREMENT NOT NULL, name VARCHAR(40) NOT NULL, secret VARCHAR(100) DEFAULT NULL, redirect VARCHAR(191) NOT NULL, revoked TINYINT(1) DEFAULT 0 NOT NULL, isConfidential TINYINT(1) DEFAULT 0 NOT NULL, user_id BINARY(16) DEFAULT NULL, INDEX IDX_13CE8101A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_refresh_tokens (id INT UNSIGNED AUTO_INCREMENT NOT NULL, revoked TINYINT(1) DEFAULT 0 NOT NULL, expires_at DATETIME NOT NULL, access_token_id INT UNSIGNED DEFAULT NULL, INDEX IDX_5AB6872CCB2688 (access_token_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE oauth_scopes (id INT UNSIGNED AUTO_INCREMENT NOT NULL, scope VARCHAR(191) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE settings (identifier ENUM('table_admin_list_selected_columns', 'table_admin_list_logins_selected_columns', 'table_user_list_selected_columns') NOT NULL, value LONGTEXT NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, admin_uuid BINARY(16) DEFAULT NULL, INDEX IDX_E545A0C5F166D246 (admin_uuid), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE user (identity VARCHAR(191) NOT NULL, password VARCHAR(191) NOT NULL, status ENUM('active', 'pending', 'deleted') DEFAULT 'pending' NOT NULL, hash VARCHAR(191) NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_8D93D6496A95E9C4 (identity), UNIQUE INDEX UNIQ_8D93D649D1B862B8 (hash), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE user_roles (userUuid BINARY(16) NOT NULL, roleUuid BINARY(16) NOT NULL, INDEX IDX_54FCD59FD73087E9 (userUuid), INDEX IDX_54FCD59F88446210 (roleUuid), PRIMARY KEY(userUuid, roleUuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE user_avatar (name VARCHAR(191) NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, userUuid BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_73256912D73087E9 (userUuid), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE user_detail (firstName VARCHAR(191) DEFAULT NULL, lastName VARCHAR(191) DEFAULT NULL, email VARCHAR(191) NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, userUuid BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_4B5464AED73087E9 (userUuid), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE user_reset_password (expires DATETIME NOT NULL, hash VARCHAR(191) NOT NULL, status ENUM('completed', 'requested') DEFAULT 'requested' NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, userUuid BINARY(16) DEFAULT NULL, UNIQUE INDEX UNIQ_D21DE3BCD1B862B8 (hash), INDEX IDX_D21DE3BCD73087E9 (userUuid), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE user_role (name ENUM('guest', 'user') DEFAULT 'user' NOT NULL, uuid BINARY(16) NOT NULL, created DATETIME NOT NULL, updated DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_2DE8C6A35E237E06 (name), PRIMARY KEY(uuid)) DEFAULT CHARACTER SET utf8mb4 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE admin_roles ADD CONSTRAINT FK_1614D53DD73087E9 FOREIGN KEY (userUuid) REFERENCES admin (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE admin_roles ADD CONSTRAINT FK_1614D53D88446210 FOREIGN KEY (roleUuid) REFERENCES admin_role (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_tokens ADD CONSTRAINT FK_CA42527C19EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token_scopes ADD CONSTRAINT FK_9FDF62E92CCB2688 FOREIGN KEY (access_token_id) REFERENCES oauth_access_tokens (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token_scopes ADD CONSTRAINT FK_9FDF62E9682B5931 FOREIGN KEY (scope_id) REFERENCES oauth_scopes (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_auth_code_scopes ADD CONSTRAINT FK_988BFFBF69FEDEE4 FOREIGN KEY (auth_code_id) REFERENCES oauth_auth_codes (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_auth_code_scopes ADD CONSTRAINT FK_988BFFBF682B5931 FOREIGN KEY (scope_id) REFERENCES oauth_scopes (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_clients ADD CONSTRAINT FK_13CE8101A76ED395 FOREIGN KEY (user_id) REFERENCES user (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB6872CCB2688 FOREIGN KEY (access_token_id) REFERENCES oauth_access_tokens (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE settings ADD CONSTRAINT FK_E545A0C5F166D246 FOREIGN KEY (admin_uuid) REFERENCES admin (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_roles ADD CONSTRAINT FK_54FCD59FD73087E9 FOREIGN KEY (userUuid) REFERENCES user (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_roles ADD CONSTRAINT FK_54FCD59F88446210 FOREIGN KEY (roleUuid) REFERENCES user_role (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_avatar ADD CONSTRAINT FK_73256912D73087E9 FOREIGN KEY (userUuid) REFERENCES user (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_detail ADD CONSTRAINT FK_4B5464AED73087E9 FOREIGN KEY (userUuid) REFERENCES user (uuid) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_reset_password ADD CONSTRAINT FK_D21DE3BCD73087E9 FOREIGN KEY (userUuid) REFERENCES user (uuid) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE admin_roles DROP FOREIGN KEY FK_1614D53DD73087E9 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE admin_roles DROP FOREIGN KEY FK_1614D53D88446210 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_tokens DROP FOREIGN KEY FK_CA42527C19EB6921 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token_scopes DROP FOREIGN KEY FK_9FDF62E92CCB2688 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_access_token_scopes DROP FOREIGN KEY FK_9FDF62E9682B5931 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_auth_codes DROP FOREIGN KEY FK_BB493F8319EB6921 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_auth_code_scopes DROP FOREIGN KEY FK_988BFFBF69FEDEE4 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_auth_code_scopes DROP FOREIGN KEY FK_988BFFBF682B5931 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_clients DROP FOREIGN KEY FK_13CE8101A76ED395 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE oauth_refresh_tokens DROP FOREIGN KEY FK_5AB6872CCB2688 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE settings DROP FOREIGN KEY FK_E545A0C5F166D246 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_roles DROP FOREIGN KEY FK_54FCD59FD73087E9 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_roles DROP FOREIGN KEY FK_54FCD59F88446210 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_avatar DROP FOREIGN KEY FK_73256912D73087E9 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_detail DROP FOREIGN KEY FK_4B5464AED73087E9 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_reset_password DROP FOREIGN KEY FK_D21DE3BCD73087E9 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE admin + SQL); + $this->addSql(<<<'SQL' + DROP TABLE admin_roles + SQL); + $this->addSql(<<<'SQL' + DROP TABLE admin_login + SQL); + $this->addSql(<<<'SQL' + DROP TABLE admin_role + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_access_tokens + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_access_token_scopes + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_auth_codes + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_auth_code_scopes + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_clients + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_refresh_tokens + SQL); + $this->addSql(<<<'SQL' + DROP TABLE oauth_scopes + SQL); + $this->addSql(<<<'SQL' + DROP TABLE settings + SQL); + $this->addSql(<<<'SQL' + DROP TABLE user + SQL); + $this->addSql(<<<'SQL' + DROP TABLE user_roles + SQL); + $this->addSql(<<<'SQL' + DROP TABLE user_avatar + SQL); + $this->addSql(<<<'SQL' + DROP TABLE user_detail + SQL); + $this->addSql(<<<'SQL' + DROP TABLE user_reset_password + SQL); + $this->addSql(<<<'SQL' + DROP TABLE user_role + SQL); + } +} diff --git a/src/Core/src/App/src/Repository/AbstractRepository.php b/src/Core/src/App/src/Repository/AbstractRepository.php new file mode 100644 index 0000000..4717718 --- /dev/null +++ b/src/Core/src/App/src/Repository/AbstractRepository.php @@ -0,0 +1,32 @@ + + */ +abstract class AbstractRepository extends EntityRepository +{ + public function deleteResource(EntityInterface $resource): void + { + $this->getEntityManager()->remove($resource); + $this->getEntityManager()->flush(); + } + + public function getQueryBuilder(): QueryBuilder + { + return $this->getEntityManager()->createQueryBuilder(); + } + + public function saveResource(EntityInterface $resource): void + { + $this->getEntityManager()->persist($resource); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Core/src/App/src/Resolver/EntityListenerResolver.php b/src/Core/src/App/src/Resolver/EntityListenerResolver.php new file mode 100644 index 0000000..a842ee0 --- /dev/null +++ b/src/Core/src/App/src/Resolver/EntityListenerResolver.php @@ -0,0 +1,27 @@ +container->get($className); + } +} diff --git a/src/Core/src/App/src/Service/AuthenticationServiceInterface.php b/src/Core/src/App/src/Service/AuthenticationServiceInterface.php new file mode 100644 index 0000000..59006d3 --- /dev/null +++ b/src/Core/src/App/src/Service/AuthenticationServiceInterface.php @@ -0,0 +1,20 @@ + $config + */ + #[Inject( + 'dot-mail.service.default', + 'dot-log.default_logger', + 'config', + )] + public function __construct( + protected \Dot\Mail\Service\MailService $mailService, + protected LoggerInterface $logger, + private readonly array $config, + ) { + } + + /** + * @throws MailException + */ + public function sendActivationMail(User $user, string $body): bool + { + if ($user->isActive()) { + return false; + } + + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); + $this->mailService->setSubject('Welcome to ' . $this->config['application']['name']); + $this->mailService->setBody($body); + + try { + return $this->mailService->send()->isValid(); + } catch (MailException | TransportExceptionInterface $exception) { + $this->logger->err($exception->getMessage()); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); + } + } + + /** + * @throws MailException + */ + public function sendResetPasswordRequestedMail(User $user, string $body): bool + { + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); + $this->mailService->setSubject( + 'Reset password instructions for your ' . $this->config['application']['name'] . ' account' + ); + $this->mailService->setBody($body); + + try { + return $this->mailService->send()->isValid(); + } catch (MailException | TransportExceptionInterface $exception) { + $this->logger->err($exception->getMessage()); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); + } + } + + /** + * @throws MailException + */ + public function sendResetPasswordCompletedMail(User $user, string $body): bool + { + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); + $this->mailService->setSubject( + 'You have successfully reset the password for your ' . $this->config['application']['name'] . ' account' + ); + $this->mailService->setBody($body); + + try { + return $this->mailService->send()->isValid(); + } catch (MailException | TransportExceptionInterface $exception) { + $this->logger->err($exception->getMessage()); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); + } + } + + /** + * @throws MailException + */ + public function sendRecoverIdentityMail(User $user, string $body): bool + { + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); + $this->mailService->setSubject( + 'Recover identity for your ' . $this->config['application']['name'] . ' account' + ); + $this->mailService->setBody($body); + + try { + return $this->mailService->send()->isValid(); + } catch (MailException | TransportExceptionInterface $exception) { + $this->logger->err($exception->getMessage()); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); + } + } + + /** + * @throws MailException + */ + public function sendWelcomeMail(User $user, string $body): bool + { + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); + $this->mailService->setSubject('Welcome to ' . $this->config['application']['name']); + $this->mailService->setBody($body); + + try { + return $this->mailService->send()->isValid(); + } catch (MailException | TransportExceptionInterface $exception) { + $this->logger->err($exception->getMessage()); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); + } + } +} diff --git a/src/Core/src/NotificationSystem/src/ConfigProvider.php b/src/Core/src/NotificationSystem/src/ConfigProvider.php new file mode 100644 index 0000000..3b35180 --- /dev/null +++ b/src/Core/src/NotificationSystem/src/ConfigProvider.php @@ -0,0 +1,41 @@ +, + * } + */ +class ConfigProvider +{ + /** + * @return ConfigType + */ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + ]; + } + + /** + * @return DependenciesType + */ + public function getDependencies(): array + { + return [ + 'factories' => [ + NotificationService::class => AttributedServiceFactory::class, + ], + ]; + } +} diff --git a/src/Core/src/NotificationSystem/src/Service/NotificationService.php b/src/Core/src/NotificationSystem/src/Service/NotificationService.php new file mode 100644 index 0000000..c9a5602 --- /dev/null +++ b/src/Core/src/NotificationSystem/src/Service/NotificationService.php @@ -0,0 +1,52 @@ + $config + */ + #[Inject( + 'config.notification.server', + )] + public function __construct( + private readonly array $config + ) { + } + + public function createClient(): Socket + { + $socketRawFactory = new Factory(); + return $socketRawFactory->createClient( + $this->config['protocol'] . '://' . $this->config['host'] . ':' . $this->config['port'] + ); + } + + public function send(string $message): void + { + $this->createClient()->write($message . $this->config['eof']); + } + + /** + * @param array $data + */ + protected function encodeEmailMessage(array $data): string + { + return Encoder::encode($data); + } + + public function sendNewAccountNotification(User $user): void + { + $data['userUuid'] = $user->getUuid()->toString(); + $this->send($this->encodeEmailMessage($data)); + } +} diff --git a/src/Core/src/Security/src/ConfigProvider.php b/src/Core/src/Security/src/ConfigProvider.php new file mode 100644 index 0000000..b2f0897 --- /dev/null +++ b/src/Core/src/Security/src/ConfigProvider.php @@ -0,0 +1,86 @@ +, + * }, + * SecurityEntities: array{ + * class: class-string, + * cache: string, + * paths: array, + * }, + * } + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ +class ConfigProvider +{ + /** + * @return ConfigType + */ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + 'doctrine' => $this->getDoctrineConfig(), + ]; + } + + /** + * @return DependenciesType + */ + private function getDependencies(): array + { + return [ + 'factories' => [ + OAuthAccessTokenRepository::class => AttributedRepositoryFactory::class, + OAuthAuthCodeRepository::class => AttributedRepositoryFactory::class, + OAuthClientRepository::class => AttributedRepositoryFactory::class, + OAuthRefreshTokenRepository::class => AttributedRepositoryFactory::class, + OAuthScopeRepository::class => AttributedRepositoryFactory::class, + ], + ]; + } + + /** + * @return DoctrineConfigType + */ + private function getDoctrineConfig(): array + { + return [ + 'driver' => [ + 'orm_default' => [ + 'drivers' => [ + 'Core\Security\Entity' => 'SecurityEntities', + ], + ], + 'SecurityEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => [__DIR__ . '/Entity'], + ], + ], + ]; + } +} diff --git a/src/Core/src/Security/src/Entity/OAuthAccessToken.php b/src/Core/src/Security/src/Entity/OAuthAccessToken.php new file mode 100644 index 0000000..f70c9c9 --- /dev/null +++ b/src/Core/src/Security/src/Entity/OAuthAccessToken.php @@ -0,0 +1,255 @@ + true])] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private int $id; + + #[ORM\ManyToOne(targetEntity: OAuthClient::class)] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id')] + private ClientEntityInterface $client; + + #[ORM\Column(name: 'user_id', type: 'string', length: 25, nullable: true)] + private ?string $userId; + + /** @var non-empty-string $token */ + #[ORM\Column(name: 'token', type: 'string', length: 100)] + private string $token; + + #[ORM\Column(name: 'revoked', type: 'boolean', options: ['default' => false])] + private bool $isRevoked = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: OAuthScope::class, inversedBy: 'accessTokens', indexBy: 'id')] + #[ORM\JoinTable(name: 'oauth_access_token_scopes')] + #[ORM\JoinColumn(name: 'access_token_id', referencedColumnName: 'id')] + #[ORM\InverseJoinColumn(name: 'scope_id', referencedColumnName: 'id')] + protected Collection $scopes; + + #[ORM\Column(name: 'expires_at', type: 'datetime_immutable')] + private DateTimeImmutable $expiresAt; + + private ?CryptKey $privateKey = null; + + private ?Configuration $jwtConfiguration = null; + + public function __construct() + { + $this->scopes = new ArrayCollection(); + } + + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setClient(ClientEntityInterface $client): self + { + $this->client = $client; + + return $this; + } + + public function getClient(): ClientEntityInterface + { + return $this->client; + } + + /** + * @return non-empty-string + */ + public function getToken(): string + { + return $this->token; + } + + /** + * @param non-empty-string $token + */ + public function setToken(string $token): self + { + $this->token = $token; + + return $this; + } + + public function setIsRevoked(bool $isRevoked): self + { + $this->isRevoked = $isRevoked; + + return $this; + } + + public function getIsRevoked(): bool + { + return $this->isRevoked; + } + + public function revoke(): self + { + $this->isRevoked = true; + + return $this; + } + + /** + * @return non-empty-string + */ + public function getIdentifier(): string + { + return $this->getToken(); + } + + /** + * @param mixed $identifier + */ + public function setIdentifier($identifier): self + { + return $this->setToken($identifier); + } + + /** + * @param string|int|null $identifier + */ + public function setUserIdentifier($identifier): self + { + if (is_int($identifier)) { + $identifier = (string) $identifier; + } + + $this->userId = $identifier; + + return $this; + } + + public function getUserIdentifier(): ?string + { + return $this->userId; + } + + public function addScope(ScopeEntityInterface $scope): self + { + if (! $this->scopes->contains($scope)) { + $this->scopes->add($scope); + } + + return $this; + } + + public function removeScope(OAuthScope $scope): self + { + if ($this->scopes->contains($scope)) { + $this->scopes->removeElement($scope); + } + + return $this; + } + + public function getScopes(?Criteria $criteria = null): array + { + if ($criteria === null) { + return $this->scopes->toArray(); + } + + return $this->scopes->matching($criteria)->toArray(); + } + + public function getExpiryDateTime(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function setExpiryDateTime(DateTimeImmutable $dateTime): self + { + $this->expiresAt = $dateTime; + + return $this; + } + + public function setPrivateKey(CryptKey $privateKey): self + { + $this->privateKey = $privateKey; + + return $this; + } + + public function initJwtConfiguration(): self + { + if (null === $this->privateKey) { + throw new RuntimeException('Unable to init JWT without private key'); + } + + /** @var non-empty-string $keyContents */ + $keyContents = $this->privateKey->getKeyContents(); + $passphrase = (string) $this->privateKey->getPassPhrase(); + + $this->jwtConfiguration = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($keyContents, $passphrase), + InMemory::plainText('/') + ); + + return $this; + } + + private function convertToJWT(): Token + { + $this->initJwtConfiguration(); + + if ($this->jwtConfiguration === null) { + throw new RuntimeException('Unable to convert to JWT without config'); + } + + /** @var non-empty-string $audiences */ + $audiences = $this->getClient()->getIdentifier(); + /** @var non-empty-string $subject */ + $subject = (string) $this->getUserIdentifier(); + return $this->jwtConfiguration->builder() + ->permittedFor($audiences) + ->identifiedBy($this->getIdentifier()) + ->issuedAt(new DateTimeImmutable()) + ->canOnlyBeUsedAfter(new DateTimeImmutable()) + ->expiresAt($this->getExpiryDateTime()) + ->relatedTo($subject) + ->withClaim('scopes', $this->getScopes()) + ->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey()); + } + + public function __toString(): string + { + return $this->convertToJWT()->toString(); + } +} diff --git a/src/Core/src/Security/src/Entity/OAuthAuthCode.php b/src/Core/src/Security/src/Entity/OAuthAuthCode.php new file mode 100644 index 0000000..72369c2 --- /dev/null +++ b/src/Core/src/Security/src/Entity/OAuthAuthCode.php @@ -0,0 +1,178 @@ + true])] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private int $id; + + #[ORM\ManyToOne(targetEntity: OAuthClient::class)] + #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id')] + private ClientEntityInterface $client; + + #[ORM\Column(name: 'revoked', type: 'boolean', options: ['default' => false])] + private bool $isRevoked = false; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: OAuthScope::class, inversedBy: 'authCodes', indexBy: 'id')] + #[ORM\JoinTable(name: 'oauth_auth_code_scopes')] + #[ORM\JoinColumn(name: 'auth_code_id', referencedColumnName: 'id')] + #[ORM\InverseJoinColumn(name: 'scope_id', referencedColumnName: 'id')] + protected Collection $scopes; + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private DateTimeImmutable $expiresDatetime; + + public function __construct() + { + $this->expiresDatetime = new DateTimeImmutable(); + $this->scopes = new ArrayCollection(); + } + + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setClient(ClientEntityInterface $client): self + { + $this->client = $client; + + return $this; + } + + public function getClient(): ClientEntityInterface + { + return $this->client; + } + + public function getIdentifier(): string + { + return (string) $this->getId(); + } + + /** + * @param mixed $identifier + */ + public function setIdentifier($identifier): self + { + $this->setId($identifier); + + return $this; + } + + /** + * @param string|int|null $identifier + */ + public function setUserIdentifier($identifier): self + { + return $this; + } + + public function getUserIdentifier(): ?string + { + $client = $this->getClient(); + assert($client instanceof OAuthClient); + + if (null === $user = $client->getUser()) { + return null; + } + + return $user->getIdentifier(); + } + + public function setIsRevoked(bool $isRevoked): self + { + $this->isRevoked = $isRevoked; + + return $this; + } + + public function getIsRevoked(): bool + { + return $this->isRevoked; + } + + public function revoke(): self + { + $this->isRevoked = true; + + return $this; + } + + public function addScope(ScopeEntityInterface $scope): self + { + if (! $this->scopes->contains($scope)) { + $this->scopes->add($scope); + } + + return $this; + } + + public function removeScope(ScopeEntityInterface $scope): self + { + $this->scopes->removeElement($scope); + + return $this; + } + + public function getScopes(?Criteria $criteria = null): array + { + if ($criteria === null) { + return $this->scopes->toArray(); + } + + return $this->scopes->matching($criteria)->toArray(); + } + + public function setExpiresDatetime(DateTimeImmutable $expiresDatetime): self + { + $this->expiresDatetime = $expiresDatetime; + + return $this; + } + + public function getExpiresDatetime(): DateTimeImmutable + { + return $this->expiresDatetime; + } + + public function getExpiryDateTime(): DateTimeImmutable + { + return $this->getExpiresDatetime(); + } + + public function setExpiryDateTime(DateTimeImmutable $dateTime): self + { + return $this->setExpiresDatetime($dateTime); + } +} diff --git a/src/Core/src/Security/src/Entity/OAuthClient.php b/src/Core/src/Security/src/Entity/OAuthClient.php new file mode 100644 index 0000000..a2b4da3 --- /dev/null +++ b/src/Core/src/Security/src/Entity/OAuthClient.php @@ -0,0 +1,146 @@ + true])] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private int $id; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'uuid', nullable: true)] + private ?User $user = null; + + #[ORM\Column(name: 'name', type: 'string', length: 40)] + private string $name; + + #[ORM\Column(name: 'secret', type: 'string', length: 100, nullable: true)] + private ?string $secret = null; + + #[ORM\Column(name: 'redirect', type: 'string', length: 191)] + private string $redirect = ''; + + #[ORM\Column(name: 'revoked', type: 'boolean', options: ['default' => false])] + private bool $isRevoked = false; + + #[ORM\Column(name: 'isConfidential', type: 'boolean', options: ['default' => false])] + private bool $isConfidential = false; + + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setUser(?User $user = null): self + { + $this->user = $user; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function getIdentity(): string + { + return $this->getName(); + } + + public function getIdentifier(): string + { + return $this->getName(); + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setSecret(?string $secret = null): self + { + $this->secret = $secret; + + return $this; + } + + public function getSecret(): ?string + { + return $this->secret; + } + + public function setRedirect(string $redirect): self + { + $this->redirect = $redirect; + + return $this; + } + + public function getRedirect(): string + { + return $this->redirect; + } + + public function getRedirectUri(): ?string + { + return $this->getRedirect(); + } + + public function setIsRevoked(bool $isRevoked): self + { + $this->isRevoked = $isRevoked; + + return $this; + } + + public function getIsRevoked(): bool + { + return $this->isRevoked; + } + + public function setIsConfidential(bool $isConfidential): self + { + $this->isConfidential = $isConfidential; + + return $this; + } + + public function getIsConfidential(): bool + { + return $this->isConfidential; + } + + public function isConfidential(): bool + { + return $this->getIsConfidential(); + } +} diff --git a/src/Core/src/Security/src/Entity/OAuthRefreshToken.php b/src/Core/src/Security/src/Entity/OAuthRefreshToken.php new file mode 100644 index 0000000..db0ba61 --- /dev/null +++ b/src/Core/src/Security/src/Entity/OAuthRefreshToken.php @@ -0,0 +1,98 @@ + true])] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private int $id; + + #[ORM\ManyToOne(targetEntity: OAuthAccessToken::class)] + #[ORM\JoinColumn(name: 'access_token_id', referencedColumnName: 'id')] + private AccessTokenEntityInterface $accessToken; + + #[ORM\Column(name: 'revoked', type: 'boolean', options: ['default' => false])] + private bool $isRevoked = false; + + #[ORM\Column(name: 'expires_at', type: 'datetime_immutable')] + private DateTimeImmutable $expiresAt; + + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getIdentifier(): string + { + return (string) $this->getId(); + } + + public function setIdentifier(mixed $identifier): self + { + $this->setId((int) $identifier); + + return $this; + } + + public function setAccessToken(AccessTokenEntityInterface $accessToken): self + { + $this->accessToken = $accessToken; + + return $this; + } + + public function getAccessToken(): OAuthAccessToken|AccessTokenEntityInterface + { + return $this->accessToken; + } + + public function setIsRevoked(bool $isRevoked): self + { + $this->isRevoked = $isRevoked; + + return $this; + } + + public function getIsRevoked(): bool + { + return $this->isRevoked; + } + + public function revoke(): self + { + $this->isRevoked = true; + + return $this; + } + + public function getExpiryDateTime(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function setExpiryDateTime(DateTimeImmutable $dateTime): self + { + $this->expiresAt = $dateTime; + + return $this; + } +} diff --git a/src/Core/src/Security/src/Entity/OAuthScope.php b/src/Core/src/Security/src/Entity/OAuthScope.php new file mode 100644 index 0000000..f90e52f --- /dev/null +++ b/src/Core/src/Security/src/Entity/OAuthScope.php @@ -0,0 +1,138 @@ + true])] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private int $id; + + #[ORM\Column(name: 'scope', type: 'string', length: 191)] + private string $scope = ''; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: OAuthAccessToken::class, mappedBy: 'scopes')] + protected Collection $accessTokens; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: OAuthAuthCode::class, mappedBy: 'scopes')] + protected Collection $authCodes; + + public function __construct() + { + $this->accessTokens = new ArrayCollection(); + $this->authCodes = new ArrayCollection(); + } + + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getIdentifier(): string + { + return $this->getScope(); + } + + public function setScope(string $scope): self + { + $this->scope = $scope; + + return $this; + } + + public function getScope(): string + { + return $this->scope; + } + + public function addAccessToken(AccessTokenEntityInterface $accessToken): self + { + if (! $this->accessTokens->contains($accessToken)) { + $this->accessTokens->add($accessToken); + } + + return $this; + } + + public function removeAccessToken(AccessTokenEntityInterface $accessToken): self + { + if ($this->accessTokens->contains($accessToken)) { + $this->accessTokens->removeElement($accessToken); + } + + return $this; + } + + /** + * @return Collection + */ + public function getAccessTokens(?Criteria $criteria = null): Collection + { + if ($criteria === null) { + return $this->accessTokens; + } + + return $this->accessTokens->matching($criteria); + } + + public function addAuthCode(AuthCodeEntityInterface $authCode): self + { + if (! $this->authCodes->contains($authCode)) { + $this->authCodes->add($authCode); + } + + return $this; + } + + public function removeAuthCode(AuthCodeEntityInterface $authCode): self + { + if ($this->authCodes->contains($authCode)) { + $this->authCodes->removeElement($authCode); + } + + return $this; + } + + /** + * @return Collection + */ + public function getAuthCodes(?Criteria $criteria = null): Collection + { + if ($criteria === null) { + return $this->authCodes; + } + + return $this->authCodes->matching($criteria); + } + + public function jsonSerialize(): string + { + return $this->getIdentifier(); + } +} diff --git a/src/Core/src/Security/src/Repository/OAuthAccessTokenRepository.php b/src/Core/src/Security/src/Repository/OAuthAccessTokenRepository.php new file mode 100644 index 0000000..3cd6b17 --- /dev/null +++ b/src/Core/src/Security/src/Repository/OAuthAccessTokenRepository.php @@ -0,0 +1,98 @@ +getQueryBuilder() + ->select(['oauth_access_tokens']) + ->from(OAuthAccessToken::class, 'oauth_access_tokens') + ->andWhere('oauth_access_tokens.userId = :identifier') + ->setParameter('identifier', $identifier) + ->andWhere('oauth_access_tokens.isRevoked = 0') + ->getQuery() + ->getResult(); + } + + /** + * @param mixed $userIdentifier + */ + public function getNewToken( + ClientEntityInterface $clientEntity, + array $scopes, + $userIdentifier = null + ): OAuthAccessToken { + $accessToken = (new OAuthAccessToken())->setClient($clientEntity); + foreach ($scopes as $scope) { + $accessToken->addScope($scope); + } + + if ($userIdentifier === null) { + return $accessToken; + } + + if ($clientEntity->getName() === OAuthClient::NAME_ADMIN) { + $repository = $this->getEntityManager()->getRepository(Admin::class); + } else { + $repository = $this->getEntityManager()->getRepository(User::class); + } + + $user = $repository->findOneBy(['identity' => $userIdentifier]); + if ($user instanceof UserEntityInterface) { + $accessToken->setUserIdentifier($user->getIdentifier()); + } + + return $accessToken; + } + + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void + { + $this->getEntityManager()->persist($accessTokenEntity); + $this->getEntityManager()->flush(); + } + + /** + * @param string $tokenId + */ + public function revokeAccessToken($tokenId): void + { + $accessTokenEntity = $this->findOneBy(['token' => $tokenId]); + if ($accessTokenEntity instanceof OAuthAccessToken) { + $this->getEntityManager()->persist($accessTokenEntity->revoke()); + $this->getEntityManager()->flush(); + } + } + + /** + * @param string $tokenId + */ + public function isAccessTokenRevoked($tokenId): bool + { + $accessTokenEntity = $this->findOneBy(['token' => $tokenId]); + if ($accessTokenEntity instanceof OAuthAccessToken) { + return $accessTokenEntity->getIsRevoked(); + } + + return true; + } +} diff --git a/src/Core/src/Security/src/Repository/OAuthAuthCodeRepository.php b/src/Core/src/Security/src/Repository/OAuthAuthCodeRepository.php new file mode 100644 index 0000000..a606ee0 --- /dev/null +++ b/src/Core/src/Security/src/Repository/OAuthAuthCodeRepository.php @@ -0,0 +1,51 @@ +getEntityManager()->persist($authCodeEntity); + $this->getEntityManager()->flush(); + } + + /** + * @param string $codeId + */ + public function revokeAuthCode($codeId): void + { + $authCodeEntity = $this->find($codeId); + if ($authCodeEntity instanceof OAuthAuthCode) { + $this->getEntityManager()->persist($authCodeEntity->revoke()); + $this->getEntityManager()->flush(); + } + } + + /** + * @param string $codeId + */ + public function isAuthCodeRevoked($codeId): bool + { + $authCodeEntity = $this->find($codeId); + if ($authCodeEntity instanceof OAuthAuthCode) { + return $authCodeEntity->getIsRevoked(); + } + + return true; + } +} diff --git a/src/Core/src/Security/src/Repository/OAuthClientRepository.php b/src/Core/src/Security/src/Repository/OAuthClientRepository.php new file mode 100644 index 0000000..53dd362 --- /dev/null +++ b/src/Core/src/Security/src/Repository/OAuthClientRepository.php @@ -0,0 +1,71 @@ +findOneBy(['name' => $clientIdentifier]); + if ($client instanceof OAuthClient) { + return $client; + } + + return null; + } + + /** + * @param string $clientIdentifier + * @param null|string $clientSecret + * @param null|string $grantType + */ + public function validateClient($clientIdentifier, $clientSecret, $grantType): bool + { + $client = $this->getClientEntity($clientIdentifier); + if (! $client instanceof OAuthClient) { + return false; + } + + if (! $this->isGranted($grantType)) { + return false; + } + + if (empty($client->getSecret())) { + return false; + } + + return password_verify((string) $clientSecret, $client->getSecret()); + } + + private function isGranted(?string $grantType = null): bool + { + return in_array($grantType, self::GRANT_TYPES); + } +} diff --git a/src/Core/src/Security/src/Repository/OAuthRefreshTokenRepository.php b/src/Core/src/Security/src/Repository/OAuthRefreshTokenRepository.php new file mode 100644 index 0000000..07b1414 --- /dev/null +++ b/src/Core/src/Security/src/Repository/OAuthRefreshTokenRepository.php @@ -0,0 +1,51 @@ +getEntityManager()->persist($refreshTokenEntity); + $this->getEntityManager()->flush(); + } + + /** + * @param string $tokenId + */ + public function revokeRefreshToken($tokenId): void + { + $refreshTokenEntity = $this->find($tokenId); + if ($refreshTokenEntity instanceof OAuthRefreshToken) { + $this->getEntityManager()->persist($refreshTokenEntity->revoke()); + $this->getEntityManager()->flush(); + } + } + + /** + * @param string $tokenId + */ + public function isRefreshTokenRevoked($tokenId): bool + { + $refreshTokenEntity = $this->find($tokenId); + if ($refreshTokenEntity instanceof OAuthRefreshToken) { + return $refreshTokenEntity->getIsRevoked(); + } + + return true; + } +} diff --git a/src/Core/src/Security/src/Repository/OAuthScopeRepository.php b/src/Core/src/Security/src/Repository/OAuthScopeRepository.php new file mode 100644 index 0000000..a78c4ae --- /dev/null +++ b/src/Core/src/Security/src/Repository/OAuthScopeRepository.php @@ -0,0 +1,43 @@ +findOneBy(['scope' => $identifier]); + if ($scope instanceof OAuthScope) { + return $scope; + } + + return null; + } + + /** + * @param string $grantType + * @param null|string $userIdentifier + * @return ScopeEntityInterface[] + */ + public function finalizeScopes( + array $scopes, + $grantType, + ClientEntityInterface $clientEntity, + $userIdentifier = null + ): array { + return $scopes; + } +} diff --git a/src/Core/src/Setting/src/ConfigProvider.php b/src/Core/src/Setting/src/ConfigProvider.php new file mode 100644 index 0000000..04282cf --- /dev/null +++ b/src/Core/src/Setting/src/ConfigProvider.php @@ -0,0 +1,83 @@ +, + * }, + * SettingEntities: array{ + * class: class-string, + * cache: non-empty-string, + * paths: non-empty-string[], + * }, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ +class ConfigProvider +{ + /** + * @return ConfigType + */ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + 'doctrine' => $this->getDoctrineConfig(), + ]; + } + + /** + * @return DependenciesType + */ + private function getDependencies(): array + { + return [ + 'factories' => [ + SettingRepository::class => AttributedRepositoryFactory::class, + ], + ]; + } + + /** + * @return DoctrineConfigType + */ + private function getDoctrineConfig(): array + { + return [ + 'driver' => [ + 'orm_default' => [ + 'drivers' => [ + 'Core\Setting\Entity' => 'SettingEntities', + ], + ], + 'SettingEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => [__DIR__ . '/Entity'], + ], + ], + 'types' => [ + SettingIdentifierEnumType::NAME => SettingIdentifierEnumType::class, + ], + ]; + } +} diff --git a/src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php b/src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php new file mode 100644 index 0000000..e5e0f93 --- /dev/null +++ b/src/Core/src/Setting/src/DBAL/Types/SettingIdentifierEnumType.php @@ -0,0 +1,23 @@ +setAdmin($admin); + $this->setIdentifier($identifier); + $this->setValue($value); + } + + public function getAdmin(): ?Admin + { + return $this->admin; + } + + public function setAdmin(Admin $admin): self + { + $this->admin = $admin; + + return $this; + } + + public function getIdentifier(): ?SettingIdentifierEnum + { + return $this->identifier; + } + + public function setIdentifier(SettingIdentifierEnum $identifier): self + { + $this->identifier = $identifier; + + return $this; + } + + public function getValue(): mixed + { + return json_decode((string) $this->value, true); + } + + /** + * @param non-empty-string[] $value + */ + public function setValue(array $value): self + { + $value = json_encode(array_unique($value)); + assert($value !== false); + + $this->value = $value; + + return $this; + } + + /** + * @return array{ + * identifier: non-empty-string, + * value: non-empty-string[], + * } + */ + public function getArrayCopy(): array + { + return [ + 'identifier' => $this->identifier->value, + 'value' => $this->getValue(), + ]; + } +} diff --git a/src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php b/src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php new file mode 100644 index 0000000..4038d93 --- /dev/null +++ b/src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php @@ -0,0 +1,22 @@ +, + * }, + * UserEntities: array{ + * class: class-string, + * cache: non-empty-string, + * paths: non-empty-string[], + * }, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ +class ConfigProvider +{ + /** + * @return ConfigType + */ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + 'doctrine' => $this->getDoctrineConfig(), + ]; + } + + /** + * @return DependenciesType + */ + private function getDependencies(): array + { + return [ + 'factories' => [ + UserAvatarEventListener::class => AttributedServiceFactory::class, + UserAvatarRepository::class => AttributedRepositoryFactory::class, + UserDetailRepository::class => AttributedRepositoryFactory::class, + UserRepository::class => AttributedRepositoryFactory::class, + UserResetPasswordRepository::class => AttributedRepositoryFactory::class, + UserRoleRepository::class => AttributedRepositoryFactory::class, + ], + ]; + } + + /** + * @return DoctrineConfigType + */ + private function getDoctrineConfig(): array + { + return [ + 'driver' => [ + 'orm_default' => [ + 'drivers' => [ + 'Core\User\Entity' => 'UserEntities', + ], + ], + 'UserEntities' => [ + 'class' => AttributeDriver::class, + 'cache' => 'array', + 'paths' => [__DIR__ . '/Entity'], + ], + ], + 'types' => [ + UserRoleEnumType::NAME => UserRoleEnumType::class, + UserStatusEnumType::NAME => UserStatusEnumType::class, + UserResetPasswordStatusEnumType::NAME => UserResetPasswordStatusEnumType::class, + ], + ]; + } +} diff --git a/src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php b/src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php new file mode 100644 index 0000000..2ffe59e --- /dev/null +++ b/src/Core/src/User/src/DBAL/Types/UserResetPasswordStatusEnumType.php @@ -0,0 +1,23 @@ + */ + #[ORM\OneToMany(targetEntity: UserResetPassword::class, mappedBy: 'user', cascade: ['persist', 'remove'])] + protected Collection $resetPasswords; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: UserRole::class)] + #[ORM\JoinTable(name: 'user_roles')] + #[ORM\JoinColumn(name: 'userUuid', referencedColumnName: 'uuid')] + #[ORM\InverseJoinColumn(name: 'roleUuid', referencedColumnName: 'uuid')] + protected Collection $roles; + + /** @var non-empty-string|null $identity */ + #[ORM\Column(name: 'identity', type: 'string', length: 191, unique: true)] + protected ?string $identity = null; + + #[ORM\Column(name: 'password', type: 'string', length: 191)] + protected ?string $password = null; + + #[ORM\Column( + type: 'user_status_enum', + enumType: UserStatusEnum::class, + options: ['default' => UserStatusEnum::Pending] + )] + protected UserStatusEnum $status = UserStatusEnum::Pending; + + /** @var non-empty-string $hash */ + #[ORM\Column(name: 'hash', type: 'string', length: 191, unique: true)] + protected string $hash; + + public function __construct() + { + parent::__construct(); + + $this->hash = self::generateHash(); + $this->roles = new ArrayCollection(); + $this->resetPasswords = new ArrayCollection(); + + $this->created(); + $this->renewHash(); + } + + public function getAvatar(): ?UserAvatar + { + return $this->avatar; + } + + public function hasAvatar(): bool + { + return $this->avatar instanceof UserAvatar; + } + + public function removeAvatar(): self + { + $this->avatar = null; + + return $this; + } + + public function setAvatar(?UserAvatar $avatar): self + { + $this->avatar = $avatar; + + return $this; + } + + public function getDetail(): ?UserDetail + { + return $this->detail; + } + + public function hasDetail(): bool + { + return $this->detail !== null; + } + + public function setDetail(UserDetail $detail): self + { + $this->detail = $detail; + + return $this; + } + + public function addResetPassword(UserResetPassword $resetPassword): void + { + $this->resetPasswords->add($resetPassword); + } + + /** + * @return Collection + */ + public function getResetPasswords(): Collection + { + return $this->resetPasswords; + } + + public function hasResetPassword(UserResetPassword $resetPassword): bool + { + return $this->resetPasswords->contains($resetPassword); + } + + public function removeResetPassword(UserResetPassword $resetPassword): self + { + $this->resetPasswords->removeElement($resetPassword); + + return $this; + } + + /** + * @param array $resetPasswords + */ + public function setResetPasswords(array $resetPasswords): self + { + foreach ($resetPasswords as $resetPassword) { + $this->resetPasswords->add($resetPassword); + } + + return $this; + } + + public function addRole(RoleInterface $role): self + { + $this->roles->add($role); + + return $this; + } + + /** + * @return RoleInterface[] + */ + public function getRoles(): array + { + return $this->roles->toArray(); + } + + public function hasRole(RoleInterface $role): bool + { + return $this->roles->contains($role); + } + + public function removeRole(RoleInterface $role): self + { + $this->roles->removeElement($role); + + return $this; + } + + /** + * @param RoleInterface[] $roles + */ + public function setRoles(array $roles): self + { + foreach ($roles as $role) { + $this->roles->add($role); + } + + return $this; + } + + public function getIdentity(): ?string + { + return $this->identity; + } + + public function hasIdentity(): bool + { + return $this->identity !== null; + } + + /** + * @param non-empty-string $identity + */ + public function setIdentity(string $identity): self + { + $this->identity = $identity; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + public function getStatus(): UserStatusEnum + { + return $this->status; + } + + public function setStatus(UserStatusEnum $status): self + { + $this->status = $status; + + return $this; + } + + public function getHash(): ?string + { + return $this->hash; + } + + /** + * @param non-empty-string $hash + */ + public function setHash(string $hash): self + { + $this->hash = $hash; + + return $this; + } + + public function getIdentifier(): string + { + return (string) $this->identity; + } + + public function activate(): self + { + return $this->setStatus(UserStatusEnum::Active); + } + + public function deactivate(): self + { + return $this->setStatus(UserStatusEnum::Pending); + } + + /** + * @return non-empty-string + */ + public static function generateHash(): string + { + return bin2hex(md5(uniqid())); + } + + public function getEmail(): string + { + if (! $this->getDetail() instanceof UserDetail) { + return ''; + } + + return trim((string) $this->getDetail()->getEmail()); + } + + public function hasEmail(): bool + { + return $this->getEmail() !== ''; + } + + public function getName(): string + { + if (! $this->getDetail() instanceof UserDetail) { + return ''; + } + + return trim($this->getDetail()->getFirstName() . ' ' . $this->getDetail()->getLastName()); + } + + public function isActive(): bool + { + return $this->status === UserStatusEnum::Active; + } + + public function isPending(): bool + { + return $this->status === UserStatusEnum::Pending; + } + + public function isDeleted(): bool + { + return $this->status === UserStatusEnum::Deleted; + } + + public function renewHash(): self + { + $this->hash = self::generateHash(); + + return $this; + } + + public function resetRoles(): self + { + $this->roles = new ArrayCollection(); + + return $this; + } + + public function hasRoles(): bool + { + return $this->roles->count() > 0; + } + + /** + * @return array{ + * uuid: non-empty-string, + * avatar: UserAvatarType|null, + * detail: UserDetailType|null, + * hash: non-empty-string, + * identity: non-empty-string|null, + * status: non-empty-string, + * roles: RoleType[], + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null, + * } + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'avatar' => $this->avatar?->getArrayCopy(), + 'detail' => $this->detail?->getArrayCopy(), + 'hash' => $this->hash, + 'identity' => $this->identity, + 'status' => $this->status->value, + 'roles' => array_map(fn (RoleInterface $role): array => $role->getArrayCopy(), $this->roles->toArray()), + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/User/src/Entity/UserAvatar.php b/src/Core/src/User/src/Entity/UserAvatar.php new file mode 100644 index 0000000..7edf86b --- /dev/null +++ b/src/Core/src/User/src/Entity/UserAvatar.php @@ -0,0 +1,98 @@ +created(); + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): self + { + $this->user = $user; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getUrl(): ?string + { + return $this->url; + } + + /** + * @param non-empty-string $url + */ + public function setUrl(string $url): self + { + $this->url = $url; + + return $this; + } + + /** + * @return UserAvatarType + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'url' => $this->url, + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/User/src/Entity/UserDetail.php b/src/Core/src/User/src/Entity/UserDetail.php new file mode 100644 index 0000000..16e0d9d --- /dev/null +++ b/src/Core/src/User/src/Entity/UserDetail.php @@ -0,0 +1,129 @@ +created(); + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): self + { + $this->user = $user; + + return $this; + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + /** + * @param non-empty-string|null $firstName + */ + public function setFirstName(?string $firstName): self + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + /** + * @param non-empty-string|null $lastName + */ + public function setLastName(?string $lastName): self + { + $this->lastName = $lastName; + + return $this; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function hasEmail(): bool + { + return $this->email !== null && $this->email !== ''; + } + + /** + * @param non-empty-string $email + */ + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * @return UserDetailType + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'firstName' => $this->firstName, + 'lastName' => $this->lastName, + 'email' => $this->email, + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/User/src/Entity/UserResetPassword.php b/src/Core/src/User/src/Entity/UserResetPassword.php new file mode 100644 index 0000000..41ab84a --- /dev/null +++ b/src/Core/src/User/src/Entity/UserResetPassword.php @@ -0,0 +1,146 @@ + UserResetPasswordStatusEnum::Requested], + )] + protected UserResetPasswordStatusEnum $status = UserResetPasswordStatusEnum::Requested; + + public function __construct() + { + parent::__construct(); + + $this->created(); + $this->expires = DateTimeImmutable::createFromMutable( + (new DateTime())->add(new DateInterval('P1D')) + ); + $this->hash = User::generateHash(); + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): self + { + $this->user = $user; + + return $this; + } + + public function getExpires(): DateTimeImmutable + { + return $this->expires; + } + + public function setExpires(DateTimeImmutable $expires): self + { + $this->expires = $expires; + + return $this; + } + + public function getHash(): string + { + return $this->hash; + } + + /** + * @param non-empty-string $hash + */ + public function setHash(string $hash): self + { + $this->hash = $hash; + + return $this; + } + + public function getStatus(): UserResetPasswordStatusEnum + { + return $this->status; + } + + public function setStatus(UserResetPasswordStatusEnum $status): self + { + $this->status = $status; + + return $this; + } + + public function isCompleted(): bool + { + return $this->getStatus() === UserResetPasswordStatusEnum::Completed; + } + + public function isValid(): bool + { + try { + return $this->getExpires() > new DateTimeImmutable(); + } catch (Throwable) { + return false; + } + } + + public function markAsCompleted(): self + { + $this->status = UserResetPasswordStatusEnum::Completed; + + return $this; + } + + /** + * @return array{ + * uuid: non-empty-string, + * expires: DateTimeImmutable, + * hash: non-empty-string, + * status: 'completed'|'requested', + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null + * } + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'expires' => $this->expires, + 'hash' => $this->hash, + 'status' => $this->status->value, + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/User/src/Entity/UserRole.php b/src/Core/src/User/src/Entity/UserRole.php new file mode 100644 index 0000000..23e1888 --- /dev/null +++ b/src/Core/src/User/src/Entity/UserRole.php @@ -0,0 +1,68 @@ + UserRoleEnum::User] + )] + protected UserRoleEnum $name = UserRoleEnum::User; + + public function __construct() + { + parent::__construct(); + + $this->created(); + } + + public function getName(): UserRoleEnum + { + return $this->name; + } + + /** + * @param UserRoleEnum $name + */ + public function setName(BackedEnum $name): RoleInterface + { + $this->name = $name; + + return $this; + } + + /** + * @return RoleType + */ + public function getArrayCopy(): array + { + return [ + 'uuid' => $this->uuid->toString(), + 'name' => $this->name->value, + 'created' => $this->created, + 'updated' => $this->updated, + ]; + } +} diff --git a/src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php b/src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php new file mode 100644 index 0000000..4ed994a --- /dev/null +++ b/src/Core/src/User/src/Enum/UserResetPasswordStatusEnum.php @@ -0,0 +1,11 @@ + + */ + public static function validCases(): array + { + return array_filter(self::cases(), fn (self $value) => $value !== self::Guest); + } +} diff --git a/src/Core/src/User/src/Enum/UserStatusEnum.php b/src/Core/src/User/src/Enum/UserStatusEnum.php new file mode 100644 index 0000000..7bac8ea --- /dev/null +++ b/src/Core/src/User/src/Enum/UserStatusEnum.php @@ -0,0 +1,44 @@ + $enum !== self::Deleted); + } + + /** + * @return array + */ + public static function toArray(): array + { + return array_reduce(self::validCases(), function (array $collector, self $enum): array { + $collector[$enum->value] = $enum->name; + + return $collector; + }, []); + } +} diff --git a/src/Core/src/User/src/EventListener/UserAvatarEventListener.php b/src/Core/src/User/src/EventListener/UserAvatarEventListener.php new file mode 100644 index 0000000..0c7b06e --- /dev/null +++ b/src/Core/src/User/src/EventListener/UserAvatarEventListener.php @@ -0,0 +1,56 @@ + $config + */ + #[Inject( + 'config', + )] + public function __construct( + protected array $config = [], + ) { + } + + public function postLoad(UserAvatar $avatar): void + { + $this->setAvatarUrl($avatar); + } + + public function postPersist(UserAvatar $avatar): void + { + $this->setAvatarUrl($avatar); + } + + public function postUpdate(UserAvatar $avatar): void + { + $this->setAvatarUrl($avatar); + } + + private function setAvatarUrl(UserAvatar $avatar): void + { + assert($avatar->getUser() instanceof User); + + $avatar->setUrl( + sprintf( + '%s/%s/%s', + rtrim($this->config['uploads']['user']['url'], '/'), + $avatar->getUser()->getUuid()->toString(), + $avatar->getName() + ) + ); + } +} diff --git a/src/Core/src/User/src/Repository/UserAvatarRepository.php b/src/Core/src/User/src/Repository/UserAvatarRepository.php new file mode 100644 index 0000000..5cb3ab9 --- /dev/null +++ b/src/Core/src/User/src/Repository/UserAvatarRepository.php @@ -0,0 +1,14 @@ + $params + * @param array $filters + */ + public function getUsers(array $params = [], array $filters = []): QueryBuilder + { + $queryBuilder = $this + ->getQueryBuilder() + ->select(['user']) + ->from(User::class, 'user') + ->leftJoin('user.detail', 'detail') + ->leftJoin('user.roles', 'role') + ->andWhere('user.status != :statusNotDeleted') + ->setParameter('statusNotDeleted', UserStatusEnum::Deleted); + + if ( + array_key_exists('identity', $filters) + && is_string($filters['identity']) + && strlen($filters['identity']) > 0 + ) { + $queryBuilder + ->andWhere($queryBuilder->expr()->like('user.identity', ':identity')) + ->setParameter('identity', '%' . $filters['identity'] . '%'); + } + if ( + array_key_exists('email', $filters) + && is_string($filters['email']) + && strlen($filters['email']) > 0 + ) { + $queryBuilder + ->andWhere($queryBuilder->expr()->like('detail.email', ':email')) + ->setParameter('email', '%' . $filters['email'] . '%'); + } + if ( + array_key_exists('status', $filters) + && is_string($filters['status']) + && strlen($filters['status']) > 0 + ) { + $queryBuilder + ->andWhere('user.status = :status') + ->setParameter('status', $filters['status']); + } + if ( + array_key_exists('role', $filters) + && is_string($filters['role']) + && strlen($filters['role']) > 0 + ) { + $queryBuilder + ->andWhere('role.name = :role') + ->setParameter('role', $filters['role']); + } + + $queryBuilder + ->orderBy($params['sort'], $params['dir']) + ->setFirstResult($params['offset']) + ->setMaxResults($params['limit']) + ->groupBy('user.uuid'); + $queryBuilder->getQuery()->useQueryCache(true); + + return $queryBuilder; + } + + /** + * @param string $username + * @param string $password + * @param string $grantType + * @throws OAuthServerException + */ + public function getUserEntityByUserCredentials( + $username, + $password, + $grantType, + ClientEntityInterface $clientEntity + ): ?UserEntity { + $qb = $this->getEntityManager()->createQueryBuilder(); + switch ($clientEntity->getName()) { + case OAuthClient::NAME_ADMIN: + $qb->select('a.password') + ->from(Admin::class, 'a') + ->andWhere('a.identity = :identity') + ->setParameter('identity', $username); + break; + case OAuthClient::NAME_FRONTEND: + $qb->select(['u.password', 'u.status']) + ->from(User::class, 'u') + ->andWhere('u.identity = :identity') + ->andWhere('u.status != :status') + ->setParameter('identity', $username) + ->setParameter('status', UserStatusEnum::Deleted); + break; + default: + throw new OAuthServerException(Message::INVALID_CLIENT_ID, 6, 'invalid_client', 401); + } + + $result = $qb->getQuery()->getArrayResult(); + if (empty($result) || empty($result[0])) { + return null; + } + + $result = $result[0]; + + if (! password_verify($password, $result['password'])) { + return null; + } + + if ($clientEntity->getName() === 'frontend' && $result['status'] !== UserStatusEnum::Active) { + throw new OAuthServerException(Message::USER_NOT_ACTIVATED, 6, 'inactive_user', 401); + } + + return new UserEntity($username); + } +} diff --git a/src/Core/src/User/src/Repository/UserResetPasswordRepository.php b/src/Core/src/User/src/Repository/UserResetPasswordRepository.php new file mode 100644 index 0000000..5c0422d --- /dev/null +++ b/src/Core/src/User/src/Repository/UserResetPasswordRepository.php @@ -0,0 +1,14 @@ + $params + * @param array $filters + */ + public function getUserRoles(array $params = [], array $filters = []): QueryBuilder + { + $queryBuilder = $this + ->getQueryBuilder() + ->select(['role']) + ->from(UserRole::class, 'role'); + + if ( + array_key_exists('name', $filters) + && is_string($filters['name']) + && strlen($filters['name']) > 0 + ) { + $queryBuilder + ->andWhere('role.name = :name') + ->setParameter('name', $filters['name']); + } + + $queryBuilder + ->orderBy($params['sort'], $params['dir']) + ->setFirstResult($params['offset']) + ->setMaxResults($params['limit']); + $queryBuilder->getQuery()->useQueryCache(true); + + return $queryBuilder; + } +} diff --git a/src/Core/src/User/src/UserIdentity.php b/src/Core/src/User/src/UserIdentity.php new file mode 100644 index 0000000..be69b69 --- /dev/null +++ b/src/Core/src/User/src/UserIdentity.php @@ -0,0 +1,60 @@ + $roles */ + protected array $roles; + /** @var array $details */ + protected array $details; + + /** + * @param non-empty-string $identity + * @param array $roles + * @param array $details + */ + public function __construct(string $identity, array $roles = [], array $details = []) + { + $this->identity = $identity; + $this->roles = $roles; + $this->details = $details; + } + + public function getIdentity(): string + { + return $this->identity; + } + + public function getRoles(): iterable + { + return $this->roles; + } + + /** + * @param null|mixed $default + */ + public function getDetail(string $name, $default = null): mixed + { + return $this->details[$name] ?? $default; + } + + public function getDetails(): array + { + return $this->details; + } + + /** + * @param array $roles + */ + public function setRoles(array $roles): void + { + $this->roles = $roles; + } +} diff --git a/src/Swoole/Command/StartCommand.php b/src/Swoole/Command/StartCommand.php index de74e40..62ac0b2 100644 --- a/src/Swoole/Command/StartCommand.php +++ b/src/Swoole/Command/StartCommand.php @@ -35,11 +35,6 @@ class StartCommand extends Command do not provide the option, 4 workers will be started. EOH; - private const PROGRAMMATIC_CONFIG_FILES = [ - 'config/pipeline.php', - 'config/routes.php', - ]; - /** @var ContainerInterface */ private $container; diff --git a/src/Swoole/ServerFactory.php b/src/Swoole/ServerFactory.php index d5a1124..6467207 100644 --- a/src/Swoole/ServerFactory.php +++ b/src/Swoole/ServerFactory.php @@ -106,7 +106,7 @@ public function __invoke(ContainerInterface $container): SwooleServer $enableCoroutine = $swooleConfig['enable_coroutine'] ?? false; if ($enableCoroutine) { - SwooleRuntime::enableCoroutine(true); + SwooleRuntime::enableCoroutine(); } $httpServer = new SwooleServer($host, $port, $mode, $protocol); diff --git a/templates/welcome.html.twig b/templates/welcome.html.twig new file mode 100644 index 0000000..73a89dd --- /dev/null +++ b/templates/welcome.html.twig @@ -0,0 +1,3 @@ +Hi {{ user.name }},

+ +You can access your {{ config.application.name }} account here. From fe0e9d37e5280e550baed24de6eba7faa4298ea4 Mon Sep 17 00:00:00 2001 From: sergiu Date: Tue, 1 Jul 2025 19:25:42 +0300 Subject: [PATCH 2/4] new line Signed-off-by: sergiu --- config/autoload/log.local.php.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/autoload/log.local.php.dist b/config/autoload/log.local.php.dist index b17d529..1422cc8 100644 --- a/config/autoload/log.local.php.dist +++ b/config/autoload/log.local.php.dist @@ -22,4 +22,4 @@ return [ ] ], ], -]; \ No newline at end of file +]; From 97777f0bbad6072961842573edb79126fd3caaba Mon Sep 17 00:00:00 2001 From: sergiu Date: Thu, 3 Jul 2025 11:55:56 +0300 Subject: [PATCH 3/4] small fixes, removed dot-errorhandler and added dot-log Signed-off-by: sergiu --- bin/composer-post-install-script.php | 59 ++++++++++++++ composer.json | 8 +- config/autoload/.gitignore | 2 +- config/autoload/local.php.dist | 8 +- config/autoload/log.local.php.dist | 1 + config/autoload/mail.global.php.dist | 80 ------------------- config/config.php | 2 - config/pipeline.php | 2 - src/App/ConfigProvider.php | 2 +- .../App/templates}/welcome.html.twig | 0 src/Core/src/App/src/ConfigProvider.php | 3 - .../NotificationSystem/src/ConfigProvider.php | 41 ---------- .../src/Service/NotificationService.php | 52 ------------ 13 files changed, 71 insertions(+), 189 deletions(-) create mode 100644 bin/composer-post-install-script.php delete mode 100644 config/autoload/mail.global.php.dist rename {templates => src/App/templates}/welcome.html.twig (100%) delete mode 100644 src/Core/src/NotificationSystem/src/ConfigProvider.php delete mode 100644 src/Core/src/NotificationSystem/src/Service/NotificationService.php diff --git a/bin/composer-post-install-script.php b/bin/composer-post-install-script.php new file mode 100644 index 0000000..39dba88 --- /dev/null +++ b/bin/composer-post-install-script.php @@ -0,0 +1,59 @@ +} $file + */ +function copyFile(array $file): void +{ + if (! in_array(getEnvironment(), $file['environment'])) { + echo "Skipping the copy of {$file['source']} due to environment settings." . PHP_EOL; + return; + } + + if (is_readable($file['destination'])) { + echo "File {$file['destination']} already exists. Skipping..." . PHP_EOL; + return; + } + + if (! copy($file['source'], $file['destination'])) { + echo "Cannot copy {$file['source']} file to {$file['destination']}" . PHP_EOL; + } else { + echo "File {$file['source']} copied successfully to {$file['destination']}." . PHP_EOL; + } +} + +function getEnvironment(): string +{ + return getenv('COMPOSER_DEV_MODE') === '1' ? ENVIRONMENT_DEVELOPMENT : ENVIRONMENT_PRODUCTION; +} + +/** + * When adding files to the below array: + * - `source` and `destination` paths must be relative to the project root folder + * - `environment` key will indicate on what environments the file will be copied + */ +$files = [ + [ + 'source' => 'config/autoload/local.php.dist', + 'destination' => 'config/autoload/local.php', + 'environment' => [ENVIRONMENT_DEVELOPMENT, ENVIRONMENT_PRODUCTION], + ], + [ + 'source' => 'vendor/dotkernel/dot-mail/config/mail.global.php.dist', + 'destination' => 'config/autoload/mail.global.php', + 'environment' => [ENVIRONMENT_DEVELOPMENT, ENVIRONMENT_PRODUCTION], + ], +]; + +echo "Using environment setting: " . getEnvironment() . PHP_EOL; + +array_walk($files, 'copyFile'); diff --git a/composer.json b/composer.json index d72ce58..cde328c 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "dotkernel/dot-cli": "^3.9", "dotkernel/dot-data-fixtures": "^1.4.0", "dotkernel/dot-dependency-injection": "^1.2", - "dotkernel/dot-errorhandler": "^4.0.0", + "dotkernel/dot-log": "4.0.4", "dotkernel/dot-mail": "^5.3.0", "dotkernel/dot-twigrenderer": "3.6.0", "laminas/laminas-component-installer": "^3.5", @@ -79,8 +79,7 @@ "Core\\App\\": "src/Core/src/App/src", "Core\\Security\\": "src/Core/src/Security/src", "Core\\Setting\\": "src/Core/src/Setting/src", - "Core\\User\\": "src/Core/src/User/src", - "Core\\NotificationSystem\\": "src/Core/src/NotificationSystem/src" + "Core\\User\\": "src/Core/src/User/src" } }, "autoload-dev": { @@ -89,6 +88,9 @@ } }, "scripts": { + "post-update-cmd": [ + "php bin/composer-post-install-script.php" + ], "check": [ "@cs-check", "@test", diff --git a/config/autoload/.gitignore b/config/autoload/.gitignore index 54b84da..a8918e0 100644 --- a/config/autoload/.gitignore +++ b/config/autoload/.gitignore @@ -1,3 +1,3 @@ local.php *.local.php -mail.global.php + diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index e9123a0..3d1d2fc 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -13,11 +13,11 @@ $baseUrl = 'http://localhost:8080'; $databases = [ 'default' => [ - 'host' => '', - 'dbname' => '', + 'host' => 'localhost', + 'dbname' => 'dotkernel', 'user' => '', 'password' => '', - 'port' => , + 'port' => 3306, 'driver' => 'pdo_mysql', 'charset' => 'utf8mb4', 'collate' => 'utf8mb4_general_ci', @@ -46,4 +46,4 @@ return [ 'eof' => "\n", ], ], -]; \ No newline at end of file +]; diff --git a/config/autoload/log.local.php.dist b/config/autoload/log.local.php.dist index 1422cc8..74f325d 100644 --- a/config/autoload/log.local.php.dist +++ b/config/autoload/log.local.php.dist @@ -23,3 +23,4 @@ return [ ], ], ]; + diff --git a/config/autoload/mail.global.php.dist b/config/autoload/mail.global.php.dist deleted file mode 100644 index a0e2d90..0000000 --- a/config/autoload/mail.global.php.dist +++ /dev/null @@ -1,80 +0,0 @@ - [ - //the key is the mail service name, this is the default one, which does not extend any configuration - 'default' => [ - //message configuration - 'message_options' => [ - //from email address of the email - 'from' => '', - //from name to be displayed instead of from address - 'from_name' => '', - //reply-to email address of the email - 'reply_to' => '', - //replyTo name to be displayed instead of the address - 'reply_to_name' => '', - //destination email address as string or a list of email addresses - 'to' => [], - //copy destination addresses - 'cc' => [], - //hidden copy destination addresses - 'bcc' => [], - //email subject - 'subject' => '', - //body options - content can be plain text, HTML - 'body' => [ - 'content' => '', - 'charset' => 'utf-8', - ], - //attachments config - 'attachments' => [ - 'files' => [], - 'dir' => [ - 'iterate' => false, - 'path' => 'data/mail/attachments', - 'recursive' => false, - ], - ], - ], - /** - * the mail transport to use can be any class implementing - * Symfony\Component\Mailer\Transport\TransportInterface - * - * for standard mail transports, you can use these aliases: - * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport - * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport - * - * defaults to sendmail - **/ - 'transport' => 'esmtp', - //options that will be used only if esmtp adapter is used - 'smtp_options' => [ - //hostname or IP address of the mail server - 'host' => '', - //port of the mail server - 587 or 465 for secure connections - 'port' => 587, - 'connection_config' => [ - //the smtp authentication identity - 'username' => '', - //the smtp authentication credential - 'password' => '', - //to disable auto_tls set tls key to false - //it's not recommended to disable TLS while connecting to an SMTP server - 'tls' => null, - ], - ], - ], - // option to log the SENT emails - 'log' => [ - 'sent' => getcwd() . '/log/mail/sent.log', - ], - ], -]; diff --git a/config/config.php b/config/config.php index e0d8feb..90cf75b 100644 --- a/config/config.php +++ b/config/config.php @@ -26,7 +26,6 @@ // Dotkernel packages Dot\Log\ConfigProvider::class, Dot\Cli\ConfigProvider::class, - Dot\ErrorHandler\ConfigProvider::class, Dot\DataFixtures\ConfigProvider::class, Dot\DependencyInjection\ConfigProvider::class, Dot\Mail\ConfigProvider::class, @@ -39,7 +38,6 @@ Core\Security\ConfigProvider::class, Core\Setting\ConfigProvider::class, Core\User\ConfigProvider::class, - Core\NotificationSystem\ConfigProvider::class, // Load application config in a pre-defined order in such a way that local settings // overwrite global settings. (Loaded as first to last): diff --git a/config/pipeline.php b/config/pipeline.php index 2ea0a65..746a581 100644 --- a/config/pipeline.php +++ b/config/pipeline.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Dot\ErrorHandler\ErrorHandlerInterface; use Mezzio\Application; use Mezzio\MiddlewareFactory; use Psr\Container\ContainerInterface; @@ -14,7 +13,6 @@ return function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void { // The error handler should be the first (most outer) middleware to catch // all Exceptions. - $app->pipe(ErrorHandlerInterface::class); // Pipe more middleware here that you want to execute on every request: // - bootstrapping diff --git a/src/App/ConfigProvider.php b/src/App/ConfigProvider.php index 64a05f7..9d6963d 100644 --- a/src/App/ConfigProvider.php +++ b/src/App/ConfigProvider.php @@ -49,7 +49,7 @@ public function getTemplates(): array { return [ 'paths' => [ - 'notification-email' => [__DIR__ . '/../../templates'], + 'notification-email' => [__DIR__ . '/templates'], ], ]; } diff --git a/templates/welcome.html.twig b/src/App/templates/welcome.html.twig similarity index 100% rename from templates/welcome.html.twig rename to src/App/templates/welcome.html.twig diff --git a/src/Core/src/App/src/ConfigProvider.php b/src/Core/src/App/src/ConfigProvider.php index 7b1e445..60d1549 100644 --- a/src/Core/src/App/src/ConfigProvider.php +++ b/src/Core/src/App/src/ConfigProvider.php @@ -18,8 +18,6 @@ use Dot\Cache\Adapter\ArrayAdapter; use Dot\Cache\Adapter\FilesystemAdapter; use Dot\DependencyInjection\Factory\AttributedServiceFactory; -use Dot\ErrorHandler\ErrorHandlerInterface; -use Dot\ErrorHandler\LogErrorHandler; use Dot\Mail\Factory\MailOptionsAbstractFactory; use Dot\Mail\Factory\MailServiceAbstractFactory; use Dot\Mail\Service\MailService as DotMailService; @@ -129,7 +127,6 @@ private function getDependencies(): array DotMailService::class => 'dot-mail.service.default', EntityManager::class => 'doctrine.entity_manager.orm_default', EntityManagerInterface::class => 'doctrine.entity_manager.orm_default', - ErrorHandlerInterface::class => LogErrorHandler::class, ], ]; } diff --git a/src/Core/src/NotificationSystem/src/ConfigProvider.php b/src/Core/src/NotificationSystem/src/ConfigProvider.php deleted file mode 100644 index 3b35180..0000000 --- a/src/Core/src/NotificationSystem/src/ConfigProvider.php +++ /dev/null @@ -1,41 +0,0 @@ -, - * } - */ -class ConfigProvider -{ - /** - * @return ConfigType - */ - public function __invoke(): array - { - return [ - 'dependencies' => $this->getDependencies(), - ]; - } - - /** - * @return DependenciesType - */ - public function getDependencies(): array - { - return [ - 'factories' => [ - NotificationService::class => AttributedServiceFactory::class, - ], - ]; - } -} diff --git a/src/Core/src/NotificationSystem/src/Service/NotificationService.php b/src/Core/src/NotificationSystem/src/Service/NotificationService.php deleted file mode 100644 index c9a5602..0000000 --- a/src/Core/src/NotificationSystem/src/Service/NotificationService.php +++ /dev/null @@ -1,52 +0,0 @@ - $config - */ - #[Inject( - 'config.notification.server', - )] - public function __construct( - private readonly array $config - ) { - } - - public function createClient(): Socket - { - $socketRawFactory = new Factory(); - return $socketRawFactory->createClient( - $this->config['protocol'] . '://' . $this->config['host'] . ':' . $this->config['port'] - ); - } - - public function send(string $message): void - { - $this->createClient()->write($message . $this->config['eof']); - } - - /** - * @param array $data - */ - protected function encodeEmailMessage(array $data): string - { - return Encoder::encode($data); - } - - public function sendNewAccountNotification(User $user): void - { - $data['userUuid'] = $user->getUuid()->toString(); - $this->send($this->encodeEmailMessage($data)); - } -} From f2b8e82f82a2d09f9ea17718bfbe9b450eedc3c0 Mon Sep 17 00:00:00 2001 From: sergiu Date: Thu, 3 Jul 2025 13:07:30 +0300 Subject: [PATCH 4/4] removed extra empty line Signed-off-by: sergiu --- config/autoload/.gitignore | 1 - config/autoload/log.local.php.dist | 1 - 2 files changed, 2 deletions(-) diff --git a/config/autoload/.gitignore b/config/autoload/.gitignore index a8918e0..1a83fda 100644 --- a/config/autoload/.gitignore +++ b/config/autoload/.gitignore @@ -1,3 +1,2 @@ local.php *.local.php - diff --git a/config/autoload/log.local.php.dist b/config/autoload/log.local.php.dist index 74f325d..1422cc8 100644 --- a/config/autoload/log.local.php.dist +++ b/config/autoload/log.local.php.dist @@ -23,4 +23,3 @@ return [ ], ], ]; -