From f572d8350f0cb8048ae3543fe1f29e9ca96bb9b5 Mon Sep 17 00:00:00 2001 From: Ilya Vasilenko Date: Wed, 19 Jun 2024 12:46:08 +0500 Subject: [PATCH] =?UTF-8?q?kafka=20(not=20custom)=20=D1=81onfirmation=20of?= =?UTF-8?q?=20registration=20profile=20delete=20&=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + Makefile | 35 ++- app/.env.example | 8 + app/composer.json | 2 + app/composer.lock | 91 +++++- app/config/packages/messenger.yaml | 41 +++ app/config/packages/security.yaml | 7 + app/config/services.yaml | 32 ++ app/migrations/Version20240614064759.php | 37 +++ app/src/Controller/AuthController.php | 18 +- app/src/Controller/ProfileController.php | 44 +++ app/src/Entity/User.php | 39 +++ app/src/Entity/UserCode.php | 66 +++++ app/src/Entity/UserImage.php | 13 + app/src/Kafka/Transport/Connection.php | 274 ++++++++++++++++++ app/src/Kafka/Transport/KafkaFactory.php | 46 +++ app/src/Kafka/Transport/KafkaOption.php | 202 +++++++++++++ app/src/Kafka/Transport/KafkaReceiver.php | 64 ++++ app/src/Kafka/Transport/KafkaSender.php | 37 +++ app/src/Kafka/Transport/KafkaTransport.php | 58 ++++ .../Kafka/Transport/KafkaTransportFactory.php | 30 ++ app/src/Listeners/AccessDeniedListener.php | 34 +++ app/src/Listeners/CodeListener.php | 63 ++++ app/src/Messenger/Handler/MessageHandler.php | 51 ++++ app/src/Messenger/Message/SendMessage.php | 76 +++++ app/src/Repository/UserCodeRepository.php | 44 +++ app/src/Response/ApiResponse.php | 10 + .../Action/Classes/CheckRecoveryCode.php | 71 +++++ .../Action/Classes/CheckRegisterCode.php | 76 +++++ .../Service/Action/Classes/DeleteProfile.php | 56 ++++ app/src/Service/Action/Classes/GetProfile.php | 44 +++ .../Action/Classes/RecoveryProfile.php | 60 ++++ app/src/Service/Action/Classes/Register.php | 26 +- .../Action/Classes/SendRegisterCode.php | 60 ++++ app/src/Service/Dto/BaseDto.php | 40 ++- .../Service/Dto/Classes/RecoveryCodeDto.php | 31 ++ app/src/Service/Dto/Classes/RecoveryDto.php | 22 ++ .../Service/Dto/Classes/RegisterCodeDto.php | 18 ++ .../Service/Response/BaseResponseService.php | 4 +- .../Response/Classes/ProfileResponse.php | 14 - .../Classes/Code/RecoveryCodeSendService.php | 23 ++ .../Classes/Code/RegisterCodeSendService.php | 22 ++ .../Service/Send/Classes/CodeSendService.php | 127 ++++++++ app/src/Service/Send/SendService.php | 86 ++++++ app/src/Service/Send/SendServiceInterface.php | 8 + app/src/Validators/UserValidator.php | 28 ++ app/symfony.lock | 12 + compose.yaml | 16 +- docker/app/Dockerfile | 2 + 49 files changed, 2224 insertions(+), 53 deletions(-) create mode 100644 app/config/packages/messenger.yaml create mode 100644 app/migrations/Version20240614064759.php create mode 100644 app/src/Controller/ProfileController.php create mode 100644 app/src/Entity/UserCode.php create mode 100644 app/src/Kafka/Transport/Connection.php create mode 100644 app/src/Kafka/Transport/KafkaFactory.php create mode 100644 app/src/Kafka/Transport/KafkaOption.php create mode 100644 app/src/Kafka/Transport/KafkaReceiver.php create mode 100644 app/src/Kafka/Transport/KafkaSender.php create mode 100644 app/src/Kafka/Transport/KafkaTransport.php create mode 100644 app/src/Kafka/Transport/KafkaTransportFactory.php create mode 100644 app/src/Listeners/AccessDeniedListener.php create mode 100644 app/src/Listeners/CodeListener.php create mode 100644 app/src/Messenger/Handler/MessageHandler.php create mode 100644 app/src/Messenger/Message/SendMessage.php create mode 100644 app/src/Repository/UserCodeRepository.php create mode 100644 app/src/Service/Action/Classes/CheckRecoveryCode.php create mode 100644 app/src/Service/Action/Classes/CheckRegisterCode.php create mode 100644 app/src/Service/Action/Classes/DeleteProfile.php create mode 100644 app/src/Service/Action/Classes/GetProfile.php create mode 100644 app/src/Service/Action/Classes/RecoveryProfile.php create mode 100644 app/src/Service/Action/Classes/SendRegisterCode.php create mode 100644 app/src/Service/Dto/Classes/RecoveryCodeDto.php create mode 100644 app/src/Service/Dto/Classes/RecoveryDto.php create mode 100644 app/src/Service/Dto/Classes/RegisterCodeDto.php delete mode 100644 app/src/Service/Response/Classes/ProfileResponse.php create mode 100644 app/src/Service/Send/Classes/Code/RecoveryCodeSendService.php create mode 100644 app/src/Service/Send/Classes/Code/RegisterCodeSendService.php create mode 100644 app/src/Service/Send/Classes/CodeSendService.php create mode 100644 app/src/Service/Send/SendService.php create mode 100644 app/src/Service/Send/SendServiceInterface.php create mode 100644 app/src/Validators/UserValidator.php diff --git a/.env.example b/.env.example index 07265fe..6cbde90 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,12 @@ USER_ID=1000 # IDE XDEBUG_IDE_KEY=myproject + +# Redis +REDIS_PORT=6379 + +# Kafka/zookeeper +ZOOKEEPER_CLIENT_PORT=2181 +ZOOKEEPER_PORT=22181 +KAFKA_BROKER_ID=1 +KAFKA_PORT=29092 \ No newline at end of file diff --git a/Makefile b/Makefile index dcbbadc..725778c 100644 --- a/Makefile +++ b/Makefile @@ -4,10 +4,15 @@ COMPOSE_PREFIX_CMD := DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 COMMAND ?= /bin/sh +DC=docker-compose +KAFKA_SERVERS=kafka:29092 +KAFKA_CONTAINER=kafka +EXEC_KAFKA=$(COMPOSE_PREFIX_CMD) $(DC) exec $(KAFKA_CONTAINER) + # -------------------------- .PHONY: deploy up build-up build down start stop logs images ps command \ - command-root shell-root shell restart rm help + command-root shell-root shell shell-kafka create-kafka-topic restart rm help deploy: ## Start using Prod Image in Prod Mode ${COMPOSE_PREFIX_CMD} docker compose -f compose.prod.yaml up --build -d @@ -52,6 +57,12 @@ shell-root: ## Enter container shell as root shell: ## Enter container shell @${COMPOSE_PREFIX_CMD} docker compose exec app /bin/sh +shell-kafka: ## Run bash shell in kafka container. + @${COMPOSE_PREFIX_CMD} docker compose exec kafka /bin/sh + +create-kafka-topic: ## Create kafka topic + $(MAKE) topic-create send_topic + restart: ## Restart container @${COMPOSE_PREFIX_CMD} docker compose restart @@ -64,4 +75,24 @@ clear: help: ## Show this help. @echo "\n\nMake Application Docker Images and Containers using Docker-Compose files" @echo "Make sure you are using \033[0;32mDocker Version >= 20.1\033[0m & \033[0;32mDocker-Compose >= 1.27\033[0m " - @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m ENV= (default: dev)\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-12s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) \ No newline at end of file + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m ENV= (default: dev)\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-12s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +.PHONY: topics topic topic-create producer-create consumer-groups consumer-group + +topics: ## Display list of topics + $(EXEC_KAFKA) kafka-topics --list --bootstrap-server $(KAFKA_SERVERS) + +topic: ## Describe existing topic + $(EXEC_KAFKA) kafka-topics --describe --bootstrap-server $(KAFKA_SERVERS) --topic $(filter-out $@,$(MAKECMDGOALS)) + +topic-create: ## Create new topic + $(EXEC_KAFKA) kafka-topics --create --bootstrap-server $(KAFKA_SERVERS) --topic $(filter-out $@,$(MAKECMDGOALS)) + +producer-create: ## Create a topic producer + $(EXEC_KAFKA) kafka-console-producer --bootstrap-server $(KAFKA_SERVERS) --topic $(filter-out $@,$(MAKECMDGOALS)) + +consumer-groups: ## Display list of consumer group + $(EXEC_KAFKA) kafka-consumer-groups --list --bootstrap-server $(KAFKA_SERVERS) + +consumer-group: ## Describe existing consumer group + $(EXEC_KAFKA) kafka-consumer-groups --describe --bootstrap-server $(KAFKA_SERVERS) --group $(filter-out $@,$(MAKECMDGOALS)) \ No newline at end of file diff --git a/app/.env.example b/app/.env.example index c9363a7..1b35983 100644 --- a/app/.env.example +++ b/app/.env.example @@ -13,3 +13,11 @@ JWT_PASSPHRASE= MAILER_ADDRESS= MAILER_DSN=smtp://user:pass@smtp.example.com:port ###< symfony/mailer ### + +CODE_TTL=300 +CONFIRM_TYPE=EMAIL + +###> symfony/messenger ### +MESSENGER_TRANSPORT_DSN=kafka:// +KAFKA_BROKERS=kafka:9092 +###< symfony/messenger ### \ No newline at end of file diff --git a/app/composer.json b/app/composer.json index 2677425..6ae3ba1 100644 --- a/app/composer.json +++ b/app/composer.json @@ -7,6 +7,7 @@ "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", + "ext-rdkafka": "*", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.12", "doctrine/doctrine-migrations-bundle": "^3.3", @@ -19,6 +20,7 @@ "symfony/flex": "^2", "symfony/framework-bundle": "7.0.*", "symfony/mailer": "7.0.*", + "symfony/messenger": "7.0.*", "symfony/mime": "7.0.*", "symfony/runtime": "7.0.*", "symfony/security-bundle": "7.0.*", diff --git a/app/composer.lock b/app/composer.lock index 0e49da8..f3c8f9a 100644 --- a/app/composer.lock +++ b/app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9ef02607d8522e77f2ce17f078546adb", + "content-hash": "b38b50076633f562508d6896c57850ce", "packages": [ { "name": "doctrine/cache", @@ -3381,6 +3381,92 @@ ], "time": "2024-05-31T14:55:39+00:00" }, + { + "name": "symfony/messenger", + "version": "v7.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "ed7bccfe31e7f0bdb5b101f48b6027622a7a48cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/ed7bccfe31e7f0bdb5b101f48b6027622a7a48cb", + "reference": "ed7bccfe31e7f0bdb5b101f48b6027622a7a48cb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-05-31T14:55:39+00:00" + }, { "name": "symfony/mime", "version": "v7.0.8", @@ -6351,7 +6437,8 @@ "platform": { "php": ">=8.2", "ext-ctype": "*", - "ext-iconv": "*" + "ext-iconv": "*", + "ext-rdkafka": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/app/config/packages/messenger.yaml b/app/config/packages/messenger.yaml new file mode 100644 index 0000000..6dc7da1 --- /dev/null +++ b/app/config/packages/messenger.yaml @@ -0,0 +1,41 @@ +framework: + messenger: + # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. + # failure_transport: failed + + transports: + send_transport: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + metadata.broker.list: '%env(KAFKA_BROKERS)%' + security.protocol: 'plaintext' + group.id: 'my-group-id' + auto.offset.reset: 'earliest' + enable.partition.eof: 'true' + message.send.max.retries: 5 + producer_message_flags_block: false + + producer_topic: 'send_topic' + consumer_topics: + - 'send_topic' + + # https://symfony.com/doc/current/messenger.html#transport-configuration + # async: '%env(MESSENGER_TRANSPORT_DSN)%' + # failed: 'doctrine://default?queue_name=failed' + # sync: 'sync://' + + routing: + 'App\Messenger\Message\SendMessage': send_transport + # Route your messages to the transports + # 'App\Message\YourMessage': async + + serializer: + default_serializer: messenger.transport.symfony_serializer + +# when@test: +# framework: +# messenger: +# transports: +# # replace with your transport name here (e.g., my_transport: 'in-memory://') +# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test +# async: 'in-memory://' diff --git a/app/config/packages/security.yaml b/app/config/packages/security.yaml index 670446f..92236a8 100644 --- a/app/config/packages/security.yaml +++ b/app/config/packages/security.yaml @@ -31,6 +31,7 @@ security: main: lazy: true provider: app_user_provider + access_denied_handler: App\Listeners\AccessDeniedListener # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -42,7 +43,13 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/api/login, roles: PUBLIC_ACCESS } + - { path: ^/api/register, roles: PUBLIC_ACCESS } + - { path: ^/api/register/send, roles: ROLE_USER } + - { path: ^/api/register/check, roles: ROLE_USER } + + - { path: ^/api/profile/recovery, roles: PUBLIC_ACCESS } + - { path: ^/api/profile/recovery/check, roles: PUBLIC_ACCESS } - { path: ^/api, roles: ROLE_CONFIRMED } # - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } diff --git a/app/config/services.yaml b/app/config/services.yaml index 8c127d8..0a7f6ed 100644 --- a/app/config/services.yaml +++ b/app/config/services.yaml @@ -4,6 +4,9 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + confirm_type: '%env(CONFIRM_TYPE)%' + code_ttl: '%env(CODE_TTL)%' + from_email: '%env(MAILER_ADDRESS)%' services: # default configuration for services in *this* file @@ -23,12 +26,30 @@ services: # Сервисы действий App\Service\Action\ActionServiceInterface $registerService: '@App\Service\Action\Classes\Register' + App\Service\Action\ActionServiceInterface $profileService: '@App\Service\Action\Classes\GetProfile' + + App\Service\Action\ActionServiceInterface $deleteProfileService: '@App\Service\Action\Classes\DeleteProfile' + + App\Service\Action\ActionServiceInterface $recoveryProfileService: '@App\Service\Action\Classes\RecoveryProfile' + + App\Service\Action\ActionServiceInterface $checkRegisterService: '@App\Service\Action\Classes\CheckRegisterCode' + + App\Service\Action\ActionServiceInterface $checkRecoveryService: '@App\Service\Action\Classes\CheckRecoveryCode' + + App\Service\Action\ActionServiceInterface $sendRegisterService: '@App\Service\Action\Classes\SendRegisterCode' + App\Service\Action\ActionServiceInterface: '@App\Service\Action\Classes\None' # Сервисы Dto App\Service\Dto\DtoServiceInterface $registerDto: '@App\Service\Dto\Classes\RegisterDto' + App\Service\Dto\DtoServiceInterface $registerCodeDto: '@App\Service\Dto\Classes\RegisterCodeDto' + + App\Service\Dto\DtoServiceInterface $recoveryCodeDto: '@App\Service\Dto\Classes\RecoveryCodeDto' + + App\Service\Dto\DtoServiceInterface $recoveryDto: '@App\Service\Dto\Classes\RecoveryDto' + App\Service\Dto\DtoServiceInterface: '@App\Service\Dto\Classes\NoneDto' @@ -38,6 +59,17 @@ services: App\Service\Response\ResponseServiceInterface: '@App\Service\Response\Classes\Response' + # Сервис отправки + App\Service\Send\SendService: + arguments: + $confirmType: '%confirm_type%' + $fromEmail: '%from_email%' + + App\Service\Send\SendServiceInterface $codeSendService: '@App\Service\Send\Classes\CodeSendService' + + App\Service\Send\SendServiceInterface $registerCodeSendService: '@App\Service\Send\Classes\Code\RegisterCodeSendService' + + App\Service\Send\SendServiceInterface $recoveryCodeSendService: '@App\Service\Send\Classes\Code\RecoveryCodeSendService' # События JWT авторизации acme_api.event.authentication_success_listener: diff --git a/app/migrations/Version20240614064759.php b/app/migrations/Version20240614064759.php new file mode 100644 index 0000000..1bf9346 --- /dev/null +++ b/app/migrations/Version20240614064759.php @@ -0,0 +1,37 @@ +addSql('CREATE SEQUENCE user_code_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE user_code (id INT NOT NULL, related_user_id INT DEFAULT NULL, code VARCHAR(255) NOT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_D947C5198771930 ON user_code (related_user_id)'); + $this->addSql('ALTER TABLE user_code ADD CONSTRAINT FK_D947C5198771930 FOREIGN KEY (related_user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP SEQUENCE user_code_id_seq CASCADE'); + $this->addSql('ALTER TABLE user_code DROP CONSTRAINT FK_D947C5198771930'); + $this->addSql('DROP TABLE user_code'); + } +} diff --git a/app/src/Controller/AuthController.php b/app/src/Controller/AuthController.php index 553613e..47b3178 100644 --- a/app/src/Controller/AuthController.php +++ b/app/src/Controller/AuthController.php @@ -2,11 +2,9 @@ namespace App\Controller; -use App\Response\ApiResponse; use App\Service\Action\ActionServiceInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; #[Route('/api', name: 'api_')] @@ -19,4 +17,20 @@ class AuthController extends AbstractController { return $registerService->getResponse(); } + + #[Route('/register/send', name: 'register_send', methods: ['GET'])] + public function sendRegisterCode( + ActionServiceInterface $sendRegisterService, + ): JsonResponse + { + return $sendRegisterService->getResponse(); + } + + #[Route('/register/check', name: 'register_check', methods: ['POST'])] + public function checkRegisterCode( + ActionServiceInterface $checkRegisterService + ): JsonResponse + { + return $checkRegisterService->getResponse(); + } } diff --git a/app/src/Controller/ProfileController.php b/app/src/Controller/ProfileController.php new file mode 100644 index 0000000..51e5442 --- /dev/null +++ b/app/src/Controller/ProfileController.php @@ -0,0 +1,44 @@ +getResponse(); + } + + #[Route('/profile/delete', name: 'profile_delete', methods: ['GET'])] + public function deleteProfile( + ActionServiceInterface $deleteProfileService, + ): JsonResponse + { + return $deleteProfileService->getResponse(); + } + + #[Route('/profile/recovery', name: 'profile_recovery', methods: ['POST'])] + public function recoveryProfile( + ActionServiceInterface $recoveryProfileService, + ): JsonResponse + { + return $recoveryProfileService->getResponse(); + } + + #[Route('/profile/recovery/check', name: 'profile_recovery_check', methods: ['POST'])] + public function recoveryCodeProfile( + ActionServiceInterface $checkRecoveryService, + ): JsonResponse + { + return $checkRecoveryService->getResponse(); + } +} diff --git a/app/src/Entity/User.php b/app/src/Entity/User.php index 47fb67c..1ec76ec 100644 --- a/app/src/Entity/User.php +++ b/app/src/Entity/User.php @@ -6,6 +6,8 @@ use App\Repository\UserRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\Ignore; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: UserRepository::class)] @@ -54,11 +56,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column] private ?bool $deleted = null; + #[ORM\OneToOne(mappedBy: 'related_user', cascade: ['persist', 'remove'])] + private ?UserCode $register_code = null; + + #[Groups(['all'])] public function getId(): ?int { return $this->id; } + #[Groups(['all', 'profile', 'edit', 'card', 'detail'])] public function getEmail(): ?string { return $this->email; @@ -76,6 +83,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * * @see UserInterface */ + #[Ignore] public function getUserIdentifier(): string { return (string) $this->email; @@ -86,6 +94,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface * * @return list */ + #[Groups(['all'])] public function getRoles(): array { $roles = $this->roles; @@ -115,6 +124,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** * @see PasswordAuthenticatedUserInterface */ + #[Groups(['all'])] public function getPassword(): string { return $this->password; @@ -136,6 +146,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface // $this->plainPassword = null; } + #[Groups(['all', 'profile', 'edit'])] public function getName(): ?string { return $this->name; @@ -148,6 +159,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + #[Groups(['all', 'profile', 'edit'])] public function getSurname(): ?string { return $this->surname; @@ -160,6 +172,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + #[Groups(['all', 'profile', 'edit'])] public function getPatronymic(): ?string { return $this->patronymic; @@ -172,6 +185,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + #[Groups(['all', 'profile', 'edit'])] public function getPhoneNumber(): ?string { return $this->phone_number; @@ -184,6 +198,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + #[Groups(['all', 'profile', 'edit'])] public function getImage(): ?UserImage { return $this->image; @@ -206,6 +221,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + #[Groups(['all', 'profile'])] public function isConfirm(): ?bool { return $this->confirm; @@ -230,8 +246,31 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + #[Groups(['card', 'detail'])] public function getFullName(): string { return $this->getSurname() . ' ' . $this->getName() . ' ' . $this->getPatronymic() ?: ''; } + + public function getRegisterCode(): ?UserCode + { + return $this->register_code; + } + + public function setRegisterCode(?UserCode $register_code): static + { + // unset the owning side of the relation if necessary + if ($register_code === null && $this->register_code !== null) { + $this->register_code->setRelatedUser(null); + } + + // set the owning side of the relation if necessary + if ($register_code !== null && $register_code->getRelatedUser() !== $this) { + $register_code->setRelatedUser($this); + } + + $this->register_code = $register_code; + + return $this; + } } diff --git a/app/src/Entity/UserCode.php b/app/src/Entity/UserCode.php new file mode 100644 index 0000000..a338b61 --- /dev/null +++ b/app/src/Entity/UserCode.php @@ -0,0 +1,66 @@ +id; + } + + public function getCode(): ?string + { + return $this->code; + } + + public function setCode(string $code): static + { + $this->code = $code; + + return $this; + } + + public function getDate(): ?\DateTimeInterface + { + return $this->date; + } + + public function setDate(\DateTimeInterface $date): static + { + $this->date = $date; + + return $this; + } + + public function getRelatedUser(): ?User + { + return $this->related_user; + } + + public function setRelatedUser(?User $related_user): static + { + $this->related_user = $related_user; + + return $this; + } +} diff --git a/app/src/Entity/UserImage.php b/app/src/Entity/UserImage.php index 439f120..d58226d 100644 --- a/app/src/Entity/UserImage.php +++ b/app/src/Entity/UserImage.php @@ -4,6 +4,7 @@ namespace App\Entity; use App\Repository\UserImageRepository; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Entity(repositoryClass: UserImageRepository::class)] class UserImage @@ -42,11 +43,21 @@ class UserImage return $this; } + #[Groups(['all', 'edit'])] public function getPath(): ?string { return $this->path; } + #[Groups(['all', 'profile', 'edit'])] + public function getPublicPath(): ?string + { + if ($this->path !== null) { + return str_replace('/app/public', '', $this->path); + } + return null; + } + public function setPath(string $path): static { $this->path = $path; @@ -54,6 +65,7 @@ class UserImage return $this; } + #[Groups(['all', 'profile', 'edit'])] public function getName(): ?string { return $this->name; @@ -66,6 +78,7 @@ class UserImage return $this; } + #[Groups(['all', 'edit'])] public function getType(): ?string { return $this->type; diff --git a/app/src/Kafka/Transport/Connection.php b/app/src/Kafka/Transport/Connection.php new file mode 100644 index 0000000..81a78e3 --- /dev/null +++ b/app/src/Kafka/Transport/Connection.php @@ -0,0 +1,274 @@ +> $kafkaConfig */ + public function __construct( + private readonly array $kafkaConfig, + private readonly string $transportName, + private readonly KafkaFactory $kafkaFactory = new KafkaFactory() + ) { + if (!\extension_loaded('rdkafka')) { + throw new LogicException(sprintf( + 'You cannot use the "%s" as the "rdkafka" extension is not installed.', __CLASS__ + )); + } + } + + public function setup(): void + { + if (!array_key_exists(self::BROKERS_LIST, $this->kafkaConfig)) { + throw new LogicException(sprintf( + 'The "%s" option is required for the Kafka Messenger transport "%s".', + self::BROKERS_LIST, + $this->transportName + )); + } + + if ( + !array_key_exists(self::CONSUMER_TOPICS_NAME, $this->kafkaConfig) && + !array_key_exists(self::PRODUCER_TOPIC_NAME, $this->kafkaConfig) + ) { + throw new LogicException(sprintf( + 'At least one of "%s" or "%s" options is required for the Kafka Messenger transport "%s".', + self::CONSUMER_TOPICS_NAME, + self::PRODUCER_TOPIC_NAME, + $this->transportName + )); + } + } + + /** @psalm-param array> $options */ + public static function builder(array $options = [], KafkaFactory $kafkaFactory = null): self + { + if (!array_key_exists(self::TRANSPORT_NAME, $options) || !is_string($options[self::TRANSPORT_NAME])) { + throw new RuntimeException('Transport name must be exist end type of string.'); + } + + self::optionsValidator($options, $options[self::TRANSPORT_NAME]); + + return new self($options, $options[self::TRANSPORT_NAME], $kafkaFactory ?? new KafkaFactory()); + } + + public function get(): \RdKafka\Message + { + if (!array_key_exists(self::GROUP_ID, $this->kafkaConfig)) { + throw new LogicException(sprintf( + 'The transport "%s" is not configured to consume messages because "%s" option is missing.', + $this->transportName, + self::GROUP_ID + )); + } + + $consumer = $this->kafkaFactory->createConsumer($this->kafkaConfig); + + try { + $consumer->subscribe($this->getTopics()); + + return $consumer->consume($this->getConsumerConsumeTimeout()); + } catch (\RdKafka\Exception $e) { + throw new TransportException($e->getMessage(), 0, $e); + } + } + + /** @psalm-param array $headers */ + public function publish(string $body, array $headers = []): void + { + $producer = $this->kafkaFactory->createProducer($this->kafkaConfig); + + $topic = $producer->newTopic($this->getTopic()); + $topic->producev( + partition: $this->getPartitionId(), // todo: retrieve from stamp ? + msgflags: $this->getMessageFlags(), + payload: $body, + headers: $headers + ); + + $producer->poll($this->getProducerPollTimeout()); + $producer->flush($this->getProducerFlushTimeout()); + } + + /** @psalm-param array> $options */ + private static function optionsValidator(array $options, string $transportName): void + { + $invalidOptions = array_diff( + array_keys($options), + array_merge( + self::GLOBAL_OPTIONS, + array_keys( + array_merge(self::GLOBAL_OPTIONS, KafkaOption::consumer(), KafkaOption::producer()) + ) + ) + ); + + if (0 < \count($invalidOptions)) { + throw new LogicException(sprintf( + 'Invalid option(s) "%s" passed to the Kafka Messenger transport "%s".', + implode('", "', $invalidOptions), + $transportName + )); + } + } + + /** @psalm-return array */ + private function getTopics(): array + { + if (!array_key_exists(self::CONSUMER_TOPICS_NAME, $this->kafkaConfig)) { + throw new LogicException(sprintf( + 'The transport "%s" is not configured to consume messages because "%s" option is missing.', + $this->transportName, + self::CONSUMER_TOPICS_NAME + )); + } + + if (!is_array($this->kafkaConfig[self::CONSUMER_TOPICS_NAME])) { + throw new LogicException(sprintf( + 'The "%s" option type must be array, %s given in "%s" transport.', + self::CONSUMER_TOPICS_NAME, + gettype($this->kafkaConfig[self::CONSUMER_TOPICS_NAME]), + $this->transportName + )); + } + + return $this->kafkaConfig[self::CONSUMER_TOPICS_NAME]; + } + + private function getConsumerConsumeTimeout(): int + { + if (!array_key_exists(self::CONSUMER_CONSUME_TIMEOUT_MS, $this->kafkaConfig)) { + return 10000; + } + + if (!is_int($this->kafkaConfig[self::CONSUMER_CONSUME_TIMEOUT_MS])) { + throw new LogicException(sprintf( + 'The "%s" option type must be integer, %s given in "%s" transport.', + self::CONSUMER_CONSUME_TIMEOUT_MS, + gettype($this->kafkaConfig[self::CONSUMER_CONSUME_TIMEOUT_MS]), + $this->transportName + )); + } + + return $this->kafkaConfig[self::CONSUMER_CONSUME_TIMEOUT_MS]; + } + + private function getTopic(): string + { + if (!array_key_exists(self::PRODUCER_TOPIC_NAME, $this->kafkaConfig)) { + throw new LogicException(sprintf( + 'The transport "%s" is not configured to dispatch messages because "%s" option is missing.', + $this->transportName, + self::PRODUCER_TOPIC_NAME + )); + } + + if (!is_string($this->kafkaConfig[self::PRODUCER_TOPIC_NAME])) { + throw new LogicException(sprintf( + 'The "%s" option type must be string, %s given in "%s" transport.', + self::PRODUCER_TOPIC_NAME, + gettype($this->kafkaConfig[self::PRODUCER_TOPIC_NAME]), + $this->transportName + )); + } + + return $this->kafkaConfig[self::PRODUCER_TOPIC_NAME]; + } + + private function getMessageFlags(): int + { + if (!array_key_exists(self::PRODUCER_MESSAGE_FLAGS_BLOCK, $this->kafkaConfig)) { + return 0; + } + + if (!is_bool($this->kafkaConfig[self::PRODUCER_MESSAGE_FLAGS_BLOCK])) { + throw new LogicException(sprintf( + 'The "%s" option type must be boolean, %s given in "%s" transport.', + self::PRODUCER_MESSAGE_FLAGS_BLOCK, + gettype($this->kafkaConfig[self::PRODUCER_MESSAGE_FLAGS_BLOCK]), + $this->transportName + )); + } + + return false === $this->kafkaConfig[self::PRODUCER_MESSAGE_FLAGS_BLOCK] ? 0 : RD_KAFKA_MSG_F_BLOCK; + } + + private function getPartitionId(): int + { + if (!array_key_exists(self::PRODUCER_PARTITION_ID_ASSIGNMENT, $this->kafkaConfig)) { + return RD_KAFKA_PARTITION_UA; + } + + if (!is_int($this->kafkaConfig[self::PRODUCER_PARTITION_ID_ASSIGNMENT])) { + throw new LogicException(sprintf( + 'The "%s" option type must be integer, %s given in "%s" transport.', + self::PRODUCER_PARTITION_ID_ASSIGNMENT, + gettype($this->kafkaConfig[self::PRODUCER_PARTITION_ID_ASSIGNMENT]), + $this->transportName + )); + } + + return $this->kafkaConfig[self::PRODUCER_PARTITION_ID_ASSIGNMENT]; + } + + private function getProducerPollTimeout(): int + { + if (!array_key_exists(self::PRODUCER_POLL_TIMEOUT_MS, $this->kafkaConfig)) { + return 0; + } + + if (!is_int($this->kafkaConfig[self::PRODUCER_POLL_TIMEOUT_MS])) { + throw new LogicException(sprintf( + 'The "%s" option type must be integer, %s given in "%s" transport.', + self::PRODUCER_POLL_TIMEOUT_MS, + gettype($this->kafkaConfig[self::PRODUCER_POLL_TIMEOUT_MS]), + $this->transportName + )); + } + + return $this->kafkaConfig[self::PRODUCER_POLL_TIMEOUT_MS]; + } + + private function getProducerFlushTimeout(): int + { + if (!array_key_exists(self::PRODUCER_FLUSH_TIMEOUT_MS, $this->kafkaConfig)) { + return 10000; + } + + if (!is_int($this->kafkaConfig[self::PRODUCER_FLUSH_TIMEOUT_MS])) { + throw new LogicException(sprintf( + 'The "%s" option type must be integer, %s given in "%s" transport.', + self::PRODUCER_FLUSH_TIMEOUT_MS, + gettype($this->kafkaConfig[self::PRODUCER_FLUSH_TIMEOUT_MS]), + $this->transportName + )); + } + + return $this->kafkaConfig[self::PRODUCER_FLUSH_TIMEOUT_MS]; + } +} diff --git a/app/src/Kafka/Transport/KafkaFactory.php b/app/src/Kafka/Transport/KafkaFactory.php new file mode 100644 index 0000000..ea4df30 --- /dev/null +++ b/app/src/Kafka/Transport/KafkaFactory.php @@ -0,0 +1,46 @@ +> $kafkaConfig */ + public function createConsumer(array $kafkaConfig): KafkaConsumer + { + $conf = new Conf(); + + foreach ($kafkaConfig as $key => $value) { + if (array_key_exists($key, array_merge(KafkaOption::global(), KafkaOption::consumer()))) { + if (!is_string($value)) { + // todo: warning + continue; + } + $conf->set($key, $value); + } + } + + return new KafkaConsumer($conf); + } + + /** @psalm-param array> $kafkaConfig */ + public function createProducer(array $kafkaConfig): Producer + { + $conf = new Conf(); + + foreach ($kafkaConfig as $key => $value) { + if (array_key_exists($key, array_merge(KafkaOption::global(), KafkaOption::producer()))) { + if (!is_string($value)) { + // todo: warning + continue; + } + $conf->set($key, $value); + } + } + + return new Producer($conf); + } +} diff --git a/app/src/Kafka/Transport/KafkaOption.php b/app/src/Kafka/Transport/KafkaOption.php new file mode 100644 index 0000000..a7bbdf6 --- /dev/null +++ b/app/src/Kafka/Transport/KafkaOption.php @@ -0,0 +1,202 @@ + */ + public static function consumer(): array + { + return array_merge( + self::global(), + [ + 'group.id' => 'C', + 'group.instance.id' => 'C', + 'partition.assignment.strategy' => 'C', + 'session.timeout.ms' => 'C', + 'heartbeat.interval.ms' => 'C', + 'group.protocol.type' => 'C', + 'coordinator.query.interval.ms' => 'C', + 'max.poll.interval.ms' => 'C', + 'enable.auto.commit' => 'C', + 'auto.commit.interval.ms' => 'C', + 'enable.auto.offset.store' => 'C', + 'queued.min.messages' => 'C', + 'queued.max.messages.kbytes' => 'C', + 'fetch.wait.max.ms' => 'C', + 'fetch.message.max.bytes' => 'C', + 'max.partition.fetch.bytes' => 'C', + 'fetch.max.bytes' => 'C', + 'fetch.min.bytes' => 'C', + 'fetch.error.backoff.ms' => 'C', + 'offset.store.method' => 'C', + 'isolation.level' => 'C', + 'consume_cb' => 'C', + 'rebalance_cb' => 'C', + 'offset_commit_cb' => 'C', + 'enable.partition.eof' => 'C', + 'check.crcs' => 'C', + 'auto.commit.enable' => 'C', + 'auto.offset.reset' => 'C', + 'offset.store.path' => 'C', + 'offset.store.sync.interval.ms' => 'C', + 'consume.callback.max.messages' => 'C', + ] + ); + } + + /** @psalm-return array */ + public static function producer(): array + { + return array_merge( + self::global(), + [ + 'transactional.id' => 'P', + 'transaction.timeout.ms' => 'P', + 'enable.idempotence' => 'P', + 'enable.gapless.guarantee' => 'P', + 'queue.buffering.max.messages' => 'P', + 'queue.buffering.max.kbytes' => 'P', + 'queue.buffering.max.ms' => 'P', + 'linger.ms' => 'P', + 'message.send.max.retries' => 'P', + 'retries' => 'P', + 'retry.backoff.ms' => 'P', + 'queue.buffering.backpressure.threshold' => 'P', + 'compression.codec' => 'P', + 'compression.type' => 'P', + 'batch.num.messages' => 'P', + 'batch.size' => 'P', + 'delivery.report.only.error' => 'P', + 'dr_cb' => 'P', + 'dr_msg_cb' => 'P', + 'sticky.partitioning.linger.ms' => 'P', + 'request.required.acks' => 'P', + 'acks' => 'P', + 'request.timeout.ms' => 'P', + 'message.timeout.ms' => 'P', + 'delivery.timeout.ms' => 'P', + 'queuing.strategy' => 'P', + 'produce.offset.report' => 'P', + 'partitioner' => 'P', + 'partitioner_cb' => 'P', + 'msg_order_cmp' => 'P', + 'compression.level' => 'P', + ] + ); + } + + /** @psalm-return array */ + public static function global(): array + { + return [ + 'builtin.features' => '*', + 'client.id' => '*', + 'metadata.broker.list' => '*', + 'bootstrap.servers' => '*', + 'message.max.bytes' => '*', + 'message.copy.max.bytes' => '*', + 'receive.message.max.bytes' => '*', + 'max.in.flight.requests.per.connection' => '*', + 'max.in.flight' => '*', + 'topic.metadata.refresh.interval.ms' => '*', + 'metadata.max.age.ms' => '*', + 'topic.metadata.refresh.fast.interval.ms' => '*', + 'topic.metadata.refresh.fast.cnt' => '*', + 'topic.metadata.refresh.sparse' => '*', + 'topic.metadata.propagation.max.ms' => '*', + 'topic.blacklist' => '*', + 'debug' => '*', + 'socket.timeout.ms' => '*', + 'socket.blocking.max.ms' => '*', + 'socket.send.buffer.bytes' => '*', + 'socket.receive.buffer.bytes' => '*', + 'socket.keepalive.enable' => '*', + 'socket.nagle.disable' => '*', + 'socket.max.fails' => '*', + 'broker.address.ttl' => '*', + 'broker.address.family' => '*', + 'socket.connection.setup.timeout.ms' => '*', + 'connections.max.idle.ms' => '*', + 'reconnect.backoff.jitter.ms' => '*', + 'reconnect.backoff.ms' => '*', + 'reconnect.backoff.max.ms' => '*', + 'statistics.interval.ms' => '*', + 'enabled_events' => '*', + 'error_cb' => '*', + 'throttle_cb' => '*', + 'stats_cb' => '*', + 'log_cb' => '*', + 'log_level' => '*', + 'log.queue' => '*', + 'log.thread.name' => '*', + 'enable.random.seed' => '*', + 'log.connection.close' => '*', + 'background_event_cb' => '*', + 'socket_cb' => '*', + 'connect_cb' => '*', + 'closesocket_cb' => '*', + 'open_cb' => '*', + 'resolve_cb' => '*', + 'opaque' => '*', + 'default_topic_conf' => '*', + 'internal.termination.signal' => '*', + 'api.version.request' => '*', + 'api.version.request.timeout.ms' => '*', + 'api.version.fallback.ms' => '*', + 'broker.version.fallback' => '*', + 'allow.auto.create.topics' => '*', + 'security.protocol' => '*', + 'ssl.cipher.suites' => '*', + 'ssl.curves.list' => '*', + 'ssl.sigalgs.list' => '*', + 'ssl.key.location' => '*', + 'ssl.key.password' => '*', + 'ssl.key.pem' => '*', + 'ssl_key' => '*', + 'ssl.certificate.location' => '*', + 'ssl.certificate.pem' => '*', + 'ssl_certificate' => '*', + 'ssl.ca.location' => '*', + 'ssl.ca.pem' => '*', + 'ssl_ca' => '*', + 'ssl.ca.certificate.stores' => '*', + 'ssl.crl.location' => '*', + 'ssl.keystore.location' => '*', + 'ssl.keystore.password' => '*', + 'ssl.providers' => '*', + 'ssl.engine.location' => '*', + 'ssl.engine.id' => '*', + 'ssl_engine_callback_data' => '*', + 'enable.ssl.certificate.verification' => '*', + 'ssl.endpoint.identification.algorithm' => '*', + 'ssl.certificate.verify_cb' => '*', + 'sasl.mechanisms' => '*', + 'sasl.mechanism' => '*', + 'sasl.kerberos.service.name' => '*', + 'sasl.kerberos.principal' => '*', + 'sasl.kerberos.kinit.cmd' => '*', + 'sasl.kerberos.keytab' => '*', + 'sasl.kerberos.min.time.before.relogin' => '*', + 'sasl.username' => '*', + 'sasl.password' => '*', + 'sasl.oauthbearer.config' => '*', + 'enable.sasl.oauthbearer.unsecure.jwt' => '*', + 'oauthbearer_token_refresh_cb' => '*', + 'sasl.oauthbearer.method' => '*', + 'sasl.oauthbearer.client.id' => '*', + 'sasl.oauthbearer.client.secret' => '*', + 'sasl.oauthbearer.scope' => '*', + 'sasl.oauthbearer.extensions' => '*', + 'sasl.oauthbearer.token.endpoint.url' => '*', + 'plugin.library.paths' => '*', + 'interceptors' => '*', + 'client.rack' => '*', + ]; + } +} diff --git a/app/src/Kafka/Transport/KafkaReceiver.php b/app/src/Kafka/Transport/KafkaReceiver.php new file mode 100644 index 0000000..e48e1fc --- /dev/null +++ b/app/src/Kafka/Transport/KafkaReceiver.php @@ -0,0 +1,64 @@ +connection = $connection; + $this->serializer = $serializer ?? new PhpSerializer(); + } + + /** @psalm-return array */ + public function get(): iterable + { + yield from $this->getEnvelope(); + } + + /** @SuppressWarnings(PHPMD.UnusedFormalParameter) */ + public function ack(Envelope $envelope): void + { + // no ack method for kafka transport + } + + /** @SuppressWarnings(PHPMD.UnusedFormalParameter) */ + public function reject(Envelope $envelope): void + { + // no reject method for kafka transport + } + + /** @psalm-return array */ + private function getEnvelope(): iterable + { + try { + $kafkaMessage = $this->connection->get(); + } catch (\RdKafka\Exception $exception) { + throw new TransportException($exception->getMessage(), 0, $exception); + } + + if (RD_KAFKA_RESP_ERR_NO_ERROR !== $kafkaMessage->err) { + switch ($kafkaMessage->err) { + case RD_KAFKA_RESP_ERR__PARTITION_EOF: // No more messages + case RD_KAFKA_RESP_ERR__TIMED_OUT: // Attempt to connect again + return; + default: + throw new TransportException($kafkaMessage->errstr(), $kafkaMessage->err); + } + } + + yield $this->serializer->decode([ + 'body' => $kafkaMessage->payload, + 'headers' => $kafkaMessage->headers, + ]); + } +} diff --git a/app/src/Kafka/Transport/KafkaSender.php b/app/src/Kafka/Transport/KafkaSender.php new file mode 100644 index 0000000..f50994b --- /dev/null +++ b/app/src/Kafka/Transport/KafkaSender.php @@ -0,0 +1,37 @@ +connection = $connection; + $this->serializer = $serializer ?? new PhpSerializer(); + } + + public function send(Envelope $envelope): Envelope + { + $encodedMessage = $this->serializer->encode($envelope); + + try { + $this->connection->publish( + $encodedMessage['body'], + $encodedMessage['headers'] ?? [] + ); + } catch (\RdKafka\Exception $e) { + throw new TransportException($e->getMessage(), 0, $e); + } + + return $envelope; + } +} diff --git a/app/src/Kafka/Transport/KafkaTransport.php b/app/src/Kafka/Transport/KafkaTransport.php new file mode 100644 index 0000000..65a2343 --- /dev/null +++ b/app/src/Kafka/Transport/KafkaTransport.php @@ -0,0 +1,58 @@ +connection = $connection; + $this->serializer = $serializer ?? new PhpSerializer(); + } + + public function setup(): void + { + $this->connection->setup(); + } + + public function get(): iterable + { + return $this->getReceiver()->get(); + } + + public function ack(Envelope $envelope): void + { + $this->getReceiver()->ack($envelope); + } + + public function reject(Envelope $envelope): void + { + $this->getReceiver()->reject($envelope); + } + + public function send(Envelope $envelope): Envelope + { + return $this->getSender()->send($envelope); + } + + private function getReceiver(): KafkaReceiver + { + return $this->receiver ??= new KafkaReceiver($this->connection, $this->serializer); + } + + private function getSender(): KafkaSender + { + return $this->sender ??= new KafkaSender($this->connection, $this->serializer); + } +} diff --git a/app/src/Kafka/Transport/KafkaTransportFactory.php b/app/src/Kafka/Transport/KafkaTransportFactory.php new file mode 100644 index 0000000..2c1a449 --- /dev/null +++ b/app/src/Kafka/Transport/KafkaTransportFactory.php @@ -0,0 +1,30 @@ +> $options + */ + public function createTransport(string $dsn, array $options, SerializerInterface $serializer): TransportInterface + { + return new KafkaTransport(Connection::builder($options), $serializer); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * + * @psalm-param array> $options + */ + public function supports(string $dsn, array $options): bool + { + return str_starts_with($dsn, 'kafka://'); + } +} diff --git a/app/src/Listeners/AccessDeniedListener.php b/app/src/Listeners/AccessDeniedListener.php new file mode 100644 index 0000000..8299af1 --- /dev/null +++ b/app/src/Listeners/AccessDeniedListener.php @@ -0,0 +1,34 @@ + ['onAccessException', 2], + ]; + } + + public function onAccessException(ExceptionEvent $event): void + { + $response = new ApiResponse(); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + $response->addError('Доступ запрещен'); + + $exception = $event->getThrowable(); + if (!$exception instanceof AccessDeniedException) { + return; + } + $event->setResponse($response); + } +} \ No newline at end of file diff --git a/app/src/Listeners/CodeListener.php b/app/src/Listeners/CodeListener.php new file mode 100644 index 0000000..5b143df --- /dev/null +++ b/app/src/Listeners/CodeListener.php @@ -0,0 +1,63 @@ +checkCode($code, $args->getObjectManager()); + } + + public function preUpdate(UserCode $code, PreUpdateEventArgs $args): void + { + $this->checkCode($code, $args->getObjectManager()); + } + + /** + * Проверка кода и генерация кода + * + * @param UserCode $code + * @param ObjectManager $om + * + * @return void + * + * @throws RandomException + */ + public function checkCode(UserCode $code, ObjectManager $om): void + { + $user = $code->getRelatedUser(); + if ($user === null) { + $om->remove($code); + $om->flush(); + } else { + $date = $code->getDate(); + $needNewCode = false; + if ($date === null) { + $needNewCode = true; + } else { + $currentDate = new \DateTime(); + if ($currentDate->getTimestamp() >= $date->getTimestamp()) { + $needNewCode = true; + } + } + + if ($needNewCode) { + $newDate = new \DateTime(); + $newDate->setTimestamp($newDate->getTimestamp() + $_ENV['CODE_TTL'] ?: 300); + $code->setDate($newDate); + $code->setCode(sprintf('%06d', random_int(0, 999999))); + } + } + } +} \ No newline at end of file diff --git a/app/src/Messenger/Handler/MessageHandler.php b/app/src/Messenger/Handler/MessageHandler.php new file mode 100644 index 0000000..097a247 --- /dev/null +++ b/app/src/Messenger/Handler/MessageHandler.php @@ -0,0 +1,51 @@ +getSendType()) { + case 'EMAIL': + $mail = new Email(); + $mail + ->subject($message->getSubject()) + ->from($message->getFrom()) + ->to($message->getTo()) + ->html($message->getBody()); + try { + $this->mailer->send($mail); + } catch (\Exception $exception) { + throw new \Exception('Ошибка отправки письма'); + } + break; + case 'SMS': + throw new \Exception('Отправка СМС недоступна'); + break; + } + } +} \ No newline at end of file diff --git a/app/src/Messenger/Message/SendMessage.php b/app/src/Messenger/Message/SendMessage.php new file mode 100644 index 0000000..1510b5b --- /dev/null +++ b/app/src/Messenger/Message/SendMessage.php @@ -0,0 +1,76 @@ +sendType; + } + + public function setSendType(?string $sendType): self + { + $this->sendType = $sendType; + + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function setSubject(string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getBody(): string + { + return $this->body; + } + + public function setBody(string $body): self + { + $this->body = $body; + + return $this; + } + + public function getFrom(): string + { + return $this->from; + } + + public function setFrom(string $from): self + { + $this->from = $from; + + return $this; + } + + public function getTo(): string + { + return $this->to; + } + + public function setTo(string $to): self + { + $this->to = $to; + + return $this; + } +} \ No newline at end of file diff --git a/app/src/Repository/UserCodeRepository.php b/app/src/Repository/UserCodeRepository.php new file mode 100644 index 0000000..9869854 --- /dev/null +++ b/app/src/Repository/UserCodeRepository.php @@ -0,0 +1,44 @@ + + */ +class UserCodeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, UserCode::class); + } + + // /** + // * @return UserCode[] Returns an array of UserCode objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('u.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?UserCode + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/app/src/Response/ApiResponse.php b/app/src/Response/ApiResponse.php index 87b5533..9e36975 100644 --- a/app/src/Response/ApiResponse.php +++ b/app/src/Response/ApiResponse.php @@ -72,6 +72,16 @@ class ApiResponse extends JsonResponse public function setResponseData(?array $responseData): void { $this->responseData = $responseData; + + $this->setResult(); + } + + /** + * @return bool + */ + public function isSuccess(): bool + { + return $this->status; } /** diff --git a/app/src/Service/Action/Classes/CheckRecoveryCode.php b/app/src/Service/Action/Classes/CheckRecoveryCode.php new file mode 100644 index 0000000..fa1c5ff --- /dev/null +++ b/app/src/Service/Action/Classes/CheckRecoveryCode.php @@ -0,0 +1,71 @@ +recoveryCodeDto->getClass(); + /** @var User $userExists */ + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($dto->email, $dto->phoneNumber); + + if ($userExists) { + $currentDate = new \DateTime(); + $code = $dto->code; + $registerCode = $userExists->getRegisterCode(); + if ($registerCode === null) { + $this->response->getResponse()->addError('Код подтверждения не отправлен'); + } else { + if ($registerCodeDate = $registerCode->getDate()) { + if ($registerCode->getCode() === $code && $currentDate->getTimestamp() < $registerCodeDate->getTimestamp()) { + try { + $userExists->setDeleted(false); + $em = $this->doctrine->getManager(); + $em->persist($userExists); + $em->remove($registerCode); + $em->flush(); + $this->response->getResponse()->addMessage('Профиль восстановлен'); + } catch (\Exception $exception) { + $this->response->getResponse()->addError('Ошибка восстановления профиля'); + } + } else { + $this->response->getResponse()->addError('Код недействителен'); + } + } else { + $this->response->getResponse()->addError('Код недействителен'); + } + } + } else { + $this->response->getResponse()->addError('Пользователь не найден'); + } + } + + public function validate(): bool + { + return $this->recoveryCodeDto->validate($this->response); + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/CheckRegisterCode.php b/app/src/Service/Action/Classes/CheckRegisterCode.php new file mode 100644 index 0000000..ed6f147 --- /dev/null +++ b/app/src/Service/Action/Classes/CheckRegisterCode.php @@ -0,0 +1,76 @@ +user = $security->getUser(); + parent::__construct($response); + } + + /** + * Подтверждение регистрации по коду + * + * @return void + */ + public function runAction(): void + { + $currentDate = new \DateTime(); + $code = $this->registerCodeDto->getClass()->code; + $registerCode = $this->user->getRegisterCode(); + if ($registerCode === null) { + $this->response->getResponse()->addError('Код подтверждения не отправлен'); + } else { + if ($registerCodeDate = $registerCode->getDate()) { + if ($registerCode->getCode() === $code && $currentDate->getTimestamp() < $registerCodeDate->getTimestamp()) { + try { + $this->user->setConfirm(true); + $em = $this->doctrine->getManager(); + $em->persist($this->user); + $em->remove($registerCode); + $em->flush(); + $this->response->getResponse()->addMessage('Регистрация подтверждена'); + } catch (\Exception $exception) { + $this->response->getResponse()->addError('Ошибка подтверждения регистрации'); + } + } else { + $this->response->getResponse()->addError('Код недействителен'); + } + } else { + $this->response->getResponse()->addError('Код недействителен'); + } + } + } + + public function validate(): bool + { + if ($this->user === null) { + $this->response->getResponse()->addError('Вы не авторизованы'); + return false; + } + return $this->registerCodeDto->validate($this->response); + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/DeleteProfile.php b/app/src/Service/Action/Classes/DeleteProfile.php new file mode 100644 index 0000000..01f042c --- /dev/null +++ b/app/src/Service/Action/Classes/DeleteProfile.php @@ -0,0 +1,56 @@ +user = $security->getUser(); + parent::__construct($response); + } + + /** + * Деактивация учетной записи + * + * @return void + */ + public function runAction(): void + { + try { + $this->user->setDeleted(true); + $em = $this->doctrine->getManager(); + $em->persist($this->user); + $em->flush(); + $this->response->getResponse()->addMessage('Профиль удален'); + } catch (\Exception $exception) { + $this->response->getResponse()->addError('Ошибка удаления профиля'); + } + } + + public function validate(): bool + { + if ($this->user === null) { + $this->response->getResponse()->addError('Вы не авторизованы'); + return false; + } + + if ($this->user->isDeleted()) { + $this->response->getResponse()->addError('Профиль уже удален'); + return false; + } + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/GetProfile.php b/app/src/Service/Action/Classes/GetProfile.php new file mode 100644 index 0000000..0c39923 --- /dev/null +++ b/app/src/Service/Action/Classes/GetProfile.php @@ -0,0 +1,44 @@ +user = $security->getUser(); + parent::__construct($response); + } + + + /** + * Получение профиля пользователя + * + * @return void + * + * @throws \JsonException + */ + public function runAction(): void + { + $serializedUser = $this->serializer->serialize($this->user, 'json', ['groups' => ['profile']]); + $this->response->getResponse()->setResponseData(json_decode($serializedUser, true, 512, JSON_THROW_ON_ERROR)); + } + + public function validate(): bool + { + return $this->user->isConfirm() && !$this->user->isDeleted(); + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/RecoveryProfile.php b/app/src/Service/Action/Classes/RecoveryProfile.php new file mode 100644 index 0000000..9cbf1c2 --- /dev/null +++ b/app/src/Service/Action/Classes/RecoveryProfile.php @@ -0,0 +1,60 @@ +recoveryDto->getClass(); + /** @var User $userExists */ + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($dto->email, $dto->phoneNumber); + + if ($userExists !== null) { + if (!$userExists->isDeleted()) { + $this->response->getResponse()->addError('Профиль не удален'); + } else { + $this->recoveryCodeSendService->setUser($userExists); + $this->recoveryCodeSendService->setResponse($this->response); + $this->recoveryCodeSendService->send(); + } + } else { + $this->response->getResponse()->addError('Пользователь не найден'); + } + } + + public function validate(): bool + { + return $this->recoveryDto->validate($this->response); + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/Register.php b/app/src/Service/Action/Classes/Register.php index f6ec013..88eebcd 100644 --- a/app/src/Service/Action/Classes/Register.php +++ b/app/src/Service/Action/Classes/Register.php @@ -7,6 +7,10 @@ use App\Service\Action\BaseActionService; use App\Service\Dto\Classes\RegisterDto; use App\Service\Dto\DtoServiceInterface; use App\Service\Response\ResponseServiceInterface; +use App\Service\Send\Classes\Code\RegisterCodeSendService; +use App\Service\Send\Classes\CodeSendService; +use App\Service\Send\SendService; +use App\Service\Send\SendServiceInterface; use Doctrine\Persistence\ManagerRegistry; use ReflectionClass; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -15,18 +19,20 @@ class Register extends BaseActionService { /** * @param RegisterDto $registerDto - * @param ResponseServiceInterface $profileResponse + * @param ResponseServiceInterface $response * @param UserPasswordHasherInterface $passwordHasher * @param ManagerRegistry $doctrine + * @param RegisterCodeSendService $registerCodeSendService */ public function __construct( private DtoServiceInterface $registerDto, - private ResponseServiceInterface $profileResponse, + private ResponseServiceInterface $response, private UserPasswordHasherInterface $passwordHasher, private ManagerRegistry $doctrine, + private SendServiceInterface $registerCodeSendService, ) { - parent::__construct($profileResponse); + parent::__construct($response); } /** @@ -42,7 +48,7 @@ class Register extends BaseActionService ->findOneByUniq($user->getEmail(), $user->getPhoneNumber()); if ($userExists) { - $this->profileResponse->getResponse()->addError('Пользователь уже существует'); + $this->response->getResponse()->addError('Пользователь уже существует'); } else { try { $user->setDeleted(false); @@ -57,9 +63,13 @@ class Register extends BaseActionService $em->persist($user); $em->flush(); - $this->profileResponse->getResponse()->addMessage('Пользователь зарегистрирован'); + $this->response->getResponse()->addMessage('Пользователь зарегистрирован'); + + $this->registerCodeSendService->setUser($user); + $this->registerCodeSendService->setResponse($this->response); + $this->registerCodeSendService->send(); } catch (\Exception $exception) { - $this->profileResponse->getResponse()->addError('Ошибка регистрации пользователя'); + $this->response->getResponse()->addError('Ошибка регистрации пользователя'); } } @@ -73,7 +83,7 @@ class Register extends BaseActionService */ public function validate(): bool { - return $this->registerDto->validate($this->profileResponse); + return $this->registerDto->validate($this->response); } /** @@ -101,7 +111,7 @@ class Register extends BaseActionService } } } else { - $this->profileResponse->getResponse()->addError('Ошибка получения данных'); + $this->response->getResponse()->addError('Ошибка получения данных'); } return $user; diff --git a/app/src/Service/Action/Classes/SendRegisterCode.php b/app/src/Service/Action/Classes/SendRegisterCode.php new file mode 100644 index 0000000..d6d5d4c --- /dev/null +++ b/app/src/Service/Action/Classes/SendRegisterCode.php @@ -0,0 +1,60 @@ +user = $security->getUser(); + parent::__construct($response); + } + + + /** + * Отправка кода подтверждения регистрации + * + * @return void + * + * @throws \JsonException + */ + public function runAction(): void + { + $this->registerCodeSendService->setUser($this->user); + $this->registerCodeSendService->setResponse($this->response); + $this->registerCodeSendService->send(); + } + + public function validate(): bool + { + if ($this->user === null) { + $this->response->getResponse()->addError('Вы не авторизованы'); + return false; + } + if ($this->user->isConfirm()) { + $this->response->getResponse()->addError('Учетная запись уже подтверждена'); + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Dto/BaseDto.php b/app/src/Service/Dto/BaseDto.php index 7cbf7f5..600c2be 100644 --- a/app/src/Service/Dto/BaseDto.php +++ b/app/src/Service/Dto/BaseDto.php @@ -36,17 +36,21 @@ abstract class BaseDto implements DtoServiceInterface public function getClass(): ?DtoServiceInterface { if ($this->request) { - $normalizer = new ObjectNormalizer( - null, - new CamelCaseToSnakeCaseNameConverter(), - null, - new ReflectionExtractor() - ); - $serializer = new Serializer( - [$normalizer, new DateTimeNormalizer()], - [new JsonEncoder()] - ); - return $serializer->deserialize($this->request->getContent(), static::class, 'json'); + try { + $normalizer = new ObjectNormalizer( + null, + new CamelCaseToSnakeCaseNameConverter(), + null, + new ReflectionExtractor() + ); + $serializer = new Serializer( + [$normalizer, new DateTimeNormalizer()], + [new JsonEncoder()] + ); + return $serializer->deserialize($this->request->getContent(), static::class, 'json'); + } catch (\Exception $exception) { + return null; + } } return null; @@ -81,13 +85,19 @@ abstract class BaseDto implements DtoServiceInterface $apiResponse = $response->getResponse(); $bValid = true; - $aErrors = $this->validator->validate($this->getClass()); - if (count($aErrors) > 0) { - foreach ($aErrors as $error) { - $apiResponse->addError($error->getMessage()); + if ($classObj = $this->getClass()) { + $aErrors = $this->validator->validate($classObj); + if (count($aErrors) > 0) { + foreach ($aErrors as $error) { + $apiResponse->addError($error->getMessage()); + } + $bValid = false; } + } else { + $apiResponse->addError("Данные не получены"); $bValid = false; } + return $bValid; } } \ No newline at end of file diff --git a/app/src/Service/Dto/Classes/RecoveryCodeDto.php b/app/src/Service/Dto/Classes/RecoveryCodeDto.php new file mode 100644 index 0000000..2e9757e --- /dev/null +++ b/app/src/Service/Dto/Classes/RecoveryCodeDto.php @@ -0,0 +1,31 @@ +response = new ApiResponse(); } diff --git a/app/src/Service/Response/Classes/ProfileResponse.php b/app/src/Service/Response/Classes/ProfileResponse.php deleted file mode 100644 index 384f3d0..0000000 --- a/app/src/Service/Response/Classes/ProfileResponse.php +++ /dev/null @@ -1,14 +0,0 @@ -Уважаемый {surname} {name} {patronymic} +
Ваш код для восстановления: {code}
+
Время действия кода: {time}
+ HTML; + } + +} \ No newline at end of file diff --git a/app/src/Service/Send/Classes/Code/RegisterCodeSendService.php b/app/src/Service/Send/Classes/Code/RegisterCodeSendService.php new file mode 100644 index 0000000..9371af7 --- /dev/null +++ b/app/src/Service/Send/Classes/Code/RegisterCodeSendService.php @@ -0,0 +1,22 @@ +Уважаемый {surname} {name} {patronymic} +
Ваш код для подтверждения: {code}
+
Время действия кода: {time}
+ HTML; + } +} \ No newline at end of file diff --git a/app/src/Service/Send/Classes/CodeSendService.php b/app/src/Service/Send/Classes/CodeSendService.php new file mode 100644 index 0000000..b35d49f --- /dev/null +++ b/app/src/Service/Send/Classes/CodeSendService.php @@ -0,0 +1,127 @@ +response = $response; + } + + public function setUser(?User $user): void + { + $this->user = $user; + } + + public function getSubject(): string + { + return ''; + } + + public function getBody(): string + { + return '{code}'; + } + + public function send(): void + { + if ($this->user === null) { + $this->response->getResponse()->addError('Письмо не отправлено, пользователь не получен'); + return; + } + $serializedUser = $this->serializer->serialize($this->user, 'json', ['groups' => ['profile']]); + $values = json_decode($serializedUser, true, 512, JSON_THROW_ON_ERROR) ?: []; + + $codeObj = $this->user->getRegisterCode(); + $code = null; + $time = null; + if ($codeObj === null) { + $codeObj = new UserCode(); + $codeObj->setRelatedUser($this->user); + } + + try { + $om = $this->doctrine->getManager(); + $om->persist($codeObj); + $om->flush(); + $code = $codeObj->getCode(); + $date = $codeObj->getDate(); + $time = $date?->diff(new \DateTime()); + } catch (\Exception $exception) { + $this->response->getResponse()->addError('Ошибка генерации кода'); + } + + if ($code) { + $values['code'] = $code; + $timeStr = 'нет'; + if ($time) { + $timeStr = $time->format('%H:%I:%S'); + } + $values['time'] = $timeStr; + $this->sendService->setTo($this->user->getEmail()); + $this->sendService->setSubject($this->formatSubject($values)); + $this->sendService->setBody($this->formatBody($values)); + $this->sendService->send(); + $this->response->getResponse()->addMessage('Письмо с кодом отправлено'); + } else { + $this->response->getResponse()->addError('Ошибка генерации кода'); + } + } + + /** + * Подстановка значений в письмо + * + * @param array $values + * + * @return string + */ + private function formatBody(array $values): string + { + $body = $this->getBody(); + + foreach ($values as $name => $value) { + $body = str_replace('{' . $name . '}', $value, $body); + } + + return $body; + } + + /** + * Подстановка значений в тему письма + * + * @param array $values + * + * @return string + */ + private function formatSubject(array $values): string + { + $subject = $this->getSubject(); + + foreach ($values as $name => $value) { + $subject = str_replace('{' . $name . '}', $value, $subject); + } + + return $subject; + } +} \ No newline at end of file diff --git a/app/src/Service/Send/SendService.php b/app/src/Service/Send/SendService.php new file mode 100644 index 0000000..eb1d5e3 --- /dev/null +++ b/app/src/Service/Send/SendService.php @@ -0,0 +1,86 @@ +from; + } + + public function setFrom(?string $from): self + { + $this->from = $from; + + return $this; + } + + public function getTo(): ?string + { + return $this->to; + } + + public function setTo(?string $to): self + { + $this->to = $to; + + return $this; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getBody(): ?string + { + return $this->body; + } + + public function setBody(?string $body): self + { + $this->body = $body; + + return $this; + } + + public function send(): void + { + try { + $this->bus->dispatch(new SendMessage( + $this->from ?: $this->fromEmail, + $this->to, + $this->subject, + $this->body, + $this->confirmType + )); + } catch (Throwable $e) { + dd($e); + } + } +} \ No newline at end of file diff --git a/app/src/Service/Send/SendServiceInterface.php b/app/src/Service/Send/SendServiceInterface.php new file mode 100644 index 0000000..a1b6ad8 --- /dev/null +++ b/app/src/Service/Send/SendServiceInterface.php @@ -0,0 +1,8 @@ +getObject(); + if ($oObject) { + if ($oObject->email !== null && $oObject->phoneNumber !== null) { + $context->buildViolation('Передайте либо Email либо номер телефона') + ->addViolation(); + } + } + } +} \ No newline at end of file diff --git a/app/symfony.lock b/app/symfony.lock index ff98b32..9ef8d81 100644 --- a/app/symfony.lock +++ b/app/symfony.lock @@ -102,6 +102,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/messenger": { + "version": "7.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, "symfony/routing": { "version": "7.0", "recipe": { diff --git a/compose.yaml b/compose.yaml index 0b9b0e8..a537a6c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -74,7 +74,7 @@ services: image: redis:6.2-alpine restart: unless-stopped ports: - - '6379:6379' + - ${REDIS_PORT}:${REDIS_PORT} command: redis-server --save 20 1 --loglevel warning volumes: - redis:/data @@ -82,23 +82,25 @@ services: zookeeper: container_name: ${CONTAINER_NAME}-zookeeper image: confluentinc/cp-zookeeper:latest + restart: unless-stopped environment: - ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_CLIENT_PORT: ${ZOOKEEPER_CLIENT_PORT} ZOOKEEPER_TICK_TIME: 2000 ports: - - 22181:2181 + - ${ZOOKEEPER_PORT}:${ZOOKEEPER_CLIENT_PORT} kafka: container_name: ${CONTAINER_NAME}-kafka image: confluentinc/cp-kafka:latest + restart: unless-stopped depends_on: - zookeeper ports: - - 29092:29092 + - ${KAFKA_PORT}:${KAFKA_PORT} environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_BROKER_ID: ${KAFKA_BROKER_ID} + KAFKA_ZOOKEEPER_CONNECT: zookeeper:${ZOOKEEPER_CLIENT_PORT} + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:${KAFKA_PORT} KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index bd634f2..985283e 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -43,6 +43,7 @@ RUN apk add --no-cache --virtual .build-deps \ libzip-dev \ icu-dev \ postgresql-dev \ + librdkafka-dev \ # PHP Extensions --------------------------------- \ && curl -sSLf \ -o /usr/local/bin/install-php-extensions \ @@ -57,6 +58,7 @@ RUN apk add --no-cache --virtual .build-deps \ pdo_pgsql \ # Pecl Extensions --------------------------------- \ && pecl install apcu && docker-php-ext-enable apcu \ + && pecl install rdkafka && docker-php-ext-enable rdkafka \ # --------------------------------------------------\ # Install Xdebug at this step to make editing dev image cache-friendly, we delete xdebug from production image later \ && pecl install xdebug-${XDEBUG_VERSION} \ -- GitLab