diff --git a/config/services.yaml b/config/services.yaml index 49aba49..221ef22 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,6 +7,7 @@ # 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: + task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' imports: - { resource: version.yaml } @@ -24,3 +25,15 @@ services: # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones + + App\EventListener\TaskDocumentListener: + arguments: + $uploadDir: '%task_document_upload_dir%' + + App\State\TaskDocumentProcessor: + arguments: + $uploadDir: '%task_document_upload_dir%' + + App\Controller\TaskDocumentDownloadController: + arguments: + $uploadDir: '%task_document_upload_dir%' diff --git a/src/Controller/TaskDocumentDownloadController.php b/src/Controller/TaskDocumentDownloadController.php new file mode 100644 index 0000000..17ef4c2 --- /dev/null +++ b/src/Controller/TaskDocumentDownloadController.php @@ -0,0 +1,52 @@ +entityManager->getRepository(TaskDocument::class)->find($id); + + if (null === $document) { + throw new NotFoundHttpException('Document not found.'); + } + + $filePath = $this->uploadDir.'/'.$document->getFileName(); + + if (!file_exists($filePath)) { + throw new NotFoundHttpException('File not found on disk.'); + } + + $response = new BinaryFileResponse($filePath); + $mimeType = $document->getMimeType() ?? 'application/octet-stream'; + + // Inline for images and PDFs, attachment for everything else + $disposition = str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType + ? ResponseHeaderBag::DISPOSITION_INLINE + : ResponseHeaderBag::DISPOSITION_ATTACHMENT; + + $response->setContentDisposition($disposition, $document->getOriginalName()); + $response->headers->set('Content-Type', $mimeType); + + return $response; + } +} diff --git a/src/EventListener/TaskDocumentListener.php b/src/EventListener/TaskDocumentListener.php index 87ff2fa..997f2f0 100644 --- a/src/EventListener/TaskDocumentListener.php +++ b/src/EventListener/TaskDocumentListener.php @@ -4,4 +4,27 @@ declare(strict_types=1); namespace App\EventListener; -class TaskDocumentListener {} +use App\Entity\TaskDocument; +use Doctrine\ORM\Event\PreRemoveEventArgs; +use Psr\Log\LoggerInterface; + +class TaskDocumentListener +{ + public function __construct( + private readonly string $uploadDir, + private readonly LoggerInterface $logger, + ) {} + + public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void + { + $filePath = $this->uploadDir.'/'.$document->getFileName(); + + if (file_exists($filePath)) { + if (!unlink($filePath)) { + $this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]); + } + } else { + $this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]); + } + } +} diff --git a/src/State/TaskDocumentProcessor.php b/src/State/TaskDocumentProcessor.php index 45d6c6e..3ed8ed6 100644 --- a/src/State/TaskDocumentProcessor.php +++ b/src/State/TaskDocumentProcessor.php @@ -6,11 +6,90 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; +use App\Entity\Task; +use App\Entity\TaskDocument; +use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Uid\Uuid; -class TaskDocumentProcessor implements ProcessorInterface +/** + * @implements ProcessorInterface + */ +final readonly class TaskDocumentProcessor implements ProcessorInterface { - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private RequestStack $requestStack, + private string $uploadDir, + ) {} + + /** + * @param TaskDocument $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument { - return $data; + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + throw new BadRequestHttpException('No request available.'); + } + + $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 50 MB limit.'); + } + + $taskIri = $request->request->get('task'); + + if (null === $taskIri || '' === $taskIri) { + throw new BadRequestHttpException('Task IRI is required.'); + } + + // Extract task ID from IRI (e.g., "/api/tasks/42" -> 42) + $taskId = (int) basename((string) $taskIri); + $task = $this->entityManager->getRepository(Task::class)->find($taskId); + + if (null === $task) { + throw new BadRequestHttpException('Task not found.'); + } + + // Capture file metadata BEFORE move() — move invalidates the temp file + $originalName = $file->getClientOriginalName(); + $extension = $file->getClientOriginalExtension() ?: 'bin'; + $mimeType = $file->getClientMimeType() ?? 'application/octet-stream'; + $fileSize = $file->getSize(); + $uuid = Uuid::v4()->toRfc4122(); + $fileName = $uuid.'.'.$extension; + + if (!is_dir($this->uploadDir)) { + mkdir($this->uploadDir, 0o775, true); + } + + $file->move($this->uploadDir, $fileName); + + $document = new TaskDocument(); + $document->setTask($task); + $document->setOriginalName($originalName); + $document->setFileName($fileName); + $document->setMimeType($mimeType); + $document->setSize($fileSize); + $document->setCreatedAt(new DateTimeImmutable()); + $document->setUploadedBy($this->security->getUser()); + + $this->entityManager->persist($document); + $this->entityManager->flush(); + + return $document; } }