feat : add document upload processor, download controller and cleanup listener
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# 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
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
|
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
|
||||||
|
|
||||||
imports:
|
imports:
|
||||||
- { resource: version.yaml }
|
- { resource: version.yaml }
|
||||||
@@ -24,3 +25,15 @@ services:
|
|||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# 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%'
|
||||||
|
|||||||
52
src/Controller/TaskDocumentDownloadController.php
Normal file
52
src/Controller/TaskDocumentDownloadController.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\TaskDocument;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
class TaskDocumentDownloadController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly string $uploadDir,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function __invoke(int $id): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$document = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,27 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\EventListener;
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,90 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
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<TaskDocument, TaskDocument>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user