diff --git a/.env.example b/.env.example index 07265fe4dcd6f9bb9a94c331010f0d81a409687e..6cbde90a1e2c9766972ae24ee7f6f06b039ab9f0 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/.gitignore b/.gitignore index d38a7f5e9384c618f672fba97a3324e893387b05..647b7ba36d716e1409643ab5aeec8083e30d8a30 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /.vscode/ .env +/app/public/uploads/ diff --git a/Makefile b/Makefile index dcbbadc7ca2cdb2d8c86ce05baf2c2687f3b2e0e..725778cfc2d1155d74a561b4c0e80e819a5290b2 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/README.md b/README.md index a99a2a1a27eb623e0ddd8d1dc6f8e9ad995da46f..362f2960e4d28895dde5c1c00ddbf88f7ef11f76 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,21 @@ ## Инструкция +## Kafka +
+Инструкция + +1. Создание топика + 1. Запустить команду - `make create-kafka-topic` +2. Запуск воркера + 1. Перейти в командную строку php - `make shell` + 2. Запустить команду - `bin/console messenger:consume send_transport` +3. Остановка воркера + 1. Перейти в командную строку php - `make shell` + 2. Запустить команду - `bin/console messenger:stop-workers` + +
+ ## Настройка Xdebug (PHPStorm)
Инструкция diff --git a/app/.env.example b/app/.env.example index c9363a772f9bcafcb564ac85d4696168ad3c9e39..1b35983752ef0ee1a788f1fa5196bf5171eea22b 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 2677425c6e5bbdf9cab641d1d05c699eb18e036e..804133de31d0d2376b8fa6c92f68a5c9d260c9b2 100644 --- a/app/composer.json +++ b/app/composer.json @@ -7,11 +7,14 @@ "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", + "ext-rdkafka": "*", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.12", "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.2", "lexik/jwt-authentication-bundle": "^3.0", + "nelmio/api-doc-bundle": "^4.27", + "symfony/asset": "7.0.*", "symfony/cache": "7.0.*", "symfony/console": "7.0.*", "symfony/dotenv": "7.0.*", @@ -19,12 +22,16 @@ "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.*", "symfony/serializer": "7.0.*", + "symfony/twig-bundle": "7.0.*", "symfony/validator": "7.0.*", - "symfony/yaml": "7.0.*" + "symfony/yaml": "7.0.*", + "twig/extra-bundle": "^2.12|^3.0", + "twig/twig": "^2.12|^3.0" }, "require-dev": { "roave/security-advisories": "dev-latest", diff --git a/app/composer.lock b/app/composer.lock index 0e49da842a6f2aad1e3a634a007bd11a00a01ce1..df4798038f84eca925af6362f3e88f58e2452ed3 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": "a47e42ecd6c25174dcfe545065022162", "packages": [ { "name": "doctrine/cache", @@ -1548,6 +1548,344 @@ ], "time": "2024-05-05T17:49:24+00:00" }, + { + "name": "nelmio/api-doc-bundle", + "version": "v4.27.0", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioApiDocBundle.git", + "reference": "221a1febaf861435b51c80cffd1a78efb4168345" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/221a1febaf861435b51c80cffd1a78efb4168345", + "reference": "221a1febaf861435b51c80cffd1a78efb4168345", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.4", + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0", + "phpdocumentor/type-resolver": "^1.8.2", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/framework-bundle": "^5.4.24 || ^6.4 || ^7.0", + "symfony/http-foundation": "^5.4 || ^6.4 || ^7.0", + "symfony/http-kernel": "^5.4 || ^6.4 || ^7.0", + "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", + "symfony/property-info": "^5.4.10 || ^6.4 || ^7.0", + "symfony/routing": "^5.4 || ^6.4 || ^7.0", + "zircote/swagger-php": "^4.6.1" + }, + "conflict": { + "zircote/swagger-php": "4.8.7" + }, + "require-dev": { + "api-platform/core": "^2.7.0 || ^3", + "composer/package-versions-deprecated": "1.11.99.1", + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.52", + "friendsofsymfony/rest-bundle": "^2.8 || ^3.0", + "jms/serializer": "^1.14 || ^3.0", + "jms/serializer-bundle": "^2.3 || ^3.0 || ^4.0 || ^5.0", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-strict-rules": "^1.5", + "phpstan/phpstan-symfony": "^1.3", + "phpunit/phpunit": "^9.6 || ^10.5", + "symfony/asset": "^5.4 || ^6.4 || ^7.0", + "symfony/browser-kit": "^5.4 || ^6.4 || ^7.0", + "symfony/cache": "^5.4 || ^6.4 || ^7.0", + "symfony/dom-crawler": "^5.4 || ^6.4 || ^7.0", + "symfony/expression-language": "^5.4 || ^6.4 || ^7.0", + "symfony/form": "^5.4 || ^6.4 || ^7.0", + "symfony/phpunit-bridge": "^6.4", + "symfony/property-access": "^5.4 || ^6.4 || ^7.0", + "symfony/security-csrf": "^5.4 || ^6.4 || ^7.0", + "symfony/serializer": "^5.4 || ^6.4 || ^7.0", + "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0", + "symfony/templating": "^5.4 || ^6.4 || ^7.0", + "symfony/twig-bundle": "^5.4 || ^6.4 || ^7.0", + "symfony/uid": "^5.4 || ^6.4 || ^7.0", + "symfony/validator": "^5.4 || ^6.4 || ^7.0", + "willdurand/hateoas-bundle": "^1.0 || ^2.0" + }, + "suggest": { + "api-platform/core": "For using an API oriented framework.", + "doctrine/annotations": "For using doctrine annotations", + "friendsofsymfony/rest-bundle": "For using the parameters annotations.", + "jms/serializer-bundle": "For describing your models.", + "symfony/asset": "For using the Swagger UI.", + "symfony/cache": "For using a PSR-6 compatible cache implementation with the API doc generator.", + "symfony/form": "For describing your form type models.", + "symfony/monolog-bundle": "For using a PSR-3 compatible logger implementation with the API PHP describer.", + "symfony/security-csrf": "For using csrf protection tokens in forms.", + "symfony/serializer": "For describing your models.", + "symfony/twig-bundle": "For using the Swagger UI.", + "symfony/validator": "For describing the validation constraints in your models.", + "willdurand/hateoas-bundle": "For extracting HATEOAS metadata." + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\ApiDocBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioApiDocBundle/contributors" + } + ], + "description": "Generates documentation for your REST API from annotations and attributes", + "keywords": [ + "api", + "doc", + "documentation", + "rest" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.27.0" + }, + "time": "2024-06-12T23:47:19+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.4.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c", + "reference": "9d07b3f7fdcf5efec5d1609cba3c19c5ea2bdc9c", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.5", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.4.1" + }, + "time": "2024-05-21T05:55:05+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "153ae662783729388a584b4361f2545e4d841e3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/153ae662783729388a584b4361f2545e4d841e3c", + "reference": "153ae662783729388a584b4361f2545e4d841e3c", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.13" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.8.2" + }, + "time": "2024-02-23T11:10:43+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "1.29.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fcaefacf2d5c417e928405b71b400d4ce10daaf4", + "reference": "fcaefacf2d5c417e928405b71b400d4ce10daaf4", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^4.15", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.29.1" + }, + "time": "2024-05-31T08:52:43+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -1798,6 +2136,75 @@ }, "time": "2021-07-14T16:46:02+00:00" }, + { + "name": "symfony/asset", + "version": "v7.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset.git", + "reference": "0f106714bb8d857560edd2ada7f387d2f437c830" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset/zipball/0f106714bb8d857560edd2ada7f387d2f437c830", + "reference": "0f106714bb8d857560edd2ada7f387d2f437c830", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/http-foundation": "<6.4" + }, + "require-dev": { + "symfony/http-client": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Asset\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset/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/cache", "version": "v7.0.8", @@ -3382,31 +3789,117 @@ "time": "2024-05-31T14:55:39+00:00" }, { - "name": "symfony/mime", + "name": "symfony/messenger", "version": "v7.0.8", "source": { "type": "git", - "url": "https://github.com/symfony/mime.git", - "reference": "3426d1e95f432c82ceef57e9943383116800f406" + "url": "https://github.com/symfony/messenger.git", + "reference": "ed7bccfe31e7f0bdb5b101f48b6027622a7a48cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/3426d1e95f432c82ceef57e9943383116800f406", - "reference": "3426d1e95f432c82ceef57e9943383116800f406", + "url": "https://api.github.com/repos/symfony/messenger/zipball/ed7bccfe31e7f0bdb5b101f48b6027622a7a48cb", + "reference": "ed7bccfe31e7f0bdb5b101f48b6027622a7a48cb", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/polyfill-intl-idn": "^1.10", - "symfony/polyfill-mbstring": "^1.0" + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0" }, "conflict": { - "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4" - }, + "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", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "3426d1e95f432c82ceef57e9943383116800f406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/3426d1e95f432c82ceef57e9943383116800f406", + "reference": "3426d1e95f432c82ceef57e9943383116800f406", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4" + }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", @@ -3465,6 +3958,73 @@ ], "time": "2024-06-02T15:49:03+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v7.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "19eecfc6f1b0e4b093db7f4a71eedc91843e711a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/19eecfc6f1b0e4b093db7f4a71eedc91843e711a", + "reference": "19eecfc6f1b0e4b093db7f4a71eedc91843e711a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/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/password-hasher", "version": "v7.0.8", @@ -5010,6 +5570,198 @@ ], "time": "2024-04-18T09:32:20+00:00" }, + { + "name": "symfony/twig-bridge", + "version": "v7.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "c8e05d7545962198df715d705c132de0674dc5b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/c8e05d7545962198df715d705c132de0674dc5b2", + "reference": "c8e05d7545962198df715d705c132de0674dc5b2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.0.4" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/console": "<6.4", + "symfony/form": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.4", + "symfony/serializer": "<6.4", + "symfony/translation": "<6.4", + "symfony/workflow": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/asset": "^6.4|^7.0", + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/html-sanitizer": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^6.4|^7.0", + "symfony/security-csrf": "^6.4|^7.0", + "symfony/security-http": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0", + "symfony/workflow": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", + "twig/cssinliner-extra": "^2.12|^3", + "twig/inky-extra": "^2.12|^3", + "twig/markdown-extra": "^2.12|^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/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/twig-bundle", + "version": "v7.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bundle.git", + "reference": "a90e474bc260e59bed98a556db63673e6420a0be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/a90e474bc260e59bed98a556db63673e6420a0be", + "reference": "a90e474bc260e59bed98a556db63673e6420a0be", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.2", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0", + "twig/twig": "^3.0.4" + }, + "conflict": { + "symfony/framework-bundle": "<6.4", + "symfony/translation": "<6.4" + }, + "require-dev": { + "symfony/asset": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\TwigBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of Twig into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bundle/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/validator", "version": "v7.0.8", @@ -5334,6 +6086,298 @@ } ], "time": "2024-04-28T11:44:19+00:00" + }, + { + "name": "twig/extra-bundle", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/twigphp/twig-extra-bundle.git", + "reference": "cdc6e23aeb7f4953c1039568c3439aab60c56454" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/cdc6e23aeb7f4953c1039568c3439aab60c56454", + "reference": "cdc6e23aeb7f4953c1039568c3439aab60c56454", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/framework-bundle": "^5.4|^6.4|^7.0", + "symfony/twig-bundle": "^5.4|^6.4|^7.0", + "twig/twig": "^3.0" + }, + "require-dev": { + "league/commonmark": "^1.0|^2.0", + "symfony/phpunit-bridge": "^6.4|^7.0", + "twig/cache-extra": "^3.0", + "twig/cssinliner-extra": "^3.0", + "twig/html-extra": "^3.0", + "twig/inky-extra": "^3.0", + "twig/intl-extra": "^3.0", + "twig/markdown-extra": "^3.0", + "twig/string-extra": "^3.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Twig\\Extra\\TwigExtraBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Symfony bundle for extra Twig extensions", + "homepage": "https://twig.symfony.com", + "keywords": [ + "bundle", + "extra", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2024-05-11T07:35:57+00:00" + }, + { + "name": "twig/twig", + "version": "v3.10.3", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "67f29781ffafa520b0bbfbd8384674b42db04572" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/67f29781ffafa520b0bbfbd8384674b42db04572", + "reference": "67f29781ffafa520b0bbfbd8384674b42db04572", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3", + "symfony/polyfill-php80": "^1.22" + }, + "require-dev": { + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.10.3" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2024-05-16T10:04:27+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "4.10.0", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "2d983ce67b9eb7e18403ae7bc5e765f8ce7b8d56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/2d983ce67b9eb7e18403ae7bc5e765f8ce7b8d56", + "reference": "2d983ce67b9eb7e18403ae7bc5e765f8ce7b8d56", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.2", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2 || ^3", + "symfony/finder": ">=2.2", + "symfony/yaml": ">=3.3" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.11", + "doctrine/annotations": "^1.7 || ^2.0", + "friendsofphp/php-cs-fixer": "^2.17 || ^3.47.1", + "phpstan/phpstan": "^1.6", + "phpunit/phpunit": ">=8", + "vimeo/psalm": "^4.23" + }, + "suggest": { + "doctrine/annotations": "^1.7 || ^2.0" + }, + "bin": [ + "bin/openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Robert Allen", + "email": "zircote@gmail.com" + }, + { + "name": "Bob Fanger", + "email": "bfanger@gmail.com", + "homepage": "https://bfanger.nl" + }, + { + "name": "Martin Rademacher", + "email": "mano@radebatz.net", + "homepage": "https://radebatz.net" + } + ], + "description": "swagger-php - Generate interactive documentation for your RESTful API using phpdoc annotations", + "homepage": "https://github.com/zircote/swagger-php/", + "keywords": [ + "api", + "json", + "rest", + "service discovery" + ], + "support": { + "issues": "https://github.com/zircote/swagger-php/issues", + "source": "https://github.com/zircote/swagger-php/tree/4.10.0" + }, + "time": "2024-06-06T22:42:02+00:00" } ], "packages-dev": [ @@ -6351,7 +7395,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/bundles.php b/app/config/bundles.php index afcea0bad7528ed2a2933019a4f535cbbb5a1d40..386a29b1da7b000598112d980c463d18ef32bcfe 100644 --- a/app/config/bundles.php +++ b/app/config/bundles.php @@ -7,4 +7,7 @@ return [ Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], + Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], ]; diff --git a/app/config/packages/framework.yaml b/app/config/packages/framework.yaml index 877eb25d15da47921c07414c3b5d3a333cf889ff..5fc005d7ab3a10bce302d9fc9fe5ca22f2223711 100644 --- a/app/config/packages/framework.yaml +++ b/app/config/packages/framework.yaml @@ -8,6 +8,8 @@ framework: #esi: true #fragments: true + serializer: + name_converter: serializer.name_converter.camel_case_to_snake_case when@test: framework: diff --git a/app/config/packages/lexik_jwt_authentication.yaml b/app/config/packages/lexik_jwt_authentication.yaml index edfb69dc8975a1f4fc09a0ca5c6ed4bcde6eb695..dd7a3aa5057695d211934700ec13353408f44273 100644 --- a/app/config/packages/lexik_jwt_authentication.yaml +++ b/app/config/packages/lexik_jwt_authentication.yaml @@ -2,3 +2,4 @@ lexik_jwt_authentication: secret_key: '%env(resolve:JWT_SECRET_KEY)%' public_key: '%env(resolve:JWT_PUBLIC_KEY)%' pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: 3600 diff --git a/app/config/packages/messenger.yaml b/app/config/packages/messenger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6dc7da12750522c0d51bb45032c65c70d8f51bca --- /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/nelmio_api_doc.yaml b/app/config/packages/nelmio_api_doc.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0e7eb67b44426a86ed45bed7fe13ba00662caed5 --- /dev/null +++ b/app/config/packages/nelmio_api_doc.yaml @@ -0,0 +1,21 @@ +nelmio_api_doc: + use_validation_groups: true + documentation: + servers: + - url: http://127.0.0.1:8080 + description: Localhost + info: + title: Квесты + description: Тестовое задание + version: 1.0.0 + components: + securitySchemes: + Bearer: + type: http + scheme: bearer + bearerFormat: JWT + security: + - Bearer: [ ] + areas: # to filter documented areas + path_patterns: + - ^/api(?!/doc$) # Accepts routes under /api except /api/doc diff --git a/app/config/packages/security.yaml b/app/config/packages/security.yaml index 367af25a56d2decba5041becc5770b16c91b60d4..ffde54bbe1acd25dc1c8a6462b46234216df4561 100644 --- a/app/config/packages/security.yaml +++ b/app/config/packages/security.yaml @@ -4,14 +4,34 @@ security: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email firewalls: + login: + pattern: ^/api/login + stateless: true + json_login: + check_path: /api/login + username_path: email + password_path: password + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + + api: + pattern: ^/api + stateless: true + jwt: ~ + dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider + access_denied_handler: App\Listeners\AccessDeniedListener # activate different ways to authenticate # https://symfony.com/doc/current/security.html#the-firewall @@ -22,6 +42,24 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: + - { path: ^/api/login, roles: PUBLIC_ACCESS } + - { path: ^/api/doc, 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/password/reset/check, roles: PUBLIC_ACCESS } + - { path: ^/api/password/reset, roles: ROLE_USER } + - { path: ^/api/password/send, roles: PUBLIC_ACCESS } + + - { path: ^/api/profile/recovery, roles: PUBLIC_ACCESS } + - { path: ^/api/profile/recovery/check, roles: PUBLIC_ACCESS } + - { path: ^/api/profile/reset/email, roles: ROLE_USER } + - { path: ^/api/profile/reset/field, roles: ROLE_USER } + - { path: ^/api/profile/change, roles: ROLE_USER } + - { path: ^/api/profile, roles: ROLE_USER } + - { path: ^/api, roles: ROLE_CONFIRMED } # - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/profile, roles: ROLE_USER } diff --git a/app/config/packages/twig.yaml b/app/config/packages/twig.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3f795d921eae0f2961c18624a1ea9bdfeaca11d4 --- /dev/null +++ b/app/config/packages/twig.yaml @@ -0,0 +1,6 @@ +twig: + file_name_pattern: '*.twig' + +when@test: + twig: + strict_variables: true diff --git a/app/config/routes.yaml b/app/config/routes.yaml index 41ef8140ba811c0da46ce1962ffa50d4c56b9840..dbc41f6e7c5ebdf66cc64ce14bfafd7c59889a27 100644 --- a/app/config/routes.yaml +++ b/app/config/routes.yaml @@ -3,3 +3,6 @@ controllers: path: ../src/Controller/ namespace: App\Controller type: attribute + +api_login: + path: /api/login \ No newline at end of file diff --git a/app/config/routes/nelmio_api_doc.yaml b/app/config/routes/nelmio_api_doc.yaml new file mode 100644 index 0000000000000000000000000000000000000000..e160911540137a79a92dc5fd112312910038cb93 --- /dev/null +++ b/app/config/routes/nelmio_api_doc.yaml @@ -0,0 +1,12 @@ +# Expose your documentation as JSON swagger compliant +app.swagger: + path: /api/doc.json + methods: GET + defaults: { _controller: nelmio_api_doc.controller.swagger } + +## Requires the Asset component and the Twig bundle +## $ composer require twig asset +app.swagger_ui: + path: /api/doc + methods: GET + defaults: { _controller: nelmio_api_doc.controller.swagger_ui } \ No newline at end of file diff --git a/app/config/services.yaml b/app/config/services.yaml index 2d6a76f94dce138741e2d63ae83a11c1879031d9..02884eb17e3bae4741b0165e463a6f60db3e9f74 100644 --- a/app/config/services.yaml +++ b/app/config/services.yaml @@ -4,6 +4,11 @@ # 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)%' + # Директория сохранения файлов + images_directory: '%kernel.project_dir%/public/uploads/user_images' services: # default configuration for services in *this* file @@ -20,5 +25,43 @@ services: - '../src/Entity/' - '../src/Kernel.php' - # add more service definitions when explicit configuration is needed - # please note that last definitions always *replace* previous ones + App\Service\Action\Classes\SaveImage: + arguments: + $targetDirectory: '%images_directory%' + + + # Сервис отправки + App\Service\Send\SendService: + arguments: + $confirmType: '%confirm_type%' + $fromEmail: '%from_email%' + + App\Listeners\KernelExceptionListener: + tags: + - { name: kernel.event_listener, event: kernel.exception } + + # События JWT авторизации + acme_api.event.authentication_success_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_authentication_success, method: onAuthenticationSuccessResponse } + + acme_api.event.authentication_failure_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_authentication_failure, method: onAuthenticationFailureResponse } + + acme_api.event.jwt_invalid_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_invalid, method: onJWTInvalid } + + acme_api.event.jwt_notfound_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_not_found, method: onJWTNotFound } + + acme_api.event.jwt_expired_listener: + class: App\Listeners\JwtListener + tags: + - { name: kernel.event_listener, event: lexik_jwt_authentication.on_jwt_expired, method: onJWTExpired } \ No newline at end of file diff --git a/app/migrations/Version20240607055116.php b/app/migrations/Version20240607055116.php new file mode 100644 index 0000000000000000000000000000000000000000..9baa894a7483f5a9f51204a71707c85e1710eb80 --- /dev/null +++ b/app/migrations/Version20240607055116.php @@ -0,0 +1,42 @@ +addSql('CREATE SEQUENCE "user_id_seq" INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE user_image_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE "user" (id INT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, surname VARCHAR(255) NOT NULL, patronymic VARCHAR(255) NOT NULL, phone_number VARCHAR(255) DEFAULT NULL, confirm BOOLEAN NOT NULL, deleted BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON "user" (email)'); + $this->addSql('CREATE TABLE user_image (id INT NOT NULL, related_user_id INT DEFAULT NULL, path VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_27FFFF0798771930 ON user_image (related_user_id)'); + $this->addSql('ALTER TABLE user_image ADD CONSTRAINT FK_27FFFF0798771930 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_id_seq" CASCADE'); + $this->addSql('DROP SEQUENCE user_image_id_seq CASCADE'); + $this->addSql('ALTER TABLE user_image DROP CONSTRAINT FK_27FFFF0798771930'); + $this->addSql('DROP TABLE "user"'); + $this->addSql('DROP TABLE user_image'); + } +} diff --git a/app/migrations/Version20240614064759.php b/app/migrations/Version20240614064759.php new file mode 100644 index 0000000000000000000000000000000000000000..1bf93460eff3506b9b772496d48e225c9d00e35b --- /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/migrations/Version20240620091453.php b/app/migrations/Version20240620091453.php new file mode 100644 index 0000000000000000000000000000000000000000..0cbe709689d044264d857fde7e1a195a7396ba00 --- /dev/null +++ b/app/migrations/Version20240620091453.php @@ -0,0 +1,37 @@ +addSql('CREATE SEQUENCE user_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE user_history (id INT NOT NULL, related_user_id INT NOT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, field VARCHAR(255) DEFAULT NULL, type VARCHAR(255) NOT NULL, value VARCHAR(255) DEFAULT NULL, old_value VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_7FB76E4198771930 ON user_history (related_user_id)'); + $this->addSql('ALTER TABLE user_history ADD CONSTRAINT FK_7FB76E4198771930 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_history_id_seq CASCADE'); + $this->addSql('ALTER TABLE user_history DROP CONSTRAINT FK_7FB76E4198771930'); + $this->addSql('DROP TABLE user_history'); + } +} diff --git a/app/migrations/Version20240620121602.php b/app/migrations/Version20240620121602.php new file mode 100644 index 0000000000000000000000000000000000000000..56d05ee180b5c4def1aaafda8bc41e64e4a2268c --- /dev/null +++ b/app/migrations/Version20240620121602.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE "user" ALTER patronymic DROP NOT NULL'); + } + + 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('ALTER TABLE "user" ALTER patronymic SET NOT NULL'); + } +} diff --git a/app/src/Controller/AuthController.php b/app/src/Controller/AuthController.php new file mode 100644 index 0000000000000000000000000000000000000000..cc257a82f2799eadc67f039c17c7b204cbce74fa --- /dev/null +++ b/app/src/Controller/AuthController.php @@ -0,0 +1,98 @@ +getResponse(); + } + + #[Route('/register/send', name: 'register_send', methods: ['GET'])] + public function sendRegisterCode( + #[Autowire(service: 'action.register.send')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/register/check', name: 'register_check', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: RegisterCodeDto::class)) + )] + public function checkRegisterCode( + #[Autowire(service: 'action.register.code')] + ActionServiceInterface $actionService + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/password/reset', name: 'password_reset', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: ChangePasswordDto::class)) + )] + public function resetPassword( + #[Autowire(service: 'action.reset.password.change')] + ActionServiceInterface $actionService + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/password/send', name: 'password_send', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: RecoveryDto::class)) + )] + public function sendResetPassword( + #[Autowire(service: 'action.reset.password.send')] + ActionServiceInterface $actionService + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/password/reset/check', name: 'password_reset_check', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: ResetPasswordCodeDto::class)) + )] + public function resetCheckPassword( + #[Autowire(service: 'action.reset.password.code')] + ActionServiceInterface $actionService + ): JsonResponse + { + return $actionService->getResponse(); + } +} diff --git a/app/src/Controller/ProfileController.php b/app/src/Controller/ProfileController.php new file mode 100644 index 0000000000000000000000000000000000000000..2f9448814b83803201199e561c3edde09a81374d --- /dev/null +++ b/app/src/Controller/ProfileController.php @@ -0,0 +1,162 @@ +getResponse(); + } + + #[Route('/profile/delete', name: 'profile_delete', methods: ['GET'])] + #[OA\Response( + response: 200, + description: 'Ответ', + content: new OA\JsonContent( + ref: new Model(type: Response::class, groups: ["message"]) + ) + )] + public function deleteProfile( + #[Autowire(service: 'action.profile.delete')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/profile/recovery', name: 'profile_recovery', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: RecoveryDto::class)) + )] + #[OA\Response( + response: 200, + description: 'Ответ', + content: new OA\JsonContent( + ref: new Model(type: Response::class, groups: ["message"]) + ) + )] + public function recoveryProfile( + #[Autowire(service: 'action.recovery.send')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/profile/recovery/check', name: 'profile_recovery_check', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: RecoveryCodeDto::class)) + )] + #[OA\Response( + response: 200, + description: 'Ответ', + content: new OA\JsonContent( + ref: new Model(type: Response::class, groups: ["message"]) + ) + )] + public function recoveryCodeProfile( + #[Autowire(service: 'action.recovery.code')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/profile/change', name: 'profile_change', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: ChangeProfileDto::class)) + )] + #[OA\Response( + response: 200, + description: 'Ответ', + content: new OA\JsonContent( + ref: new Model(type: ProfileResponse::class, groups: ["message", "data", "profile"]) + ) + )] + public function changeProfile( + #[Autowire(service: 'action.profile.change')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/profile/reset/email', name: 'profile_reset_email', methods: ['GET'])] + #[OA\Response( + response: 200, + description: 'Ответ', + content: new OA\JsonContent( + ref: new Model(type: Response::class, groups: ["message"]) + ) + )] + public function resetLastConfirmEmail( + #[Autowire(service: 'action.reset.email')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/profile/image', name: 'profile_image', methods: ['POST'])] + #[OA\RequestBody( + content: new OA\JsonContent(ref: new Model(type: ImageDto::class)) + )] + #[OA\Response( + response: 200, + description: 'Ответ', + content: new OA\JsonContent( + ref: new Model(type: Response::class, groups: ["message"]) + ) + )] + public function saveImage( + #[Autowire(service: 'action.profile.image.save')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } + + #[Route('/profile/image/delete', name: 'profile_image_delete', methods: ['GET'])] + #[OA\Response( + response: 200, + description: 'Ответ', + content: new OA\JsonContent( + ref: new Model(type: Response::class, groups: ["message"]) + ) + )] + public function deleteImage( + #[Autowire(service: 'action.profile.image.delete')] + ActionServiceInterface $actionService, + ): JsonResponse + { + return $actionService->getResponse(); + } +} diff --git a/app/src/Entity/User.php b/app/src/Entity/User.php new file mode 100644 index 0000000000000000000000000000000000000000..348b043e42771781be5de044820ca04043e80c65 --- /dev/null +++ b/app/src/Entity/User.php @@ -0,0 +1,396 @@ + The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private ?string $password = null; + + #[ORM\Column(length: 255)] + private ?string $name = null; + + #[ORM\Column(length: 255)] + private ?string $surname = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $patronymic = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $phone_number = null; + + #[ORM\OneToOne(mappedBy: 'related_user', cascade: ['persist', 'remove'])] + private ?UserImage $image = null; + + #[ORM\Column] + private ?bool $confirm = null; + + #[ORM\Column] + private ?bool $deleted = null; + + #[ORM\OneToOne(mappedBy: 'related_user', cascade: ['persist', 'remove'])] + private ?UserCode $register_code = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: UserHistory::class, mappedBy: 'related_user', cascade: ['persist', 'remove'],fetch: 'EAGER')] + private Collection $userHistories; + + public function __construct() + { + $this->userHistories = new ArrayCollection(); + } + + #[Groups(['all'])] + public function getId(): ?int + { + return $this->id; + } + + #[Groups(['all', 'profile', 'edit', 'card', 'detail', 'listen'])] + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + #[Ignore] + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + * + * @return list + */ + #[Groups(['all'])] + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + if ($this->isDeleted()) { + $roles[] = 'ROLE_DELETED'; + } else if ($this->isConfirm()) { + $roles[] = 'ROLE_CONFIRMED'; + } else { + $roles[] = 'ROLE_NOT_CONFIRMED'; + } + + return array_unique($roles); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + #[Groups(['all', 'listen'])] + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + #[Groups(['all', 'profile', 'edit', 'listen'])] + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + #[Groups(['all', 'profile', 'edit', 'listen'])] + public function getSurname(): ?string + { + return $this->surname; + } + + public function setSurname(string $surname): static + { + $this->surname = $surname; + + return $this; + } + + #[Groups(['all', 'profile', 'edit', 'listen'])] + public function getPatronymic(): ?string + { + return $this->patronymic; + } + + public function setPatronymic(?string $patronymic): static + { + $this->patronymic = $patronymic; + + return $this; + } + + #[Groups(['all', 'profile', 'edit', 'listen'])] + public function getPhoneNumber(): ?string + { + return $this->phone_number; + } + + public function setPhoneNumber(?string $phone_number): static + { + $this->phone_number = $phone_number; + + return $this; + } + + #[Groups(['all', 'profile', 'edit', 'listen'])] + public function getImage(): ?UserImage + { + return $this->image; + } + + public function setImage(?UserImage $image): static + { + // unset the owning side of the relation if necessary + if ($image === null && $this->image !== null) { + $this->image->setRelatedUser(null); + } + + // set the owning side of the relation if necessary + if ($image !== null && $image->getRelatedUser() !== $this) { + $image->setRelatedUser($this); + } + + $this->image = $image; + + return $this; + } + + #[Groups(['all', 'profile', 'edit', 'listen'])] + public function isConfirm(): ?bool + { + return $this->confirm; + } + + public function setConfirm(bool $confirm): static + { + $this->confirm = $confirm; + + return $this; + } + + #[Groups(['all', 'profile', 'edit', 'listen'])] + public function isDeleted(): ?bool + { + return $this->deleted; + } + + public function setDeleted(bool $deleted): static + { + $this->deleted = $deleted; + + 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; + } + + /** + * @return Collection + */ + #[Groups(['all', 'profile'])] + public function getUserHistories(): Collection + { + return $this->userHistories; + } + + public function addUserHistory(UserHistory $userHistory): static + { + if (!$this->userHistories->contains($userHistory)) { + $this->userHistories->add($userHistory); + $userHistory->setRelatedUser($this); + } + + return $this; + } + + public function removeUserHistory(UserHistory $userHistory): static + { + if ($this->userHistories->removeElement($userHistory)) { + // set the owning side to null (unless already changed) + if ($userHistory->getRelatedUser() === $this) { + $userHistory->setRelatedUser(null); + } + } + + return $this; + } + + #[Groups(['all', 'profile'])] + public function getLastConfirmEmail(): ?string + { + $lastDate = null; + $lastEmail = null; + foreach ($this->getUserHistories() as $userHistory) { + if ($userHistory->getField() === 'confirm' && $userHistory->getDate() && $userHistory->getType() === UserHistory::TYPE_CREATE) { + if ($lastDate === null || $lastDate->getTimestamp() < $userHistory->getDate()->getTimestamp()) { + $lastDate = $userHistory->getDate(); + $lastEmail = $userHistory->getValue(); + } + } + } + + return $lastEmail; + } + + /** + * Создание пользователя по массиву + * + * @param array $data + * + * @return self + */ + #[Ignore()] + public static function createByArray(array $data, array $groups = ['listen']): ?self + { + try { + $normalizer = new ObjectNormalizer( + new ClassMetadataFactory(new AttributeLoader()), + new CamelCaseToSnakeCaseNameConverter(), + null, + new ReflectionExtractor() + ); + $serializer = new Serializer( + [new DateTimeNormalizer(), $normalizer], + [new JsonEncoder()] + ); + return $serializer->deserialize( + json_encode($data, JSON_THROW_ON_ERROR), + __CLASS__, + 'json', + [ + 'groups' => $groups, + ] + ); + } catch (\Exception $exception) { + return null; + } + } + + #[Ignore()] + public function newCopy(array $groups = ['listen']): ?self + { + $normalizer = new ObjectNormalizer( + new ClassMetadataFactory(new AttributeLoader()), + new CamelCaseToSnakeCaseNameConverter(), + null, + new ReflectionExtractor() + ); + $serializer = new Serializer([new DateTimeNormalizer(), $normalizer], [new JsonEncoder()]); + $data = $serializer->serialize($this, 'json', ['groups' => $groups]); + $array = json_decode($data, true, 512, JSON_THROW_ON_ERROR); + return self::createByArray($array, $groups); + } +} diff --git a/app/src/Entity/UserCode.php b/app/src/Entity/UserCode.php new file mode 100644 index 0000000000000000000000000000000000000000..a338b610e23250121bdbe4fa4feba2b6199ae893 --- /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/UserHistory.php b/app/src/Entity/UserHistory.php new file mode 100644 index 0000000000000000000000000000000000000000..3cf93def581c33585fe33a42911b65e3b4db13c4 --- /dev/null +++ b/app/src/Entity/UserHistory.php @@ -0,0 +1,198 @@ +id; + } + + public function getRelatedUser(): ?User + { + return $this->related_user; + } + + public function setRelatedUser(?User $related_user): static + { + $this->related_user = $related_user; + + return $this; + } + + #[Groups(['all', 'profile'])] + public function getDate(): ?\DateTimeInterface + { + return $this->date; + } + + public function setDate(\DateTimeInterface $date): static + { + $this->date = $date; + + return $this; + } + + #[Groups(['all', 'profile'])] + public function getField(): ?string + { + return $this->field; + } + + public function setField(?string $field): static + { + $this->field = $field; + + return $this; + } + + #[Groups(['all', 'profile'])] + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + #[Groups(['all', 'profile'])] + public function getValue(): ?string + { + return $this->value; + } + + public function setValue(?string $value): static + { + $this->value = $value; + + return $this; + } + + #[Groups(['all', 'profile'])] + public function getOldValue(): ?string + { + return $this->old_value; + } + + public function setOldValue(?string $old_value): static + { + $this->old_value = $old_value; + + return $this; + } + + #[Groups(['all', 'profile'])] + public function getText(): ?string + { + $text = ''; + $type = $this->getType(); + + switch ($field = $this->getField()) { + case 'password': + $text = 'Пароль изменен'; + break; + case 'image': + switch ($type) { + case self::TYPE_CREATE: + $text = 'Изображение загружено'; + break; + case self::TYPE_UPDATE: + $text = 'Изображение обновлено'; + break; + case self::TYPE_DELETE: + $text = 'Изображение удалено'; + break; + } + break; + case 'confirm': + switch ($type) { + case self::TYPE_CREATE: + $text = 'Почта подтверждена'; + break; + case self::TYPE_DELETE: + $text = 'Подтверждение почты сброшено'; + break; + } + break; + case 'user': + switch ($type) { + case self::TYPE_CREATE: + $text = 'Пользователь зарегистрирован'; + break; + case self::TYPE_UPDATE: + $text = 'Пользователь обновлен'; + break; + case self::TYPE_RECOVERY: + $text = 'Пользователь восстановлен'; + break; + case self::TYPE_DELETE: + $text = 'Пользователь удален'; + break; + } + break; + default: + if (isset(UserListener::HISTORY_FIELDS[$field])) { + switch ($type) { + case self::TYPE_CREATE: + $text = 'Поле "' . UserListener::HISTORY_FIELDS[$field] . '" заполнено'; + break; + case self::TYPE_UPDATE: + $text = 'Поле "' . UserListener::HISTORY_FIELDS[$field] . '" обновлено'; + break; + case self::TYPE_DELETE: + $text = 'Поле "' . UserListener::HISTORY_FIELDS[$field] . '" удалено '; + break; + } + } + break; + } + + $time = ''; + if ($date = $this->getDate()) { + $time = $date->format('d.m.Y H:i:s') . ' - '; + } + + return $time . $text; + } +} diff --git a/app/src/Entity/UserImage.php b/app/src/Entity/UserImage.php new file mode 100644 index 0000000000000000000000000000000000000000..0716a4985059d5b9b43017d42db3138a144bd2a7 --- /dev/null +++ b/app/src/Entity/UserImage.php @@ -0,0 +1,93 @@ +id; + } + + public function getRelatedUser(): ?User + { + return $this->related_user; + } + + public function setRelatedUser(?User $related_user): static + { + $this->related_user = $related_user; + + 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; + + return $this; + } + + #[Groups(['all', 'profile', 'edit'])] + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + #[Groups(['all', 'edit'])] + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } +} diff --git a/app/src/Kafka/Transport/Connection.php b/app/src/Kafka/Transport/Connection.php new file mode 100644 index 0000000000000000000000000000000000000000..81a78e33bc82a7906ef86ea7a7868080357aff54 --- /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 0000000000000000000000000000000000000000..ea4df306a809d66664fcace677a662a86c1ad93d --- /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 0000000000000000000000000000000000000000..a7bbdf66013e5033ac0690994b18e5a896939426 --- /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 0000000000000000000000000000000000000000..e48e1fc46fce91bdafba5212b2b1abeb4cb6fee4 --- /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 0000000000000000000000000000000000000000..f50994b609b8132df42406a2531f2f8c8fe73b13 --- /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 0000000000000000000000000000000000000000..65a2343881a4044eb60a3c857f0a3939313609d1 --- /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 0000000000000000000000000000000000000000..2c1a449b233d89c9d24807138389f95d436da310 --- /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 0000000000000000000000000000000000000000..7a7aa3f2020a0e5760312d10c42ffe2d6ae6b489 --- /dev/null +++ b/app/src/Listeners/AccessDeniedListener.php @@ -0,0 +1,33 @@ + ['onAccessException', 2], + ]; + } + + public function onAccessException(ExceptionEvent $event): void + { + $response = new \App\Service\Response\Classes\Response(); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + $response->addError('Доступ запрещен'); + + $exception = $event->getThrowable(); + if (!$exception instanceof AccessDeniedException) { + return; + } + $event->setResponse($response->getResponse()); + } +} \ 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 0000000000000000000000000000000000000000..5b143dff3a8c29c1f0f7f49004043a2d79240be1 --- /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/Listeners/JwtListener.php b/app/src/Listeners/JwtListener.php new file mode 100644 index 0000000000000000000000000000000000000000..07e56ad6037dc9d12d9177dd04e133d949cc8dd4 --- /dev/null +++ b/app/src/Listeners/JwtListener.php @@ -0,0 +1,90 @@ +getData(); + $user = $event->getUser(); + + if (!$user instanceof User) { + return; + } + + $response = new TokenResponse(); + + if ($user->isDeleted()) { + $response->addError('Пользователь удален'); + } else { + $response->setToken($data['token']); + } + + $data = json_decode($response->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR); + $event->setData($data); + } + + /** + * @param AuthenticationFailureEvent $event + */ + public function onAuthenticationFailureResponse(AuthenticationFailureEvent $event): void + { + $response = new \App\Service\Response\Classes\Response(); + $response->addError('Неверный email или пароль'); + $event->setResponse($response->getResponse()); + } + + /** + * @param JWTInvalidEvent $event + */ + public function onJWTInvalid(JWTInvalidEvent $event): void + { + $response = new \App\Service\Response\Classes\Response(); + $response->addError('Неверный токен авторизации'); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + + $event->setResponse($response->getResponse()); + } + + /** + * @param JWTNotFoundEvent $event + */ + public function onJWTNotFound(JWTNotFoundEvent $event): void + { + $response = new \App\Service\Response\Classes\Response(); + $response->addError('Отсутствует токен'); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + + $event->setResponse($response->getResponse()); + } + + /** + * @param JWTExpiredEvent $event + */ + public function onJWTExpired(JWTExpiredEvent $event): void + { + $response = new \App\Service\Response\Classes\Response(); + $response->addError('Срок действия вашего токена истек, пожалуйста, обновите его'); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + + $event->setResponse($response->getResponse()); + } +} \ No newline at end of file diff --git a/app/src/Listeners/KernelExceptionListener.php b/app/src/Listeners/KernelExceptionListener.php new file mode 100644 index 0000000000000000000000000000000000000000..a9ef3ccaf9ddbb5ca66d428ee6d4a6ce3a95c5ca --- /dev/null +++ b/app/src/Listeners/KernelExceptionListener.php @@ -0,0 +1,25 @@ + 'onKernelException', + ]; + } + + public function onKernelException(ExceptionEvent $event) + { + $response = new \App\Service\Response\Classes\Response(); + $response->setStatusCode(Response::HTTP_FORBIDDEN); + $response->addError($event->getThrowable()->getMessage()); + $event->setResponse($response->getResponse()); + } +} \ No newline at end of file diff --git a/app/src/Listeners/UserImageListener.php b/app/src/Listeners/UserImageListener.php new file mode 100644 index 0000000000000000000000000000000000000000..0eafa483df53d34b7da7dacf8994e6e465f575f4 --- /dev/null +++ b/app/src/Listeners/UserImageListener.php @@ -0,0 +1,77 @@ +getObjectManager(); + if (!$this->checkFile($file)) { + $om->remove($file); + $om->flush(); + } + } + + public function preUpdate(UserImage $file, PreUpdateEventArgs $args): void + { + $om = $args->getObjectManager(); + if (!$this->checkFile($file)) { + $om->remove($file); + $om->flush(); + } + } + + public function postRemove(UserImage $file, PostRemoveEventArgs $args): void + { + $om = $args->getObjectManager(); + $this->removeFile($file); + } + + /** + * Проверка наличия файла + * + * @param UserImage $file + * + * @return bool + */ + private function checkFile(UserImage $file): bool + { + if ($path = $file->getPath()) { + $filesystem = new Filesystem(); + if ($filesystem->exists([$path])) { + return true; + } + } + + return false; + } + + /** + * Удаление файла + * + * @param UserImage $file + * + * @return void + */ + private function removeFile(UserImage $file): void + { + if ($path = $file->getPath()) { + $filesystem = new Filesystem(); + if ($filesystem->exists([$path])) { + $filesystem->remove([$path]); + } + } + } +} \ No newline at end of file diff --git a/app/src/Listeners/UserListener.php b/app/src/Listeners/UserListener.php new file mode 100644 index 0000000000000000000000000000000000000000..9c2ae4299124bfa4d6c582e9a76f4a342c3a141e --- /dev/null +++ b/app/src/Listeners/UserListener.php @@ -0,0 +1,161 @@ + 'Email', + 'name' => 'Имя', + 'surname' => 'Фамилия', + 'patronymic' => 'Отчество', + 'phone_number' => 'Номер телефона' + ]; + + public function prePersist(User $user, PreFlushEventArgs $args): void + { + $this->checkEmail($user, $args->getObjectManager()); + $this->saveHistory($user, $args->getObjectManager()); + } + + public function preUpdate(User $user, PreUpdateEventArgs $args): void + { + $this->checkEmail($user, $args->getObjectManager()); + $this->saveHistory($user, $args->getObjectManager()); + } + + public function saveHistory(User $user, ObjectManager $om): void + { + /** @var UnitOfWork $uow */ + $uow = $om->getUnitOfWork(); + $originalValues = $uow->getOriginalEntityData($user); + $userExists = null; + if ($originalValues) { + $userExists = User::createByArray($originalValues); + } + $checkUser = $user->newCopy(); + + if ($image = $user->getImage()) { + $imageOriginalValues = $uow->getOriginalEntityData($user->getImage()); + if ($imageOriginalValues) { + if ($image->getPath() !== $imageOriginalValues['path']) { + $newUserHistory = new UserHistory(); + $newUserHistory->setType(UserHistory::TYPE_UPDATE); + $newUserHistory->setField('image'); + $newUserHistory->setValue($image->getName()); + $newUserHistory->setOldValue($imageOriginalValues['name']); + $user->addUserHistory($newUserHistory); + } + } + } + + if ($user->getId()) { + if ($userExists) { + $reflectionClass = new ReflectionClass($user); + foreach ($reflectionClass->getProperties() as $property) { + $name = $property->getName(); + if (!isset(self::HISTORY_FIELDS[$name])) { + continue; + } + $value = $property->getValue($checkUser); + $oldValue = $property->getValue($userExists); + if ($value !== $oldValue) { + if (empty($value) && empty($oldValue)) { + continue; + } + + $type = UserHistory::TYPE_UPDATE; + if (empty($value)) { + $type = UserHistory::TYPE_DELETE; + } elseif (empty($oldValue)) { + $type = UserHistory::TYPE_CREATE; + } + $newUserHistory = new UserHistory(); + $newUserHistory->setType($type); + $newUserHistory->setField($name); + $newUserHistory->setValue($value); + $newUserHistory->setOldValue($oldValue); + $user->addUserHistory($newUserHistory); + } + } + if ($userExists->isDeleted() !== $user->isDeleted()) { + $newUserHistory = new UserHistory(); + $newUserHistory->setType($user->isDeleted() ? UserHistory::TYPE_DELETE : UserHistory::TYPE_RECOVERY); + $newUserHistory->setField('user'); + $user->addUserHistory($newUserHistory); + } + if ($userExists->isConfirm() !== $user->isConfirm()) { + $newUserHistory = new UserHistory(); + $newUserHistory->setType($user->isConfirm() ? UserHistory::TYPE_CREATE : UserHistory::TYPE_DELETE); + if ($user->isConfirm()) { + $newUserHistory->setValue($user->getEmail()); + } + $newUserHistory->setField('confirm'); + $user->addUserHistory($newUserHistory); + } + if ($userExists->getPassword() !== $user->getPassword()) { + $newUserHistory = new UserHistory(); + $newUserHistory->setType(UserHistory::TYPE_UPDATE); + $newUserHistory->setField('password'); + $user->addUserHistory($newUserHistory); + } + } + } else { + $newUserHistory = new UserHistory(); + $newUserHistory->setType(UserHistory::TYPE_CREATE); + $newUserHistory->setField('user'); + $user->addUserHistory($newUserHistory); + } + } + + public function checkEmail(User $user, ObjectManager $om): void + { + if ($user->getId()) { + /** @var UnitOfWork $uow */ + $uow = $om->getUnitOfWork(); + $originalValues = $uow->getOriginalEntityData($user); + $userExists = null; + if ($originalValues) { + $userExists = User::createByArray($originalValues); + } + if ($userExists) { + if ($userExists->getEmail() !== $user->getEmail()) { + $user->setConfirm($user->getLastConfirmEmail() === $user->getEmail()); + } + } + } else { + $user->setConfirm(false); + } + } + + public function prePersistHistory(UserHistory $userHistory, PreFlushEventArgs $args): void + { + $this->setHistoryDate($userHistory); + } + + public function preUpdateHistory(UserHistory $userHistory, PreUpdateEventArgs $args): void + { + $this->setHistoryDate($userHistory); + } + + public function setHistoryDate(UserHistory $userHistory): void + { + if (!$userHistory->getDate()) { + $userHistory->setDate(new \DateTime()); + } + } +} \ 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 0000000000000000000000000000000000000000..097a247e6302d5c5e148c7564d91d86f83cc1a6f --- /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 0000000000000000000000000000000000000000..1510b5bf096fd9632655b0839e541fd7bfa37fe1 --- /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 0000000000000000000000000000000000000000..98698542625f1705ec0eef4494beaf02fd3f4157 --- /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/Repository/UserHistoryRepository.php b/app/src/Repository/UserHistoryRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..276bcaa258ee4fb1a97f8b4bd2dd782d42b7dfd8 --- /dev/null +++ b/app/src/Repository/UserHistoryRepository.php @@ -0,0 +1,43 @@ + + */ +class UserHistoryRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, UserHistory::class); + } + +// /** +// * @return UserHistory[] Returns an array of UserHistory 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): ?UserHistory +// { +// return $this->createQueryBuilder('u') +// ->andWhere('u.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/app/src/Repository/UserImageRepository.php b/app/src/Repository/UserImageRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..2c67991dd326318811fa6b1242a5e3a862f84813 --- /dev/null +++ b/app/src/Repository/UserImageRepository.php @@ -0,0 +1,43 @@ + + */ +class UserImageRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, UserImage::class); + } + + // /** + // * @return UserImage[] Returns an array of UserImage 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): ?UserImage + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/app/src/Repository/UserRepository.php b/app/src/Repository/UserRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..7046472538cde69ec47f70ed2821d93176c12537 --- /dev/null +++ b/app/src/Repository/UserRepository.php @@ -0,0 +1,93 @@ + + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } + + /** + * Поиск пользователя по Email или номеру телефона + * + * @param string|null $sEmail + * @param string|null $sPhone + * @param int|null $iIgnoreId + * + * @return User|null + */ + public function findOneByUniq(?string $sEmail = null, ?string $sPhone = null, ?int $iIgnoreId = null): ?User + { + $oQuery = $this->createQueryBuilder('u'); + if (!empty($sEmail)) { + $oQuery->orWhere('u.email = :email')->setParameter('email', $sEmail); + } + if (!empty($sPhone)) { + $oQuery->orWhere('u.phone_number = :phone_number')->setParameter('phone_number', $sPhone); + } + if (!empty($iIgnoreId)) { + $oQuery->andWhere('u.id != :id')->setParameter('id', $iIgnoreId); + } + return $oQuery + ->getQuery() + ->getOneOrNullResult(); + } + + public function getById(int $id): ?User + { + $oQuery = $this->createQueryBuilder('u'); + $oQuery->andWhere('u.id = :id')->setParameter('id', $id); + return $oQuery->getQuery()->getOneOrNullResult(); + } + + // /** + // * @return User[] Returns an array of User 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): ?User + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/app/src/Service/Action/ActionServiceInterface.php b/app/src/Service/Action/ActionServiceInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..f3e7391efa2de1748977f76a248ef86f5b0038d5 --- /dev/null +++ b/app/src/Service/Action/ActionServiceInterface.php @@ -0,0 +1,28 @@ +responseService = $responseService; + } + + #[Required] + public function initDto( + DtoServiceInterface $dtoService + ): void + { + $this->dtoService = $dtoService; + } + + #[Required] + public function initDoctrine(ManagerRegistry $doctrine): void + { + $this->doctrine = $doctrine; + } + + #[Required] + public function initSend(SendServiceInterface $sendService): void + { + $this->sendService = $sendService; + } + + abstract public function needDto(): bool; + + public function getResponse(): JsonResponse + { + if ($this->validate()) { + $this->runAction(); + } + + if ($this->responseService) { + return $this->responseService->getResponse(); + } + + $response = new Response(); + $response->addError('Ошибка получения ответа'); + return $response->getResponse(); + } + + protected function getDto(): ?DtoServiceInterface + { + if ($this->dtoService) { + return $this->dtoService->getClass(); + } + + return null; + } + + public function validate(): bool + { + $valid = true; + + if ($this->needDto() && $this->dtoService) { + $valid = $this->dtoService->validate($this->responseService); + } + + if ($valid) { + $valid = $this->customValidate(); + } + + return $valid; + } + + public function customValidate(): bool + { + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/ChangeProfile.php b/app/src/Service/Action/Classes/ChangeProfile.php new file mode 100644 index 0000000000000000000000000000000000000000..9c155d8fedd7b5f2924e42efd68f6658c35cc2ec --- /dev/null +++ b/app/src/Service/Action/Classes/ChangeProfile.php @@ -0,0 +1,107 @@ +getDto(); + + /** @var ?User $userExists */ + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($dto->email, $dto->phoneNumber, $this->user->getId()); + + if ($userExists !== null) { + if ($userExists->getEmail() === $dto->email) { + $this->responseService->addError('Email занят другим пользователем'); + } elseif ($userExists->getPhoneNumber() === $dto->phoneNumber) { + $this->responseService->addError('Номер телефона занят другим пользователем'); + } else { + $this->responseService->addError('Email или номер телефона занят другим пользователем'); + } + } else { + $changed = false; + + if ($dto->name !== null && $dto->name !== $this->user->getName()) { + $this->user->setName($dto->name); + $changed = true; + } + + if ($dto->surname !== null && $dto->surname !== $this->user->getSurname()) { + $this->user->setSurname($dto->surname); + $changed = true; + } + + if ($dto->patronymic !== null && $dto->patronymic !== $this->user->getPatronymic()) { + $this->user->setPatronymic($dto->patronymic); + $changed = true; + } + + if ($dto->phoneNumber !== null && $dto->phoneNumber !== $this->user->getPhoneNumber()) { + $this->user->setPhoneNumber($dto->phoneNumber); + $changed = true; + } + + if ($dto->email !== null && $dto->email !== $this->user->getEmail()) { + $this->user->setEmail($dto->email); + $changed = true; + } + + if ($changed) { + try { + $em = $this->doctrine->getManager(); + $em->persist($this->user); + $em->flush(); + $this->responseService->setData($this->user); + } catch (\Exception $e) { + $this->responseService->addError('Ошибка сохранения профиля'); + } + } else { + $this->responseService->setData($this->user); + } + } + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } + + public function needDto(): bool + { + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/CheckRecoveryCode.php b/app/src/Service/Action/Classes/CheckRecoveryCode.php new file mode 100644 index 0000000000000000000000000000000000000000..afdfff91a5629a1f008c6fb13d7c6a65c8f5d717 --- /dev/null +++ b/app/src/Service/Action/Classes/CheckRecoveryCode.php @@ -0,0 +1,73 @@ +getDto(); + /** @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->responseService->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->responseService->addMessage('Профиль восстановлен'); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка восстановления профиля'); + } + } else { + $this->responseService->addError('Код недействителен'); + } + } else { + $this->responseService->addError('Код недействителен'); + } + } + } else { + $this->responseService->addError('Пользователь не найден'); + } + } + + public function needDto(): bool + { + return true; + } +} \ 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 0000000000000000000000000000000000000000..3a26d7f78af4e23eb4880cb87e821d7827027812 --- /dev/null +++ b/app/src/Service/Action/Classes/CheckRegisterCode.php @@ -0,0 +1,71 @@ +getDto()->code; + $registerCode = $this->user->getRegisterCode(); + if ($registerCode === null) { + $this->responseService->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->responseService->addMessage('Регистрация подтверждена'); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка подтверждения регистрации'); + } + } else { + $this->responseService->addError('Код недействителен'); + } + } else { + $this->responseService->addError('Код недействителен'); + } + } + } + + public function needDto(): bool + { + return true; + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/DeleteImage.php b/app/src/Service/Action/Classes/DeleteImage.php new file mode 100644 index 0000000000000000000000000000000000000000..ff32f3dd6a18dd45bb6378438c2a1ef2999ccf1f --- /dev/null +++ b/app/src/Service/Action/Classes/DeleteImage.php @@ -0,0 +1,49 @@ +user->getImage(); + if ($image) { + $em = $this->doctrine->getManager(); + + try { + $newUserHistory = new UserHistory(); + $newUserHistory->setType(UserHistory::TYPE_DELETE); + $newUserHistory->setField('image'); + $newUserHistory->setValue($image->getName()); + $this->user->addUserHistory($newUserHistory); + $em->remove($image); + $em->flush(); + $this->responseService->addMessage('Изображение удалено'); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка удаления изображения'); + } + } else { + $this->responseService->addError('Нет изображения'); + } + } + + public function needDto(): bool + { + return false; + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } +} \ 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 0000000000000000000000000000000000000000..d50a89492dd7fcf3edbb338530f4e2179895fd37 --- /dev/null +++ b/app/src/Service/Action/Classes/DeleteProfile.php @@ -0,0 +1,43 @@ +user->setDeleted(true); + $em = $this->doctrine->getManager(); + $em->persist($this->user); + $em->flush(); + $this->responseService->addMessage('Профиль удален'); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка удаления профиля'); + } + } + + public function needDto(): bool + { + return false; + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } +} \ 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 0000000000000000000000000000000000000000..73a47bf0851bad47fcae37d1412153187a346fea --- /dev/null +++ b/app/src/Service/Action/Classes/GetProfile.php @@ -0,0 +1,49 @@ +responseService->setData($this->user); + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } + + public function needDto(): bool + { + return false; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/None.php b/app/src/Service/Action/Classes/None.php new file mode 100644 index 0000000000000000000000000000000000000000..4dea60206f8c6cc8c43d9124a652d0b6c5d17fb0 --- /dev/null +++ b/app/src/Service/Action/Classes/None.php @@ -0,0 +1,29 @@ +responseService) { + $this->responseService->addError('Действие не выбрано'); + } + return false; + } + + public function needDto(): bool + { + return false; + } +} \ 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 0000000000000000000000000000000000000000..7cad30652be3e7f54a944eabb985983a952a14e2 --- /dev/null +++ b/app/src/Service/Action/Classes/RecoveryProfile.php @@ -0,0 +1,64 @@ +getDto(); + /** @var User $userExists */ + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($dto->email, $dto->phoneNumber); + + if ($userExists !== null) { + if (!$userExists->isDeleted()) { + $this->responseService->addError('Профиль не удален'); + } else { + $this->sendService->setUser($userExists); + $this->sendService->setResponse($this->responseService); + $this->sendService->send(); + } + } else { + $this->responseService->addError('Пользователь не найден'); + } + } + + public function needDto(): bool + { + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/Register.php b/app/src/Service/Action/Classes/Register.php new file mode 100644 index 0000000000000000000000000000000000000000..42117d6bfe3717b81439a2a896067a6afebf5878 --- /dev/null +++ b/app/src/Service/Action/Classes/Register.php @@ -0,0 +1,120 @@ +getDto(); + $user = $this->createUser(); + if ($user !== null && $dto) { + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($user->getEmail(), $user->getPhoneNumber()); + + if ($userExists) { + $this->responseService->addError('Пользователь уже существует'); + } else { + try { + $user->setDeleted(false); + $user->setConfirm(false); + $hashedPassword = $this->passwordHasher->hashPassword( + $user, + $dto->password ?: '' + ); + $user->setPassword($hashedPassword); + + $em = $this->doctrine->getManager(); + + $em->persist($user); + $em->flush(); + $this->responseService->addMessage('Пользователь зарегистрирован'); + + $this->sendService->setUser($user); + $this->sendService->setResponse($this->responseService); + $this->sendService->send(); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка регистрации пользователя'); + } + } + } + } + + /** + * Создание пользователя из Dto + * + * @return User|null + */ + private function createUser(): ?User + { + $user = null; + + $data = $this->dtoService->toArray(); + + if ($data) { + $user = new User(); + + $reflectionClass = new ReflectionClass($user); + foreach ($reflectionClass->getProperties() as $property) { + $type = $property->getType(); + if (isset($data[$property->getName()])) { + $sValue = $data[$property->getName()] ?: null; + if ($sValue !== null && $type !== null && ($type->getName() !== 'array')) { + $property->setValue($user, $sValue); + } + } + } + } else { + $this->responseService->addError('Ошибка получения данных'); + } + + return $user; + } + + public function needDto(): bool + { + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/ResetEmail.php b/app/src/Service/Action/Classes/ResetEmail.php new file mode 100644 index 0000000000000000000000000000000000000000..cd5e06c91f979ff9e94950e898957f6d32ceb2f7 --- /dev/null +++ b/app/src/Service/Action/Classes/ResetEmail.php @@ -0,0 +1,46 @@ +user->getLastConfirmEmail()) { + if ($email === $this->user->getEmail()) { + $this->responseService->addMessage('Подтвержденный email уже установлен'); + } else { + try { + $this->user->setEmail($email); + $em = $this->doctrine->getManager(); + $em->persist($this->user); + $em->flush(); + $this->responseService->addMessage('Установлен email: ' . $email); + } catch (\Exception $e) { + $this->responseService->addError('Ошибка сохранения email'); + } + } + } else { + $this->responseService->addError('Нет последнего подтвержденного email'); + } + } + + public function needDto(): bool + { + return false; + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/ResetPassword.php b/app/src/Service/Action/Classes/ResetPassword.php new file mode 100644 index 0000000000000000000000000000000000000000..21d0e7b7a5e70b132e6dd5d2f2d127d5afaa392d --- /dev/null +++ b/app/src/Service/Action/Classes/ResetPassword.php @@ -0,0 +1,70 @@ +getDto(); + + if ($this->passwordHasher->isPasswordValid($this->user, $dto->oldPassword)) { + $hashedPassword = $this->passwordHasher->hashPassword( + $this->user, + $dto->password ?: '' + ); + $this->user->setPassword($hashedPassword); + + try { + $em = $this->doctrine->getManager(); + $em->persist($this->user); + $em->flush(); + $this->responseService->addMessage('Пароль изменен'); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка изменения пароля'); + } + } else { + $this->responseService->addError('Текущий пароль неверен'); + } + } + + public function needDto(): bool + { + return true; + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/ResetPasswordCode.php b/app/src/Service/Action/Classes/ResetPasswordCode.php new file mode 100644 index 0000000000000000000000000000000000000000..8368ef22541f04c7708f84046e5044928c6ae659 --- /dev/null +++ b/app/src/Service/Action/Classes/ResetPasswordCode.php @@ -0,0 +1,80 @@ +getDto(); + /** @var User $userExists */ + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($dto->email, $dto->phoneNumber); + + if ($userExists !== null) { + $currentDate = new \DateTime(); + $code = $dto->code; + $registerCode = $userExists->getRegisterCode(); + if ($registerCode === null) { + $this->responseService->addError('Код подтверждения не отправлен'); + } else { + if ($registerCodeDate = $registerCode->getDate()) { + if ($registerCode->getCode() === $code && $currentDate->getTimestamp() < $registerCodeDate->getTimestamp()) { + try { + $hashedPassword = $this->passwordHasher->hashPassword( + $userExists, + $dto->password ?: '' + ); + $userExists->setPassword($hashedPassword); + $em = $this->doctrine->getManager(); + $em->persist($userExists); + $em->remove($registerCode); + $em->flush(); + $this->responseService->addMessage('Пароль изменен'); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка изменения пароля'); + } + } else { + $this->responseService->addError('Код недействителен'); + } + } else { + $this->responseService->addError('Код недействителен'); + } + } + } else { + $this->responseService->addError('Пользователь не найден'); + } + } + + public function needDto(): bool + { + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/SaveImage.php b/app/src/Service/Action/Classes/SaveImage.php new file mode 100644 index 0000000000000000000000000000000000000000..36fb0f90eb745bf09d4387779fc99f716ef20bb2 --- /dev/null +++ b/app/src/Service/Action/Classes/SaveImage.php @@ -0,0 +1,147 @@ +saveFile(); + if ($file) { + $em = $this->doctrine->getManager(); + + $oldImage = $this->user->getImage(); + if ($oldImage) { + $oldImage->setName($file->getName()); + $oldImage->setType($file->getType()); + $oldImage->setPath($file->getPath()); + $file = $oldImage; + } else { + $newUserHistory = new UserHistory(); + $newUserHistory->setType(UserHistory::TYPE_CREATE); + $newUserHistory->setField('image'); + $newUserHistory->setValue($file->getName()); + $this->user->addUserHistory($newUserHistory); + $this->user->setImage($file); + } + try { + $em->persist($file); + $em->flush(); + $this->responseService->addMessage('Изображение сохранено'); + } catch (\Exception $exception) { + $this->responseService->addError('Ошибка сохранения файла пользователя'); + } + } else { + $this->responseService->addError('Ошибка сохранения файла'); + } + } + + public function saveFile(): ?UserImage + { + /** @var ImageDto $dto */ + $dto = $this->getDto(); + + $matches = []; + if (!preg_match('/^data:([a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}\/[a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}(;[a-z0-9\-]+\=[a-z0-9\-]+)?)?(;base64)?,([a-z0-9\!\$\&\\\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$)/i', $dto->data ?: '', $matches)) { + return null; + } + $extension = $matches[1]; + $content = $matches[4]; + if ($extension && $content) { + $mimeTypes = new MimeTypes(); + $types = $mimeTypes->getExtensions($extension); + if (empty($types)) { + $this->responseService->addError('Неизвестное расширения файла'); + return null; + } + if (empty(array_intersect($types, self::IMAGE_EXTENSIONS))) { + $this->responseService->addError('Файл расширения "'. reset($types) .'" недоступен для загрузки. Доступные расширения: ' . implode(', ', self::IMAGE_EXTENSIONS) . '.'); + return null; + } + + $filename = pathinfo($dto->name, PATHINFO_FILENAME); + + $filename = $filename . '.' . reset($types); + + $decoded = base64_decode($content); + if (!$decoded) { + $this->responseService->addError('Ошибка декодирования файла'); + return null; + } + + $tmpPath = sys_get_temp_dir() . '/file_upload' . uniqid(); + + if (file_put_contents($tmpPath, base64_decode($content))) { + $uploadFile = new UploadedFile($tmpPath, $filename, $extension, null, true); + + $originalFilename = pathinfo($uploadFile->getClientOriginalName(), PATHINFO_FILENAME); + $safeFilename = $this->slugger->slug($originalFilename); + $fileName = $safeFilename . '-' . uniqid() . '.' . $uploadFile->guessExtension(); + $filedir = $this->targetDirectory; + try { + $file = $uploadFile->move($filedir, $fileName); + $dmFile = new UserImage(); + $dmFile->setName($uploadFile->getClientOriginalName()); + $dmFile->setPath($file->getRealPath()); + $dmFile->setType($extension); + return $dmFile; + } catch (FileException $e) { + return null; + } + } + } + + return null; + } + + public function needDto(): bool + { + return true; + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/SendRegisterCode.php b/app/src/Service/Action/Classes/SendRegisterCode.php new file mode 100644 index 0000000000000000000000000000000000000000..135e2f94ae7f6fca37bbfe4894b58ff4e002ee71 --- /dev/null +++ b/app/src/Service/Action/Classes/SendRegisterCode.php @@ -0,0 +1,61 @@ +sendService->setUser($this->user); + $this->sendService->setResponse($this->responseService); + $this->sendService->send(); + } + + public function customValidate(): bool + { + if ($this->user->isConfirm()) { + $this->responseService->addError('Учетная запись уже подтверждена'); + return false; + } + + return true; + } + + public function needDto(): bool + { + return false; + } + + public function checkDelete(): bool + { + return true; + } + + public function checkConfirm(): bool + { + return false; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/Classes/SendResetPasswordCode.php b/app/src/Service/Action/Classes/SendResetPasswordCode.php new file mode 100644 index 0000000000000000000000000000000000000000..6984819805fd56f83681d4e41b1540059271402e --- /dev/null +++ b/app/src/Service/Action/Classes/SendResetPasswordCode.php @@ -0,0 +1,55 @@ +getDto(); + /** @var User $userExists */ + $userExists = $this->doctrine->getRepository(User::class) + ->findOneByUniq($dto->email, $dto->phoneNumber); + + if ($userExists !== null) { + $this->sendService->setUser($userExists); + $this->sendService->setResponse($this->responseService); + $this->sendService->send(); + } else { + $this->responseService->addError('Пользователь не найден'); + } + } + + public function needDto(): bool + { + return true; + } +} \ No newline at end of file diff --git a/app/src/Service/Action/UserBaseActionService.php b/app/src/Service/Action/UserBaseActionService.php new file mode 100644 index 0000000000000000000000000000000000000000..c6b201999fc70f1c267c0e5acc5ff63b0eadf4ab --- /dev/null +++ b/app/src/Service/Action/UserBaseActionService.php @@ -0,0 +1,42 @@ +user = $security->getUser(); + } + + abstract public function checkDelete(): bool; + + abstract public function checkConfirm(): bool; + + public function customValidate(): bool + { + if ($this->user === null) { + $this->responseService->addError('Вы не авторизованы'); + return false; + } + + if ($this->checkDelete() && $this->user->isDeleted()) { + $this->responseService->addError('Профиль удален'); + return false; + } + + if ($this->checkConfirm() && !$this->user->isConfirm()) { + $this->responseService->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 new file mode 100644 index 0000000000000000000000000000000000000000..363af9757865249aafbea400550fbf5999a32af0 --- /dev/null +++ b/app/src/Service/Dto/BaseDto.php @@ -0,0 +1,106 @@ +request = $requestStack->getCurrentRequest(); + } + } + + + /** + * Получение класса Dto + * + * @return DtoServiceInterface|null + */ + #[Ignore] + public function getClass(): ?DtoServiceInterface + { + if ($this->request) { + 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; + } + + public function toArray(): ?array + { + try { + $normalizer = new ObjectNormalizer( + null, + new CamelCaseToSnakeCaseNameConverter(), + null, + new ReflectionExtractor() + ); + $serializer = new Serializer([$normalizer], [new JsonEncoder()]); + $data = $serializer->serialize($this->getClass(), 'json'); + return json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } catch (\Exception $oException) { + return null; + } + } + + /** + * Валидация Dto + * + * @param ResponseServiceInterface $response + * + * @return bool + */ + public function validate(ResponseServiceInterface $response): bool + { + $bValid = true; + if ($classObj = $this->getClass()) { + $aErrors = $this->validator->validate($classObj); + if (count($aErrors) > 0) { + foreach ($aErrors as $error) { + $response->addError($error->getMessage()); + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + } + $bValid = false; + } + } else { + $response->addError("Данные не получены"); + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + $bValid = false; + } + + return $bValid; + } +} \ No newline at end of file diff --git a/app/src/Service/Dto/Classes/ChangePasswordDto.php b/app/src/Service/Dto/Classes/ChangePasswordDto.php new file mode 100644 index 0000000000000000000000000000000000000000..952993f9c85869e568900b3f597117961dc6b297 --- /dev/null +++ b/app/src/Service/Dto/Classes/ChangePasswordDto.php @@ -0,0 +1,30 @@ +data = $user; + + return $this; + } + + public function getGroups(): array + { + return ['profile']; + } +} \ No newline at end of file diff --git a/app/src/Service/Response/Classes/Response.php b/app/src/Service/Response/Classes/Response.php new file mode 100644 index 0000000000000000000000000000000000000000..b0f32f13cb1594628ea9188f757dbbc4a0acf0d4 --- /dev/null +++ b/app/src/Service/Response/Classes/Response.php @@ -0,0 +1,157 @@ +response = new JsonResponse(); + } + + /** + * Группы сериализации + * + * @return array + */ + #[Ignore] + protected function getGroups(): array + { + return []; + } + + /** + * Добавление ошибки + * + * @param string $message + * + * @return $this + * + * @throws \JsonException + */ + #[Ignore] + public function addError(string $message): self + { + $this->errors[] = $message; + + return $this; + } + + /** + * Добавление сообщения + * + * @param string $message + * + * @return $this + * + * @throws \JsonException + */ + #[Ignore] + public function addMessage(string $message): self + { + $this->messages[] = $message; + + return $this; + } + + #[Ignore] + public function isSuccess(): bool + { + if (!empty($this->errors)) { + $this->status = false; + } else { + $this->status = true; + } + + return $this->status; + } + + #[Ignore] + public function getResponse(): JsonResponse + { + $this->refresh(); + return $this->response; + } + + #[Ignore] + public function setStatusCode(int $code): self + { + $this->statusCode = $code; + return $this; + } + + #[Ignore] + protected function refresh(): self + { + $groups = ['message']; + if (!empty($this->errors)) { + $this->status = false; + if ($this->statusCode === 200) { + $this->statusCode = \Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST; + } + } else { + $this->status = true; + } + + $this->message = implode(', ', array_merge($this->messages, $this->errors)); + + if (isset($this->data) && !empty($this->data)) { + $groups = ['data']; + $groups = array_merge($groups, $this->getGroups()); + } + + $normalizer = new ObjectNormalizer( + new ClassMetadataFactory(new AttributeLoader()), + new CamelCaseToSnakeCaseNameConverter(), + null, + new ReflectionExtractor() + ); + $serializer = new Serializer([new DateTimeNormalizer(), $normalizer], [new JsonEncoder()]); + $dataStr = $serializer->serialize($this, 'json', ['groups' => $groups]); + $dataArray = json_decode($dataStr, true, 512, JSON_THROW_ON_ERROR); + + $this->response->setData($dataArray); + $this->response->setStatusCode($this->statusCode); + + return $this; + } +} \ No newline at end of file diff --git a/app/src/Service/Response/Classes/TokenResponse.php b/app/src/Service/Response/Classes/TokenResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..c97b562c757f90b51ae4776193083a562c6055bc --- /dev/null +++ b/app/src/Service/Response/Classes/TokenResponse.php @@ -0,0 +1,21 @@ +data = ['token' => $token]; + } +} \ No newline at end of file diff --git a/app/src/Service/Response/ResponseServiceInterface.php b/app/src/Service/Response/ResponseServiceInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..2f0b6e5224567a2d345c73af390fdd269da4335e --- /dev/null +++ b/app/src/Service/Response/ResponseServiceInterface.php @@ -0,0 +1,18 @@ +Уважаемый {surname} {name} {patronymic} +
Ваш код для восстановления пароля: {code}
+
Время действия кода: {time}
+ HTML; + } + +} \ No newline at end of file diff --git a/app/src/Service/Send/Classes/Code/RecoveryCodeSendService.php b/app/src/Service/Send/Classes/Code/RecoveryCodeSendService.php new file mode 100644 index 0000000000000000000000000000000000000000..306e169b1b30edadd00fa8edb5009e33cb1b2f0a --- /dev/null +++ b/app/src/Service/Send/Classes/Code/RecoveryCodeSendService.php @@ -0,0 +1,25 @@ +Уважаемый {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 0000000000000000000000000000000000000000..4a3dbb9d77fd3218182e15dc5dfeba7605f90fe0 --- /dev/null +++ b/app/src/Service/Send/Classes/Code/RegisterCodeSendService.php @@ -0,0 +1,24 @@ +Уважаемый {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 0000000000000000000000000000000000000000..1aafc513aa49feb8ae43ae60e6f5e7110991eec8 --- /dev/null +++ b/app/src/Service/Send/Classes/CodeSendService.php @@ -0,0 +1,146 @@ +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->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->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->addMessage('Письмо с кодом отправлено'); + } else { + $this->response->addError('Ошибка генерации кода'); + } + } + + /** + * Подстановка значений в письмо + * + * @param array $values + * + * @return string + */ + private function formatBody(array $values): string + { + $body = $this->getBody(); + + return self::textReplace($body, $values); + } + + /** + * Подстановка значений в тему письма + * + * @param array $values + * + * @return string + */ + private function formatSubject(array $values): string + { + $subject = $this->getSubject(); + + return self::textReplace($subject, $values); + } + + /** + * Замена переменных текста + * + * @param string $text + * @param array $values + * + * @return string + */ + private static function textReplace(string $text, array $values): string + { + foreach ($values as $name => $value) { + if (is_string($value)) { + $text = str_replace('{' . $name . '}', $value, $text); + } + } + + $match = []; + preg_match('/{\w+}/', $text, $match); + foreach ($match as $value) { + $text = str_replace($value, '', $text); + } + + return $text; + } +} \ 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 0000000000000000000000000000000000000000..487023563fdb1b737f4448aa18ea42500e1aca12 --- /dev/null +++ b/app/src/Service/Send/SendService.php @@ -0,0 +1,85 @@ +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) { + } + } +} \ 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 0000000000000000000000000000000000000000..a1b6ad81fa55e8cebe6bb23376613492a19ad6dd --- /dev/null +++ b/app/src/Service/Send/SendServiceInterface.php @@ -0,0 +1,8 @@ +getObject(); + if ($object) { + if ($object->password !== $object->repeatPassword) { + $context->buildViolation('Повтор пароля не совпадает') + ->addViolation(); + } + } + } + + /** + * Проверка на совпадение нового пароля и старого + * + * @param mixed $value + * @param ExecutionContextInterface $context + * @param mixed $payload + * + * @return void + */ + public static function validateNewPassword(mixed $value, ExecutionContextInterface $context, mixed $payload): void + { + $object = $context->getObject(); + if ($object) { + if ($object->password === $object->oldPassword) { + $context->buildViolation('Новый пароль не должен совпадать со старым') + ->addViolation(); + } + } + } + + /** + * Проверка пароля + * + * @param mixed $value + * @param ExecutionContextInterface $context + * @param mixed $payload + * + * @return void + */ + public static function validatePassword(mixed $value, ExecutionContextInterface $context, mixed $payload): void + { + $reg = '/(?=^.{8,}$)((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/'; + + $object = $context->getObject(); + + if ($object) { + if (!preg_match($reg, $object->password)) { + $context->buildViolation('Пароль должен содержать строчные и прописные латинские буквы, цифры, спецсимволы. Минимум 8 символов') + ->addViolation(); + } + } + } +} \ No newline at end of file diff --git a/app/src/Validators/UserValidator.php b/app/src/Validators/UserValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..62e1b6eb255ecffdf1d9327a34b8fa566f808658 --- /dev/null +++ b/app/src/Validators/UserValidator.php @@ -0,0 +1,28 @@ +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 ff98b32f910dda0fc6fff2112069631ec124ec07..6babe261381e9ef543850a43b480f6db498d3ca3 100644 --- a/app/symfony.lock +++ b/app/symfony.lock @@ -38,6 +38,19 @@ "config/packages/lexik_jwt_authentication.yaml" ] }, + "nelmio/api-doc-bundle": { + "version": "4.27", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "3.0", + "ref": "c8e0c38e1a280ab9e37587a8fa32b251d5bc1c94" + }, + "files": [ + "config/packages/nelmio_api_doc.yaml", + "config/routes/nelmio_api_doc.yaml" + ] + }, "symfony/console": { "version": "7.0", "recipe": { @@ -102,6 +115,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": { @@ -128,6 +153,19 @@ "config/routes/security.yaml" ] }, + "symfony/twig-bundle": { + "version": "7.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.4", + "ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877" + }, + "files": [ + "config/packages/twig.yaml", + "templates/base.html.twig" + ] + }, "symfony/validator": { "version": "7.0", "recipe": { @@ -139,5 +177,8 @@ "files": [ "config/packages/validator.yaml" ] + }, + "twig/extra-bundle": { + "version": "v3.10.0" } } diff --git a/app/templates/base.html.twig b/app/templates/base.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..1069c1476fda90ed10c4e2662c87c092458a1b32 --- /dev/null +++ b/app/templates/base.html.twig @@ -0,0 +1,16 @@ + + + + + {% block title %}Welcome!{% endblock %} + + {% block stylesheets %} + {% endblock %} + + {% block javascripts %} + {% endblock %} + + + {% block body %}{% endblock %} + + diff --git a/compose.yaml b/compose.yaml index 0b9b0e89dbba2a9d5a532573bd8ce78b919d35e0..a537a6cb3a695685e2ba5058aed3543c7c70d149 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 bd634f2e01eedaf56210be4d0dcb71e0d1320cbb..985283e194d011b6dbdaf31157f770582670344d 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} \