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:
141
src/Service/DocumentStorageService.php
Normal file
141
src/Service/DocumentStorageService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user