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,141 @@
<?php
declare(strict_types=1);
namespace App\Service;
use DateTimeImmutable;
use RuntimeException;
use Symfony\Component\HttpKernel\KernelInterface;
use function dirname;
class DocumentStorageService
{
private readonly string $storageDir;
public function __construct(
private readonly KernelInterface $kernel,
) {
$this->storageDir = $this->kernel->getProjectDir().'/var/storage/documents';
}
public function getStorageDir(): string
{
return $this->storageDir;
}
public function getAbsolutePath(string $relativePath): string
{
return $this->storageDir.'/'.$relativePath;
}
/**
* Store binary content and return the relative path.
* Path format: {year}/{month}/{documentId}.{ext}.
*/
public function store(string $content, string $documentId, string $extension): string
{
$now = new DateTimeImmutable();
$subDir = $now->format('Y').'/'.$now->format('m');
$relativePath = $subDir.'/'.$documentId.'.'.$extension;
$absolutePath = $this->storageDir.'/'.$relativePath;
$dir = dirname($absolutePath);
if (!is_dir($dir)) {
if (!mkdir($dir, 0o775, true) && !is_dir($dir)) {
throw new RuntimeException(sprintf('Cannot create directory "%s"', $dir));
}
}
$bytesWritten = file_put_contents($absolutePath, $content);
if (false === $bytesWritten) {
throw new RuntimeException(sprintf('Cannot write file "%s"', $absolutePath));
}
return $relativePath;
}
/**
* Store a file from a given source path (e.g., temp upload).
*/
public function storeFromPath(string $sourcePath, string $documentId, string $extension): string
{
$content = file_get_contents($sourcePath);
if (false === $content) {
throw new RuntimeException(sprintf('Cannot read source file "%s"', $sourcePath));
}
return $this->store($content, $documentId, $extension);
}
public function read(string $relativePath): string
{
$absolutePath = $this->getAbsolutePath($relativePath);
if (!file_exists($absolutePath)) {
throw new RuntimeException(sprintf('File not found: "%s"', $absolutePath));
}
$content = file_get_contents($absolutePath);
if (false === $content) {
throw new RuntimeException(sprintf('Cannot read file "%s"', $absolutePath));
}
return $content;
}
public function delete(string $relativePath): bool
{
$absolutePath = $this->getAbsolutePath($relativePath);
if (!file_exists($absolutePath)) {
return false;
}
return @unlink($absolutePath);
}
public function exists(string $relativePath): bool
{
return file_exists($this->getAbsolutePath($relativePath));
}
public function isBase64DataUri(string $path): bool
{
return str_starts_with($path, 'data:');
}
public function extensionFromMimeType(string $mimeType): string
{
$map = [
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/svg+xml' => 'svg',
'image/bmp' => 'bmp',
'text/plain' => 'txt',
'text/csv' => 'csv',
'application/json' => 'json',
'application/xml' => 'xml',
'application/zip' => 'zip',
'audio/mpeg' => 'mp3',
'audio/ogg' => 'ogg',
'video/mp4' => 'mp4',
'video/webm' => 'webm',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/msword' => 'doc',
'application/vnd.ms-excel' => 'xls',
];
return $map[$mimeType] ?? 'bin';
}
public function extensionFromFilename(string $filename): string
{
$ext = pathinfo($filename, PATHINFO_EXTENSION);
return '' !== $ext ? strtolower($ext) : 'bin';
}
}