*/ final readonly class TaskDocumentProcessor implements ProcessorInterface { private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB private const ALLOWED_MIME_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', 'application/zip', 'application/x-rar-compressed', 'application/gzip', 'application/json', 'application/xml', 'text/xml', ]; private const MIME_TO_EXTENSION = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', 'application/msword' => 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', 'application/vnd.ms-excel' => 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', 'application/vnd.ms-powerpoint' => 'ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', 'text/plain' => 'txt', 'text/csv' => 'csv', 'application/zip' => 'zip', 'application/x-rar-compressed' => 'rar', 'application/gzip' => 'gz', 'application/json' => 'json', 'application/xml' => 'xml', 'text/xml' => 'xml', ]; public function __construct( private EntityManagerInterface $entityManager, private Security $security, private RequestStack $requestStack, private FileSource $fileSource, private SharePathResolver $pathResolver, private string $uploadDir, ) {} /** * @param TaskDocument $data */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument { // Défense en profondeur : l'opération Post est déjà protégée par // ROLE_ADMIN ou ROLE_CLIENT, mais on re-vérifie ici pour que les deux // chemins (upload ET lien partage) restent sûrs si la configuration de // sécurité de l'opération venait à changer. if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_CLIENT')) { throw new AccessDeniedHttpException('Creating documents requires admin or client privileges.'); } $request = $this->requestStack->getCurrentRequest(); if (null === $request) { throw new BadRequestHttpException('No request available.'); } // Deux modes de création : upload d'un fichier (multipart) ou lien vers un fichier du partage SMB (JSON). $sharePath = $this->extractSharePath($request); // Sécurité : un utilisateur client ne peut PAS créer de lien vers le // partage SMB interne (référence de fichier arbitraire hors de son // périmètre) — seul le téléversement lui est permis. Le lien partage // reste réservé aux administrateurs. if (null !== $sharePath && !$this->security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedHttpException('Les utilisateurs clients ne peuvent pas créer de lien vers le partage ; un téléversement est requis.'); } $document = null !== $sharePath ? $this->createShareLink($request, $sharePath) : $this->createUpload($request); $document->setCreatedAt(new DateTimeImmutable()); $document->setUploadedBy($this->security->getUser()); $this->entityManager->persist($document); $this->entityManager->flush(); return $document; } private function extractSharePath(Request $request): ?string { // Lien SMB : champ multipart/form OU corps JSON { "sharePath": "..." } $fromForm = $request->request->get('sharePath'); if (is_string($fromForm) && '' !== $fromForm) { return $fromForm; } if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) { $payload = json_decode($request->getContent() ?: '{}', true); if (is_array($payload) && isset($payload['sharePath']) && is_string($payload['sharePath']) && '' !== $payload['sharePath']) { return $payload['sharePath']; } } return null; } private function createUpload(Request $request): TaskDocument { $file = $request->files->get('file'); if (null === $file || !$file->isValid()) { throw new BadRequestHttpException('No valid file uploaded.'); } if ($file->getSize() > self::MAX_FILE_SIZE) { throw new BadRequestHttpException('File size exceeds 50 MB limit.'); } // Use server-detected MIME type (finfo), not the client-supplied one $originalName = $file->getClientOriginalName(); $mimeType = $file->getMimeType() ?: 'application/octet-stream'; $fileSize = $file->getSize(); if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType)); } $extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin'; $fileName = Uuid::v4()->toRfc4122().'.'.$extension; if (!is_dir($this->uploadDir)) { mkdir($this->uploadDir, 0o775, true); } $file->move($this->uploadDir, $fileName); $document = new TaskDocument(); $this->attachTarget($document, $request); $document->setOriginalName($originalName); $document->setFileName($fileName); $document->setMimeType($mimeType); $document->setSize($fileSize); return $document; } private function createShareLink(Request $request, string $rawSharePath): TaskDocument { try { $path = $this->pathResolver->normalizeRelative($rawSharePath); } catch (InvalidPathException) { throw new BadRequestHttpException('Invalid share path.'); } if ('' === $path) { throw new BadRequestHttpException('A share path is required.'); } $entry = $this->findShareEntry($path); if (null === $entry) { throw new BadRequestHttpException('File not found on the share.'); } if (!in_array($entry->mimeType, self::ALLOWED_MIME_TYPES, true)) { throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $entry->mimeType)); } $document = new TaskDocument(); $this->attachTarget($document, $request); $document->setOriginalName($entry->name); $document->setSharePath($path); $document->setMimeType($entry->mimeType); $document->setSize($entry->size); return $document; } /** * Récupère les métadonnées (taille, type) du fichier sur le partage en listant son dossier parent. */ private function findShareEntry(string $path): ?FileEntry { $parent = dirname($path); $parent = ('.' === $parent || '/' === $parent) ? '' : $parent; $name = basename($path); try { $entries = $this->fileSource->dir($parent); } catch (ShareNotConfiguredException) { throw new BadRequestHttpException('Share not configured.'); } catch (ShareConnectionException) { throw new BadRequestHttpException('Unable to reach the share.'); } foreach ($entries as $entry) { if (!$entry->isDir && $entry->name === $name) { return $entry; } } return null; } /** * Attaches the document to a task OR a client ticket, enforcing per-role * access. Exactly one of the two targets must be provided. * * - ROLE_ADMIN may attach to any task or any client ticket. * - ROLE_CLIENT may only attach to a client ticket they submitted, and may * never attach to a task. */ private function attachTarget(TaskDocument $document, Request $request): void { $taskIri = $this->readField($request, 'task'); $clientTicketIri = $this->readField($request, 'clientTicket'); if ('' === $taskIri && '' === $clientTicketIri) { throw new BadRequestHttpException('A task or a clientTicket IRI is required.'); } if ('' !== $taskIri && '' !== $clientTicketIri) { throw new BadRequestHttpException('Provide either a task or a clientTicket, not both.'); } $isClient = $this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN'); if ('' !== $clientTicketIri) { $document->setClientTicket($this->resolveClientTicket($clientTicketIri, $isClient)); return; } if ($isClient) { throw new AccessDeniedHttpException('Client users can only attach documents to a client ticket.'); } $document->setTask($this->resolveTask($taskIri)); } private function readField(Request $request, string $field): string { $value = $request->request->get($field); if (is_string($value) && '' !== $value) { return $value; } if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) { $payload = json_decode($request->getContent() ?: '{}', true); if (is_array($payload) && isset($payload[$field]) && is_string($payload[$field])) { return $payload[$field]; } } return ''; } private function resolveTask(string $taskIri): Task { $task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri)); if (null === $task) { throw new BadRequestHttpException('Task not found.'); } return $task; } private function resolveClientTicket(string $ticketIri, bool $isClient): ClientTicketInterface { $ticket = $this->entityManager->getRepository(ClientTicketInterface::class)->find((int) basename($ticketIri)); if (null === $ticket) { throw new BadRequestHttpException('Client ticket not found.'); } if ($isClient) { $user = $this->security->getUser(); assert($user instanceof UserInterface); if ($ticket->getSubmittedBy() !== $user) { throw new AccessDeniedHttpException('You can only attach documents to your own tickets.'); } } return $ticket; } }