- Add login_throttling on /login_check (5 attempts/min) with symfony/rate-limiter - Add Cache-Control: public, max-age=86400 on avatar responses - Remove symfony/twig-bundle (unused in API-only project) - Remove unused dev deps: symfony/browser-kit, symfony/css-selector - Rename API Platform title to "Lesstime API" Tickets: T-010, T-016, T-022, T-024, T-025 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
4.9 KiB
PHP
146 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Entity\User;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
class UserAvatarController extends AbstractController
|
|
{
|
|
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
|
|
public function __construct(
|
|
private readonly EntityManagerInterface $entityManager,
|
|
private readonly string $avatarUploadDir,
|
|
) {}
|
|
|
|
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
|
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
|
public function upload(int $id, Request $request): JsonResponse
|
|
{
|
|
$user = $this->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->getMimeType();
|
|
|
|
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('IS_AUTHENTICATED_FULLY')]
|
|
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('IS_AUTHENTICATED_FULLY')]
|
|
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);
|
|
}
|
|
}
|
|
}
|