feat(directory) : add ReportDocument storage (entity, upload processor, download, cleanup)

This commit is contained in:
Matthieu
2026-06-22 11:51:53 +02:00
parent 5af529d1b2
commit b9538454a9
6 changed files with 444 additions and 0 deletions
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\ReportDocument;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
use function in_array;
/**
* @implements ProcessorInterface<ReportDocument, ReportDocument>
*/
final readonly class ReportDocumentProcessor implements ProcessorInterface
{
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv',
'application/zip', 'application/x-rar-compressed', 'application/gzip',
'application/json', 'application/xml', 'text/xml',
];
private const MIME_TO_EXTENSION = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'text/plain' => 'txt',
'text/csv' => 'csv',
'application/zip' => 'zip',
'application/x-rar-compressed' => 'rar',
'application/gzip' => 'gz',
'application/json' => 'json',
'application/xml' => 'xml',
'text/xml' => 'xml',
];
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private string $uploadDir,
) {}
/**
* @param ReportDocument $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReportDocument
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Creating report documents requires admin privileges.');
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new BadRequestHttpException('No request available.');
}
$document = $this->createUpload($request);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return $document;
}
private function createUpload(Request $request): ReportDocument
{
$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.');
}
$report = $this->resolveReport((string) $request->request->get('commercialReport', ''));
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
$fileSize = $file->getSize();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType));
}
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);
}
$file->move($this->uploadDir, $fileName);
$document = new ReportDocument();
$document->setCommercialReport($report);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
$document->setSize($fileSize);
return $document;
}
private function resolveReport(string $iri): CommercialReport
{
if ('' === $iri) {
throw new BadRequestHttpException('A commercialReport IRI is required.');
}
$report = $this->entityManager->getRepository(CommercialReport::class)->find((int) basename($iri));
if (null === $report) {
throw new BadRequestHttpException('Commercial report not found.');
}
return $report;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Controller;
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ReportDocumentDownloadController
{
private const INLINE_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
];
public function __construct(
private readonly ReportDocumentRepositoryInterface $repository,
private readonly string $uploadDir,
) {}
#[Route('/api/report_documents/{id}/download', name: 'report_document_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(int $id): Response
{
$document = $this->repository->findById($id);
if (null === $document || null === $document->getFileName()) {
throw new NotFoundHttpException('Document not found.');
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (!is_file($filePath)) {
throw new NotFoundHttpException('File missing on disk.');
}
$response = new BinaryFileResponse($filePath);
$mimeType = (string) $document->getMimeType();
$disposition = in_array($mimeType, self::INLINE_MIME_TYPES, true)
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
$response->setContentDisposition($disposition, (string) $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType);
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Doctrine;
use App\Module\Directory\Domain\Entity\ReportDocument;
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ReportDocument>
*/
final class DoctrineReportDocumentRepository extends ServiceEntityRepository implements ReportDocumentRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ReportDocument::class);
}
public function findById(int $id): ?ReportDocument
{
return $this->find($id);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\EventListener;
use App\Module\Directory\Domain\Entity\ReportDocument;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Psr\Log\LoggerInterface;
final readonly class ReportDocumentListener
{
public function __construct(
private string $uploadDir,
private LoggerInterface $logger,
) {}
public function preRemove(ReportDocument $document, PreRemoveEventArgs $args): void
{
$fileName = $document->getFileName();
if (null === $fileName) {
return;
}
$path = $this->uploadDir.'/'.$fileName;
if (is_file($path) && !@unlink($path)) {
$this->logger->warning('Failed to delete report document file', ['path' => $path]);
}
}
}