diff --git a/.gitignore b/.gitignore index be98836..93fcfad 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ /var/ /vendor/ ###< symfony/framework-bundle ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### + +.idea diff --git a/composer.json b/composer.json index 1f02e1b..3f739de 100644 --- a/composer.json +++ b/composer.json @@ -5,13 +5,28 @@ "php": ">=8.1.0", "ext-ctype": "*", "ext-iconv": "*", - "symfony/flex": "^1.9.8", + "doctrine/dbal": "^3", + "doctrine/doctrine-bundle": "^2.13", + "doctrine/doctrine-migrations-bundle": "^3.3", + "doctrine/orm": "^3.2", + "lexik/jwt-authentication-bundle": "^3.1", + "phpdocumentor/reflection-docblock": "^5.4", + "phpstan/phpdoc-parser": "^1.32", "symfony/console": "6.4.*", "symfony/dotenv": "6.4.*", + "symfony/flex": "^1.9.8", "symfony/framework-bundle": "6.4.*", + "symfony/property-access": "6.4.*", + "symfony/property-info": "6.4.*", + "symfony/security-bundle": "6.4.*", + "symfony/serializer": "6.4.*", + "symfony/validator": "6.4.*", "symfony/yaml": "6.4.*" }, "require-dev": { + "symfony/maker-bundle": "^1.61", + "symfony/stopwatch": "6.4.*", + "symfony/web-profiler-bundle": "6.4.*" }, "config": { "optimize-autoloader": true, diff --git a/config/bundles.php b/config/bundles.php index 49d3fb6..a714703 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -2,4 +2,11 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], + Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 0000000..d42c52d --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,50 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + + # IMPORTANT: You MUST configure your server version, + # either here or in the DATABASE_URL env var (see .env file) + #server_version: '16' + + profiling_collect_backtrace: '%kernel.debug%' + use_savepoints: true + orm: + auto_generate_proxy_classes: true + enable_lazy_ghost_objects: true + report_fields_where_declared: true + validate_xml_mapping: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Entity' + prefix: 'App\Entity' + alias: App + +when@test: + doctrine: + dbal: + # "TEST_TOKEN" is typically set by ParaTest + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + auto_generate_proxy_classes: false + proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 0000000..29231d9 --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,6 @@ +doctrine_migrations: + migrations_paths: + # namespace is arbitrary but should be different from App\Migrations + # as migrations classes should NOT be autoloaded + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 0000000..edfb69d --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,4 @@ +lexik_jwt_authentication: + secret_key: '%env(resolve:JWT_SECRET_KEY)%' + public_key: '%env(resolve:JWT_PUBLIC_KEY)%' + pass_phrase: '%env(JWT_PASSPHRASE)%' diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 0000000..0ca8849 --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,61 @@ +security: + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider + providers: + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + login: + pattern: ^/login + stateless: true + json_login: + check_path: /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: ^/advert + stateless: true + jwt: ~ + + main: + lazy: true + provider: app_user_provider + logout: + path: app_logout + invalidate_session: false + + # activate different ways to authenticate + # https://symfony.com/doc/current/security.html#the-firewall + + # https://symfony.com/doc/current/security/impersonating_user.html + # switch_user: true + + # 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: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/advert, roles: IS_AUTHENTICATED_FULLY } + +when@test: + security: + password_hashers: + # By default, password hashers are resource intensive and take time. This is + # important to generate secure password hashes. In tests however, secure hashes + # are not important, waste resources and increase test times. The following + # reduces the work factor to the lowest possible values. + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: + algorithm: auto + cost: 4 # Lowest possible value for bcrypt + time_cost: 3 # Lowest possible value for argon + memory_cost: 10 # Lowest possible value for argon diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml new file mode 100644 index 0000000..3f795d9 --- /dev/null +++ b/config/packages/twig.yaml @@ -0,0 +1,6 @@ +twig: + file_name_pattern: '*.twig' + +when@test: + twig: + strict_variables: true diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 0000000..0201281 --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,13 @@ +framework: + validation: + email_validation_mode: html5 + + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/config/packages/web_profiler.yaml b/config/packages/web_profiler.yaml new file mode 100644 index 0000000..b946111 --- /dev/null +++ b/config/packages/web_profiler.yaml @@ -0,0 +1,17 @@ +when@dev: + web_profiler: + toolbar: true + intercept_redirects: false + + framework: + profiler: + only_exceptions: false + collect_serializer_data: true + +when@test: + web_profiler: + toolbar: false + intercept_redirects: false + + framework: + profiler: { collect: false } diff --git a/config/routes/attributes.yaml b/config/routes/attributes.yaml new file mode 100644 index 0000000..a7a32e0 --- /dev/null +++ b/config/routes/attributes.yaml @@ -0,0 +1,10 @@ +# config/routes/attributes.yaml +controllers: + resource: + path: ../../src/Controller/ + namespace: App\Controller + type: attribute + +kernel: + resource: App\Kernel + type: attribute diff --git a/config/routes/security.yaml b/config/routes/security.yaml new file mode 100644 index 0000000..f853be1 --- /dev/null +++ b/config/routes/security.yaml @@ -0,0 +1,3 @@ +_security_logout: + resource: security.route_loader.logout + type: service diff --git a/config/routes/web_profiler.yaml b/config/routes/web_profiler.yaml new file mode 100644 index 0000000..8d85319 --- /dev/null +++ b/config/routes/web_profiler.yaml @@ -0,0 +1,8 @@ +when@dev: + web_profiler_wdt: + resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' + prefix: /_wdt + + web_profiler_profiler: + resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' + prefix: /_profiler diff --git a/migrations/.gitignore b/migrations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Controller/AdvertsController.php b/src/Controller/AdvertsController.php new file mode 100644 index 0000000..c874b79 --- /dev/null +++ b/src/Controller/AdvertsController.php @@ -0,0 +1,167 @@ +entityManager = $entityManager; + $this->security = $security; + } + #[Route('/advert', name: 'create_advert', methods: ['POST'])] + public function addAdvert(Request $request, ValidatorInterface $validator, SerializerInterface $serializer): JsonResponse + { + // Get user connected + $user = $this->security->getUser(); + // Deserialize request content + $advert = $serializer->deserialize($request->getContent(), Adverts::class, 'json'); + + // Validate data and check error + $errors = $validator->validate($advert); + if (count($errors) > 0) { + return new JsonResponse($serializer->serialize($errors, 'json'), JsonResponse::HTTP_BAD_REQUEST, [], true); + } + + // Add user to advert + $advert->setUser($user); + // Save in database new Advert object + $this->entityManager->persist($advert); + $this->entityManager->flush(); + + // Return new advert created with ID + return new JsonResponse(['status' => 'Advert created', 'id' => $advert->getId()], Response::HTTP_CREATED); + } + + #[Route('/advert/{id}', name: 'patch_advert', methods: ['PATCH'])] + public function updateAdvert(int $id, Request $request, AdvertsRepository $advertsRepository, SerializerInterface $serializer): JsonResponse + { + // Find the Advert by ID + $advert = $advertsRepository->find($id); + + // Check if Advert doesn't exist + if (!$advert) { + return new JsonResponse(['error' => 'Advert not found'], Response::HTTP_NOT_FOUND, [], true); + } + + // Check if User connected is advert's owner + if ($advert->getUser() !== $this->getUser()) { + return new JsonResponse(['error' => 'Access denied, This advert not yours'], Response::HTTP_FORBIDDEN); + } + + + // Deserialize data into Advert + $serializer->deserialize( + $request->getContent(), + Adverts::class, + 'json', + ['object_to_populate' => $advert] + ); + + // Save in database + $this->entityManager->flush(); + + // Return response + return new JsonResponse(['message' => 'Advert updated successfully'], Response::HTTP_OK); + } + #[Route('/advert', name: 'get_adverts', methods: ['GET'])] + public function getAdvertList(AdvertsRepository $advertsRepository, SerializerInterface $serializer): JsonResponse + { + $advertsList = $advertsRepository->findAll(); + + $jsonAdvertsList = $serializer->serialize($advertsList,'json', ['groups' => 'user']); + return new JsonResponse($jsonAdvertsList, Response::HTTP_OK, [], true); + } + + #[Route('/advert/{id}', name: 'get_advert', requirements: ['id' => '\d+'], methods: ['GET'])] + public function getAdvert(int $id, AdvertsRepository $advertsRepository, SerializerInterface $serializer): JsonResponse + { + // Retrieve the advert from the database using the ID + $advert = $advertsRepository->find($id); + + // If the advert exist return advert's information + if ($advert) { + $jsonAdvert = $serializer->serialize($advert,'json', ['groups' => 'user']); + return new JsonResponse($jsonAdvert, Response::HTTP_OK, [], true); + } + + // If the advert doesn't exist, return a 404 error + return new JsonResponse(null, Response::HTTP_NOT_FOUND); + } + + #[Route('/advert/search', name: 'advert_search', methods: ['GET'])] + public function searchAdverts(Request $request, SerializerInterface $serializer): JsonResponse + { + // Get request's parameters + $title = $request->query->get('title'); + $priceMin = $request->query->get('price_min'); + $priceMax = $request->query->get('price_max'); + + // Create request to filter + $queryBuilder = $this->entityManager->getRepository(Adverts::class)->createQueryBuilder('a'); + + // Filter by title + if ($title) { + $queryBuilder->andWhere('a.title LIKE :title') + ->setParameter('title', '%' . $title . '%'); + } + + // Filter by minimum price + if ($priceMin) { + $queryBuilder->andWhere('a.price >= :priceMin') + ->setParameter('priceMin', $priceMin); + } + + // Filter by maximum price + if ($priceMax) { + $queryBuilder->andWhere('a.price <= :priceMax') + ->setParameter('priceMax', $priceMax); + } + + // Get results from request + $adverts = $queryBuilder->getQuery()->getResult(); + + $jsonAdverts = $serializer->serialize($adverts,'json', ['groups' => 'user']); + return new JsonResponse($jsonAdverts, Response::HTTP_OK, [], true); + } + + #[Route('/advert/{id}', name: 'delete_advert', methods: ['DELETE'])] + public function deleteAdvert(int $id, AdvertsRepository $advertsRepository): JsonResponse + { + + // Find the Advert by ID + $advert = $advertsRepository->find($id); + + // Check if Advert doesn't exist + if (!$advert) { + return new JsonResponse(['error' => 'Advert not found'], Response::HTTP_NOT_FOUND); + } + + // Check if User connected is advert's owner + if ($advert->getUser() !== $this->getUser()) { + return new JsonResponse(['error' => 'Access denied, This advert not yours'], Response::HTTP_FORBIDDEN); + } + + // Delete Advert + $this->entityManager->remove($advert); + $this->entityManager->flush(); + + // Return Response + return new JsonResponse(['message' => 'Advert deleted successfully'], Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php new file mode 100644 index 0000000..1828df9 --- /dev/null +++ b/src/Controller/AuthController.php @@ -0,0 +1,63 @@ +entityManager = $entityManager; + $this->passwordHasher = $passwordHasher; + } + #[Route('/register', name: 'user_register', methods: ['POST'])] + public function register(Request $request, ValidatorInterface $validator): JsonResponse + { + $data = json_decode($request->getContent(), true); + + // Create new user + $user = new User(); + $user->setFirstName($data['firstName']); + $user->setLastName($data['lastName']); + $user->setPhoneNumber($data['phoneNumber']); + $user->setEmail($data['email']); + // Hashed password + $hashedPassword = $this->passwordHasher->hashPassword( + $user, + $data['password'] + ); + $user->setPassword($hashedPassword); + // default role USER + $user->setRoles(['ROLE_USER']); + + // User validation + $errors = $validator->validate($user); + if (count($errors) > 0) { + return new JsonResponse(['errors' => (string) $errors], JsonResponse::HTTP_BAD_REQUEST); + } + + // Save user into database + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return new JsonResponse(['status' => 'User created'], JsonResponse::HTTP_CREATED); + } + + #[Route('/login', name: 'login', methods: ['POST'])] + public function login(): void + { + //This route is handled by the firewall JWT + } + +} diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Entity/Adverts.php b/src/Entity/Adverts.php new file mode 100644 index 0000000..ecff4be --- /dev/null +++ b/src/Entity/Adverts.php @@ -0,0 +1,126 @@ +id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): static + { + $this->description = $description; + + return $this; + } + + public function getPrice(): ?float + { + return $this->price; + } + + public function setPrice(float $price): static + { + $this->price = $price; + + return $this; + } + + public function getZipCode(): ?string + { + return $this->zipCode; + } + + public function setZipCode(string $zipCode): static + { + $this->zipCode = $zipCode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(string $city): static + { + $this->city = $city; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..817f38e --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,209 @@ + The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private ?string $password = null; + + #[ORM\Column(length: 255)] + #[Groups(["user"])] + private ?string $firstName = null; + + #[ORM\Column(length: 255)] + #[Groups(["user"])] + private ?string $lastName = null; + + #[ORM\Column(length: 255)] + private ?string $phoneNumber = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Adverts::class, mappedBy: 'user')] + private Collection $adverts; + + public function __construct() + { + $this->adverts = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + 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 + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + * + * @return list + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + 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; + } + + /** + * Méthod getUsername will be use to authentification with JWT. + * @return string + */ + public function getUsername(): string + { + return $this->getUserIdentifier(); + } + + public function getFirstName(): ?string + { + return $this->firstName; + } + + public function setFirstName(string $firstName): static + { + $this->firstName = $firstName; + + return $this; + } + + public function getLastName(): ?string + { + return $this->lastName; + } + + public function setLastName(string $lastName): static + { + $this->lastName = $lastName; + + return $this; + } + + public function getPhoneNumber(): ?string + { + return $this->phoneNumber; + } + + public function setPhoneNumber(string $phoneNumber): static + { + $this->phoneNumber = $phoneNumber; + + return $this; + } + + /** + * @return Collection + */ + public function getAdverts(): Collection + { + return $this->adverts; + } + + public function addAdvert(Adverts $advert): static + { + if (!$this->adverts->contains($advert)) { + $this->adverts->add($advert); + $advert->setUser($this); + } + + return $this; + } + + public function removeAdvert(Adverts $advert): static + { + if ($this->adverts->removeElement($advert)) { + // set the owning side to null (unless already changed) + if ($advert->getUser() === $this) { + $advert->setUser(null); + } + } + + return $this; + } +} diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Repository/AdvertsRepository.php b/src/Repository/AdvertsRepository.php new file mode 100644 index 0000000..2cbaf94 --- /dev/null +++ b/src/Repository/AdvertsRepository.php @@ -0,0 +1,43 @@ + + */ +class AdvertsRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Adverts::class); + } + + // /** + // * @return Adverts[] Returns an array of Adverts objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Adverts + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..4f2804e --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,60 @@ + + */ +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(); + } + + // /** + // * @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/src/Repository/UsersRepository.php b/src/Repository/UsersRepository.php new file mode 100644 index 0000000..3c6568a --- /dev/null +++ b/src/Repository/UsersRepository.php @@ -0,0 +1,43 @@ + + */ +class UsersRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Users::class); + } + + // /** + // * @return Users[] Returns an array of Users 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): ?Users + // { + // return $this->createQueryBuilder('u') + // ->andWhere('u.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +}