diff --git a/config/services.yaml b/config/services.yaml index 5ebc718..f2713cc 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,6 +8,7 @@ # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' + avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars' imports: - { resource: version.yaml } @@ -39,3 +40,7 @@ services: App\Controller\TaskDocumentDownloadController: arguments: $uploadDir: '%task_document_upload_dir%' + + App\Controller\UserAvatarController: + arguments: + $avatarUploadDir: '%avatar_upload_dir%' diff --git a/src/Controller/UserAvatarController.php b/src/Controller/UserAvatarController.php new file mode 100644 index 0000000..675ba76 --- /dev/null +++ b/src/Controller/UserAvatarController.php @@ -0,0 +1,145 @@ +findUserOrFail($id); + $this->assertCanManageAvatar($user); + + $file = $request->files->get('file'); + + if (null === $file || !$file->isValid()) { + throw new BadRequestHttpException('No valid file uploaded.'); + } + + if ($file->getSize() > self::MAX_FILE_SIZE) { + throw new BadRequestHttpException('File size exceeds 5 MB limit.'); + } + + $mimeType = $file->getClientMimeType(); + + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.'); + } + + // Delete previous avatar file if exists + $this->deleteAvatarFile($user); + + $extension = $file->guessExtension() ?? 'bin'; + $fileName = Uuid::v4()->toRfc4122().'.'.$extension; + + if (!is_dir($this->avatarUploadDir)) { + mkdir($this->avatarUploadDir, 0o775, true); + } + + $file->move($this->avatarUploadDir, $fileName); + + $user->setAvatarFileName($fileName); + $this->entityManager->flush(); + + return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]); + } + + #[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)] + #[IsGranted('ROLE_USER')] + public function serve(int $id): BinaryFileResponse + { + $user = $this->findUserOrFail($id); + + if (null === $user->getAvatarFileName()) { + throw new NotFoundHttpException('No avatar set.'); + } + + $filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName(); + + if (!file_exists($filePath)) { + throw new NotFoundHttpException('Avatar file not found on disk.'); + } + + $response = new BinaryFileResponse($filePath); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName()); + $extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION); + $mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif']; + $response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream'); + $response->headers->set('Cache-Control', 'public, max-age=86400'); + + return $response; + } + + #[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)] + #[IsGranted('ROLE_USER')] + public function delete(int $id): Response + { + $user = $this->findUserOrFail($id); + $this->assertCanManageAvatar($user); + + $this->deleteAvatarFile($user); + $user->setAvatarFileName(null); + $this->entityManager->flush(); + + return new Response(null, Response::HTTP_NO_CONTENT); + } + + private function findUserOrFail(int $id): User + { + $user = $this->entityManager->getRepository(User::class)->find($id); + + if (null === $user) { + throw new NotFoundHttpException('User not found.'); + } + + return $user; + } + + private function assertCanManageAvatar(User $user): void + { + $currentUser = $this->getUser(); + + if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) { + throw new AccessDeniedHttpException('You can only manage your own avatar.'); + } + } + + private function deleteAvatarFile(User $user): void + { + if (null === $user->getAvatarFileName()) { + return; + } + + $filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName(); + + if (file_exists($filePath)) { + unlink($filePath); + } + } +}