feat(documents) : filesystem storage, server-side pagination and PDF compression

- Add DocumentStorageService for file-based storage (replaces Base64 in DB)
- Add DocumentServeController with /file and /download endpoints
- Add DocumentUploadProcessor using FormData + filesystem storage
- Add DocumentNormalizer exposing fileUrl/downloadUrl on all responses
- Add DocumentFileCleanupListener for automatic file deletion
- Add MigrateDocumentsToFilesystemCommand (Base64 → files, memory-safe)
- Add ApiFilter (SearchFilter, ExistsFilter, OrderFilter) on Document entity
- Add PdfCompressorService + refactor CompressPdfCommand for batch processing
- Fix TypeMachine PUT: deserialize=false + validate=false to prevent
  UniqueEntity false positive and writableLink collection interference
- Update CHANGELOG for v1.8.0
- Update frontend submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-03 15:18:55 +01:00
parent 7a5dd0b555
commit d89c97f0a0
14 changed files with 942 additions and 90 deletions

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use function strlen;
#[Route('/api/documents')]
class DocumentServeController extends AbstractController
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly DocumentStorageService $storageService,
) {}
#[Route('/{id}/file', name: 'document_serve_file', methods: ['GET'])]
public function serve(string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$document = $this->documentRepository->find($id);
if (!$document) {
return $this->json(['error' => 'Document not found.'], 404);
}
$path = $document->getPath();
// Backward compatibility: serve Base64 data URIs from DB
if ($this->storageService->isBase64DataUri($path)) {
$parts = explode(',', $path, 2);
$content = base64_decode($parts[1] ?? '', true);
if (false === $content) {
return $this->json(['error' => 'Invalid document data.'], 500);
}
return new Response($content, 200, [
'Content-Type' => $document->getMimeType(),
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_INLINE.'; filename="'.$document->getFilename().'"',
'Content-Length' => (string) strlen($content),
'Cache-Control' => 'private, max-age=3600',
]);
}
// File-based path: serve from disk
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
return $this->json(['error' => 'File not found on disk.'], 404);
}
$response = new BinaryFileResponse($absolutePath);
$response->headers->set('Content-Type', $document->getMimeType());
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$document->getFilename()
);
$response->headers->set('Cache-Control', 'private, max-age=3600');
return $response;
}
#[Route('/{id}/download', name: 'document_download_file', methods: ['GET'])]
public function download(string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$document = $this->documentRepository->find($id);
if (!$document) {
return $this->json(['error' => 'Document not found.'], 404);
}
$path = $document->getPath();
if ($this->storageService->isBase64DataUri($path)) {
$parts = explode(',', $path, 2);
$content = base64_decode($parts[1] ?? '', true);
if (false === $content) {
return $this->json(['error' => 'Invalid document data.'], 500);
}
return new Response($content, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="'.$document->getFilename().'"',
'Content-Length' => (string) strlen($content),
]);
}
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
return $this->json(['error' => 'File not found on disk.'], 404);
}
$response = new BinaryFileResponse($absolutePath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$document->getFilename()
);
return $response;
}
}