- 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>
129 lines
4.5 KiB
PHP
129 lines
4.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\State;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use App\Entity\Document;
|
|
use App\Service\DocumentStorageService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
|
|
final class DocumentUploadProcessor implements ProcessorInterface
|
|
{
|
|
public function __construct(
|
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
private readonly ProcessorInterface $decorated,
|
|
private readonly RequestStack $requestStack,
|
|
private readonly DocumentStorageService $storageService,
|
|
private readonly EntityManagerInterface $em,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $uriVariables
|
|
* @param array<string, mixed> $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);
|
|
}
|
|
}
|
|
}
|