diff --git a/CHANGELOG.md b/CHANGELOG.md index 6793d61..604e4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.8.0] - 2026-03-03 + +### Ajouts +- **Stockage documents sur disque** : les documents sont desormais stockes en fichiers sur le systeme de fichiers au lieu de Base64 en base de donnees. Les endpoints `/api/documents/{id}/file` et `/api/documents/{id}/download` servent les fichiers directement. +- **Commande de migration** `app:migrate-documents-to-filesystem` : migre les documents existants (Base64 → fichiers) avec dry-run, batch-size et limit. +- **Pagination serveur sur la page Documents** : recherche, tri (date/nom/taille), filtre par rattachement (site/machine/composant/piece/produit), selecteur par page (20/50/100). +- **Compression PDF automatique** : les documents PDF uploades sont compresses automatiquement via Ghostscript. Commande `app:compress-pdf` pour compresser les PDFs existants. +- **Nettoyage automatique des fichiers** : suppression du fichier sur disque lors de la suppression d'un document. +- **Champ description** sur les entites Piece et Composant, visible dans les catalogues avec popover au survol. + +### Corrections +- Fix normalisation des documents : `fileUrl` et `downloadUrl` toujours exposes dans l'API (meme sans `path` dans le groupe de serialisation). +- Fix recursion infinie dans `DocumentNormalizer` (`getSupportedTypes` retourne `false` pour desactiver le cache). +- Fix edition de squelettes machines : `deserialize: false` + `validate: false` sur le PUT pour eviter le conflit UniqueEntity et l'interference du deserialiseur avec les collections writableLink. +- Fix sites : ajout operation PATCH et correction migration contrainte. +- Retrocompatibilite : le controleur de service gere transparentement les anciens documents Base64 et les nouveaux fichiers. + +### Migration requise +```bash +docker compose exec php php bin/console doctrine:migrations:migrate +docker compose exec php php bin/console app:migrate-documents-to-filesystem +``` + ## [1.7.0] - 2026-03-02 ### Ajouts diff --git a/Inventory_frontend b/Inventory_frontend index 546cc37..e88ed5b 160000 --- a/Inventory_frontend +++ b/Inventory_frontend @@ -1 +1 @@ -Subproject commit 546cc37a09a1eea69c9b931fd85fa64b4c8170e3 +Subproject commit e88ed5b8f20ddba1bafdc5ce036839357c6df200 diff --git a/src/Command/CompressPdfCommand.php b/src/Command/CompressPdfCommand.php index ee3232c..d3e7119 100644 --- a/src/Command/CompressPdfCommand.php +++ b/src/Command/CompressPdfCommand.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace App\Command; +use App\Entity\Document; use App\Repository\DocumentRepository; +use App\Service\DocumentStorageService; +use App\Service\PdfCompressorService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -13,15 +16,20 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use function count; +use function strlen; + #[AsCommand( name: 'app:compress-pdf', - description: 'Compress all PDF documents stored in database without quality loss', + description: 'Compress all PDF documents without quality loss', )] class CompressPdfCommand extends Command { public function __construct( private readonly DocumentRepository $documentRepository, private readonly EntityManagerInterface $em, + private readonly PdfCompressorService $pdfCompressor, + private readonly DocumentStorageService $storageService, ) { parent::__construct(); } @@ -61,87 +69,13 @@ class CompressPdfCommand extends Command $compressed = 0; foreach ($documents as $document) { - $base64Data = $document->getPath(); + $path = $document->getPath(); - // Remove data URI prefix if present - if (str_contains($base64Data, ',')) { - $base64Data = explode(',', $base64Data, 2)[1]; - } - - $pdfContent = base64_decode($base64Data, true); - if (false === $pdfContent) { - $io->warning(sprintf('Failed to decode document: %s', $document->getName())); - - continue; - } - - $originalSize = strlen($pdfContent); - - if ($dryRun) { - $io->text(sprintf( - ' [DRY-RUN] Would compress: %s (%s)', - $document->getName(), - $this->formatBytes($originalSize) - )); - - continue; - } - - // Create temp files - $tempInput = tempnam(sys_get_temp_dir(), 'pdf_in_'); - $tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_'); - - file_put_contents($tempInput, $pdfContent); - - // Compress with qpdf (lossless) - $command = sprintf( - 'qpdf --linearize --object-streams=generate %s %s 2>&1', - escapeshellarg($tempInput), - escapeshellarg($tempOutput) - ); - - exec($command, $cmdOutput, $returnCode); - - if (0 !== $returnCode || !file_exists($tempOutput)) { - $io->warning(sprintf('Failed to compress: %s', $document->getName())); - @unlink($tempInput); - @unlink($tempOutput); - - continue; - } - - $compressedContent = file_get_contents($tempOutput); - $compressedSize = strlen($compressedContent); - - // Only update if we actually saved space - if ($compressedSize < $originalSize) { - $saved = $originalSize - $compressedSize; - $totalSaved += $saved; - ++$compressed; - - // Rebuild base64 with data URI prefix - $newBase64 = 'data:application/pdf;base64,'.base64_encode($compressedContent); - $document->setPath($newBase64); - $document->setSize($compressedSize); - - $io->text(sprintf( - ' ✓ %s: %s → %s (-%s, -%.1f%%)', - $document->getName(), - $this->formatBytes($originalSize), - $this->formatBytes($compressedSize), - $this->formatBytes($saved), - ($saved / $originalSize) * 100 - )); + if ($this->storageService->isBase64DataUri($path)) { + $this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed); } else { - $io->text(sprintf( - ' - %s: Already optimal (%s)', - $document->getName(), - $this->formatBytes($originalSize) - )); + $this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed); } - - @unlink($tempInput); - @unlink($tempOutput); } if (!$dryRun && $compressed > 0) { @@ -161,6 +95,115 @@ class CompressPdfCommand extends Command return Command::SUCCESS; } + private function compressBase64Document( + Document $document, + string $path, + bool $dryRun, + SymfonyStyle $io, + int &$totalSaved, + int &$compressed, + ): void { + $base64Data = $path; + if (str_contains($base64Data, ',')) { + $base64Data = explode(',', $base64Data, 2)[1]; + } + + $pdfContent = base64_decode($base64Data, true); + if (false === $pdfContent) { + $io->warning(sprintf('Failed to decode document: %s', $document->getName())); + + return; + } + + $originalSize = strlen($pdfContent); + + if ($dryRun) { + $io->text(sprintf( + ' [DRY-RUN] Would compress (base64): %s (%s)', + $document->getName(), + $this->formatBytes($originalSize) + )); + + return; + } + + $result = $this->pdfCompressor->compressBase64Pdf($path); + if (null !== $result) { + $document->setPath($result['path']); + $document->setSize($result['size']); + $totalSaved += $result['saved']; + ++$compressed; + + $io->text(sprintf( + ' OK %s: %s → %s (-%s, -%.1f%%)', + $document->getName(), + $this->formatBytes($result['originalSize']), + $this->formatBytes($result['size']), + $this->formatBytes($result['saved']), + ($result['saved'] / $result['originalSize']) * 100 + )); + } else { + $io->text(sprintf( + ' - %s: Already optimal (%s)', + $document->getName(), + $this->formatBytes($originalSize) + )); + } + } + + private function compressFileDocument( + Document $document, + string $path, + bool $dryRun, + SymfonyStyle $io, + int &$totalSaved, + int &$compressed, + ): void { + $absolutePath = $this->storageService->getAbsolutePath($path); + if (!file_exists($absolutePath)) { + $io->warning(sprintf('File not found: %s (%s)', $document->getName(), $path)); + + return; + } + + $originalSize = filesize($absolutePath); + if (false === $originalSize) { + return; + } + + if ($dryRun) { + $io->text(sprintf( + ' [DRY-RUN] Would compress (file): %s (%s)', + $document->getName(), + $this->formatBytes($originalSize) + )); + + return; + } + + $result = $this->pdfCompressor->compressFile($absolutePath); + if (null !== $result) { + $document->setSize($result['size']); + $totalSaved += $result['saved']; + ++$compressed; + + $io->text(sprintf( + ' OK %s: %s → %s (-%s, -%.1f%%)', + $document->getName(), + $this->formatBytes($result['originalSize']), + $this->formatBytes($result['size']), + $this->formatBytes($result['saved']), + ($result['saved'] / $result['originalSize']) * 100 + )); + } else { + $io->text(sprintf( + ' - %s: Already optimal (%s)', + $document->getName(), + $this->formatBytes($originalSize) + )); + } + } + private function formatBytes(int $bytes): string { $units = ['B', 'KB', 'MB', 'GB']; diff --git a/src/Command/MigrateDocumentsToFilesystemCommand.php b/src/Command/MigrateDocumentsToFilesystemCommand.php new file mode 100644 index 0000000..7265b84 --- /dev/null +++ b/src/Command/MigrateDocumentsToFilesystemCommand.php @@ -0,0 +1,218 @@ +addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be migrated without making changes') + ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Number of documents to process before flushing', '50') + ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Max documents to migrate (for testing)', '0') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = $input->getOption('dry-run'); + $batchSize = (int) $input->getOption('batch-size'); + $limit = (int) $input->getOption('limit'); + + $io->title('Document Storage Migration: Base64 → Filesystem'); + + // Verify storage directory is writable + $storageDir = $this->storageService->getStorageDir(); + if (!$dryRun) { + if (!is_dir($storageDir)) { + mkdir($storageDir, 0o775, true); + } + if (!is_writable($storageDir)) { + $io->error("Storage directory is not writable: {$storageDir}"); + + return Command::FAILURE; + } + $io->text("Storage directory: {$storageDir}"); + } + + // Step 1: fetch only IDs of Base64 documents (no heavy path column loaded) + $conn = $this->em->getConnection(); + $ids = $conn->fetchFirstColumn("SELECT id FROM documents WHERE path LIKE 'data:%'"); + $total = count($ids); + $migrated = 0; + $skipped = 0; + $errors = 0; + $totalBytes = 0; + + $io->text(sprintf('Found %d documents with Base64 data to migrate', $total)); + + if (0 === $total) { + $io->success('Nothing to migrate — all documents are already file-based.'); + + return Command::SUCCESS; + } + + // Step 2: process one document at a time to avoid memory exhaustion + foreach ($ids as $index => $docId) { + if ($limit > 0 && $migrated >= $limit) { + $io->text("Reached limit of {$limit} documents."); + + break; + } + + // Fetch single row with raw SQL to keep memory flat + $row = $conn->fetchAssociative( + 'SELECT id, name, filename, path, mimetype, size FROM documents WHERE id = ?', + [$docId] + ); + + if (!$row) { + ++$skipped; + + continue; + } + + $path = $row['path']; + if (!$this->storageService->isBase64DataUri($path)) { + ++$skipped; + + continue; + } + + $docName = $row['name'] ?: $row['filename']; + $filename = $row['filename'] ?: $row['name']; + $mimeType = $row['mimetype'] ?? 'application/octet-stream'; + + // Extract binary content from data URI + $parts = explode(',', $path, 2); + $base64 = $parts[1] ?? ''; + $content = base64_decode($base64, true); + + // Free the raw row immediately + unset($row, $path, $base64, $parts); + + if (false === $content || '' === $content) { + $io->warning(sprintf('[%d/%d] Cannot decode: %s (id: %s)', $index + 1, $total, $docName, $docId)); + ++$errors; + + continue; + } + + $fileSize = strlen($content); + $extension = $this->storageService->extensionFromFilename( + $filename ?: ('file.'.$this->storageService->extensionFromMimeType($mimeType)) + ); + + if ($dryRun) { + $io->text(sprintf( + ' [DRY-RUN] Would migrate: %s (%s)', + $docName, + $this->formatBytes($fileSize) + )); + ++$migrated; + $totalBytes += $fileSize; + unset($content); + + continue; + } + + try { + $relativePath = $this->storageService->store($content, $docId, $extension); + unset($content); + + // Update DB directly — avoid loading entity with huge path + $conn->executeStatement( + 'UPDATE documents SET path = ?, size = ? WHERE id = ?', + [$relativePath, $fileSize, $docId] + ); + + ++$migrated; + $totalBytes += $fileSize; + + $io->text(sprintf( + ' [OK] %s → %s (%s)', + $docName, + $relativePath, + $this->formatBytes($fileSize) + )); + } catch (Throwable $e) { + unset($content); + $io->error(sprintf( + ' [FAIL] %s: %s', + $docName, + $e->getMessage() + )); + ++$errors; + + continue; + } + + if (0 === $migrated % $batchSize) { + $io->text(sprintf(' ... %d migrated so far', $migrated)); + } + } + + $io->newLine(); + $io->table( + ['Metric', 'Count'], + [ + ['Total documents', (string) $total], + ['Migrated', (string) $migrated], + ['Skipped (already file-based)', (string) $skipped], + ['Errors', (string) $errors], + ['Total bytes written', $this->formatBytes($totalBytes)], + ] + ); + + if ($dryRun) { + $io->info('Dry run completed. No changes were made.'); + } elseif ($errors > 0) { + $io->warning(sprintf('Migration completed with %d errors.', $errors)); + } else { + $io->success('Migration completed successfully.'); + } + + return $errors > 0 ? Command::FAILURE : Command::SUCCESS; + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $i = 0; + while ($bytes >= 1024 && $i < count($units) - 1) { + $bytes /= 1024; + ++$i; + } + + return round($bytes, 2).' '.$units[$i]; + } +} diff --git a/src/Controller/DocumentQueryController.php b/src/Controller/DocumentQueryController.php index f59c512..0055d20 100644 --- a/src/Controller/DocumentQueryController.php +++ b/src/Controller/DocumentQueryController.php @@ -112,7 +112,8 @@ class DocumentQueryController extends AbstractController 'id' => $document->getId(), 'name' => $document->getName(), 'filename' => $document->getFilename(), - 'path' => $document->getPath(), + 'fileUrl' => '/api/documents/'.$document->getId().'/file', + 'downloadUrl' => '/api/documents/'.$document->getId().'/download', 'mimeType' => $document->getMimeType(), 'size' => $document->getSize(), 'siteId' => $document->getSite()?->getId(), diff --git a/src/Controller/DocumentServeController.php b/src/Controller/DocumentServeController.php new file mode 100644 index 0000000..c598572 --- /dev/null +++ b/src/Controller/DocumentServeController.php @@ -0,0 +1,109 @@ +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; + } +} diff --git a/src/Entity/Document.php b/src/Entity/Document.php index b5c78d6..bdaafcd 100644 --- a/src/Entity/Document.php +++ b/src/Entity/Document.php @@ -4,6 +4,10 @@ declare(strict_types=1); namespace App\Entity; +use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; @@ -11,6 +15,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Repository\DocumentRepository; +use App\State\DocumentUploadProcessor; use DateTimeImmutable; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -19,6 +24,9 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Entity(repositoryClass: DocumentRepository::class)] #[ORM\Table(name: 'documents')] #[ORM\HasLifecycleCallbacks] +#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial', 'filename' => 'partial'])] +#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])] +#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])] #[ApiResource( operations: [ new GetCollection( @@ -29,12 +37,18 @@ use Symfony\Component\Serializer\Attribute\Groups; security: "is_granted('ROLE_VIEWER')", normalizationContext: ['groups' => ['document:list', 'document:detail']], ), - new Post(security: "is_granted('ROLE_GESTIONNAIRE')"), + new Post( + security: "is_granted('ROLE_GESTIONNAIRE')", + processor: DocumentUploadProcessor::class, + deserialize: false, + inputFormats: ['multipart' => ['multipart/form-data']], + ), new Put(security: "is_granted('ROLE_GESTIONNAIRE')"), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), ], paginationClientItemsPerPage: true, - paginationMaximumItemsPerPage: 200 + paginationMaximumItemsPerPage: 500, + order: ['createdAt' => 'DESC'] )] class Document { diff --git a/src/Entity/TypeMachine.php b/src/Entity/TypeMachine.php index 2bf3dfb..b8795fb 100644 --- a/src/Entity/TypeMachine.php +++ b/src/Entity/TypeMachine.php @@ -31,7 +31,7 @@ use Symfony\Component\Validator\Constraints as Assert; new Get(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"), new Post(security: "is_granted('ROLE_GESTIONNAIRE')"), - new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: TypeMachinePutProcessor::class), + new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: TypeMachinePutProcessor::class, deserialize: false, validate: false), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), ], paginationClientItemsPerPage: true, diff --git a/src/EventListener/DocumentFileCleanupListener.php b/src/EventListener/DocumentFileCleanupListener.php new file mode 100644 index 0000000..ab4b363 --- /dev/null +++ b/src/EventListener/DocumentFileCleanupListener.php @@ -0,0 +1,44 @@ +getPath(); + + // Do not attempt file deletion for Base64 data URIs + if ($this->storageService->isBase64DataUri($path)) { + return; + } + + $deleted = $this->storageService->delete($path); + + if ($deleted) { + $this->logger?->info('Document file deleted from disk', [ + 'documentId' => $document->getId(), + 'path' => $path, + ]); + } else { + $this->logger?->warning('Document file not found on disk during cleanup', [ + 'documentId' => $document->getId(), + 'path' => $path, + ]); + } + } +} diff --git a/src/EventListener/DocumentPdfCompressorListener.php b/src/EventListener/DocumentPdfCompressorListener.php index 85e00b9..b4800cd 100644 --- a/src/EventListener/DocumentPdfCompressorListener.php +++ b/src/EventListener/DocumentPdfCompressorListener.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\EventListener; use App\Entity\Document; +use App\Service\DocumentStorageService; use App\Service\PdfCompressorService; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; use Doctrine\ORM\Events; @@ -16,6 +17,7 @@ class DocumentPdfCompressorListener { public function __construct( private readonly PdfCompressorService $pdfCompressor, + private readonly DocumentStorageService $storageService, private readonly ?LoggerInterface $logger = null, ) {} @@ -35,15 +37,26 @@ class DocumentPdfCompressorListener return; } - $result = $this->pdfCompressor->compressBase64Pdf($document->getPath()); + $path = $document->getPath(); - if (null === $result) { - return; + if ($this->storageService->isBase64DataUri($path)) { + // Legacy Base64 path + $result = $this->pdfCompressor->compressBase64Pdf($path); + if (null === $result) { + return; + } + $document->setPath($result['path']); + $document->setSize($result['size']); + } else { + // File-based path + $absolutePath = $this->storageService->getAbsolutePath($path); + $result = $this->pdfCompressor->compressFile($absolutePath); + if (null === $result) { + return; + } + $document->setSize($result['size']); } - $document->setPath($result['path']); - $document->setSize($result['size']); - $this->logger?->info('PDF compressed', [ 'document' => $document->getName(), 'originalSize' => $result['originalSize'], diff --git a/src/Serializer/DocumentNormalizer.php b/src/Serializer/DocumentNormalizer.php new file mode 100644 index 0000000..61fe2e3 --- /dev/null +++ b/src/Serializer/DocumentNormalizer.php @@ -0,0 +1,61 @@ +|ArrayObject|bool|float|int|string + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array|ArrayObject|bool|float|int|string|null + { + $context[self::ALREADY_CALLED] = true; + + /** @var null|array $normalized */ + $normalized = $this->normalizer->normalize($data, $format, $context); + + if (is_array($normalized) && $data instanceof Document && $data->getId()) { + // Remove raw 'path' if present (never expose it to the client) + unset($normalized['path']); + + // Always provide URL-based access + $normalized['fileUrl'] = '/api/documents/'.$data->getId().'/file'; + $normalized['downloadUrl'] = '/api/documents/'.$data->getId().'/download'; + } + + return $normalized; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + if (isset($context[self::ALREADY_CALLED])) { + return false; + } + + return $data instanceof Document; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + Document::class => false, + ]; + } +} diff --git a/src/Service/DocumentStorageService.php b/src/Service/DocumentStorageService.php new file mode 100644 index 0000000..724c7dc --- /dev/null +++ b/src/Service/DocumentStorageService.php @@ -0,0 +1,141 @@ +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'; + } +} diff --git a/src/Service/PdfCompressorService.php b/src/Service/PdfCompressorService.php index 9ebe502..cf1df3a 100644 --- a/src/Service/PdfCompressorService.php +++ b/src/Service/PdfCompressorService.php @@ -6,6 +6,63 @@ namespace App\Service; class PdfCompressorService { + /** + * Compress an actual PDF file on disk. Returns metadata or null if no gain. + * + * @return null|array{size: int, originalSize: int, saved: int} + */ + public function compressFile(string $absolutePath): ?array + { + exec('which qpdf', $qpdfPath, $returnCode); + if (0 !== $returnCode) { + return null; + } + + if (!file_exists($absolutePath)) { + return null; + } + + $originalSize = filesize($absolutePath); + if (false === $originalSize) { + return null; + } + + $tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_'); + + $command = sprintf( + 'qpdf --linearize --object-streams=generate %s %s 2>&1', + escapeshellarg($absolutePath), + escapeshellarg($tempOutput) + ); + + exec($command, $cmdOutput, $returnCode); + + if (0 !== $returnCode || !file_exists($tempOutput)) { + @unlink($tempOutput); + + return null; + } + + $compressedSize = filesize($tempOutput); + if (false === $compressedSize || $compressedSize >= $originalSize) { + @unlink($tempOutput); + + return null; + } + + if (!rename($tempOutput, $absolutePath)) { + @unlink($tempOutput); + + return null; + } + + return [ + 'size' => $compressedSize, + 'originalSize' => $originalSize, + 'saved' => $originalSize - $compressedSize, + ]; + } + public function compressBase64Pdf(string $base64Data): ?array { // Check if qpdf is available diff --git a/src/State/DocumentUploadProcessor.php b/src/State/DocumentUploadProcessor.php new file mode 100644 index 0000000..2d67088 --- /dev/null +++ b/src/State/DocumentUploadProcessor.php @@ -0,0 +1,128 @@ + $uriVariables + * @param array $context + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + throw new BadRequestHttpException('No request available.'); + } + + $contentType = $request->headers->get('Content-Type', ''); + + // Multipart/form-data → file upload + if (str_contains($contentType, 'multipart/form-data')) { + return $this->handleMultipartUpload($operation, $uriVariables, $context, $request); + } + + // Fallback to default processor for legacy JSON/Base64 requests + if (!$data instanceof Document) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + private function handleMultipartUpload( + Operation $operation, + array $uriVariables, + array $context, + Request $request, + ): Document { + /** @var null|UploadedFile $file */ + $file = $request->files->get('file'); + if (null === $file || !$file->isValid()) { + throw new BadRequestHttpException('A valid file is required in the "file" field.'); + } + + $document = new Document(); + + // Metadata from form fields + $name = $request->request->get('name', $file->getClientOriginalName()); + $filename = $file->getClientOriginalName(); + $mimeType = $file->getMimeType() ?: $request->request->get('mimeType', 'application/octet-stream'); + $size = $file->getSize(); + + $document->setName($name); + $document->setFilename($filename); + $document->setMimeType($mimeType); + $document->setSize((int) $size); + + // Handle entity relations from form fields + $this->setRelationsFromRequest($document, $request); + + // Generate CUID early so we can use it for the filename on disk + $documentId = 'cl'.bin2hex(random_bytes(12)); + $document->setId($documentId); + + // Store file on disk + $extension = $this->storageService->extensionFromFilename($filename); + $relativePath = $this->storageService->storeFromPath( + $file->getPathname(), + $documentId, + $extension, + ); + $document->setPath($relativePath); + + // Persist via decorated processor (triggers prePersist for timestamps) + return $this->decorated->process($document, $operation, $uriVariables, $context); + } + + private function setRelationsFromRequest(Document $document, Request $request): void + { + $relationMap = [ + 'machineId' => 'Machine', + 'composantId' => 'Composant', + 'pieceId' => 'Piece', + 'productId' => 'Product', + 'siteId' => 'Site', + ]; + + foreach ($relationMap as $field => $entityName) { + $value = $request->request->get($field); + if (!$value) { + continue; + } + + // Accept both raw ID and IRI format + $id = $value; + if (str_contains($value, '/')) { + $parts = explode('/', rtrim($value, '/')); + $id = end($parts); + } + + $entityClass = 'App\Entity\\'.$entityName; + $entity = $this->em->getReference($entityClass, $id); + $setter = 'set'.$entityName; + $document->{$setter}($entity); + } + } +}