diff --git a/frontend/components/task/TaskDocumentList.vue b/frontend/components/task/TaskDocumentList.vue index a9be141..b49f741 100644 --- a/frontend/components/task/TaskDocumentList.vue +++ b/frontend/components/task/TaskDocumentList.vue @@ -28,7 +28,15 @@

{{ doc.originalName }}

-

{{ formatFileSize(doc.size) }}

+

+ + {{ formatFileSize(doc.size) }} +

diff --git a/frontend/components/task/TaskDocumentShareLinker.vue b/frontend/components/task/TaskDocumentShareLinker.vue new file mode 100644 index 0000000..695db5c --- /dev/null +++ b/frontend/components/task/TaskDocumentShareLinker.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/components/task/TaskModal.vue b/frontend/components/task/TaskModal.vue index 55e83d4..5930be3 100644 --- a/frontend/components/task/TaskModal.vue +++ b/frontend/components/task/TaskModal.vue @@ -184,6 +184,20 @@ :task-id="task.id" @uploaded="handleDocumentUploaded" /> +
+ +
+ ([]) const previewDoc = ref(null) +// Lien vers un fichier du partage SMB (en plus de l'upload classique) +const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus() +const showShareLinker = ref(false) +ensureShareStatus() + // Sync documents from task prop when modal opens or task changes watch(() => props.task?.documents, (docs) => { localDocuments.value = docs ? [...docs] : [] diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 918e626..e373002 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -128,7 +128,13 @@ "download": "Télécharger", "copy": "Copier", "copied": "Contenu copié !", - "maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo." + "maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo.", + "linkShareButton": "Lier depuis le partage", + "linkShareTitle": "Lier un fichier du partage", + "linkShareHint": "Cliquez sur un dossier pour naviguer, sur un fichier pour le lier au ticket.", + "linkShareSuccess": "Fichier du partage lié au ticket.", + "linkShareError": "Impossible de lier ce fichier (type non autorisé ou introuvable).", + "shareLinkBadge": "Lien vers le partage" }, "tasks": { "created": "Ticket créé avec succès.", @@ -434,6 +440,7 @@ "empty": "Ce dossier est vide.", "filterPlaceholder": "Filtrer ce dossier…", "download": "Télécharger", + "reload": "Recharger", "previewError": "Aperçu impossible. Téléchargez le fichier pour l'ouvrir.", "colName": "Nom", "colSize": "Taille", diff --git a/frontend/pages/documents.vue b/frontend/pages/documents.vue index da5c9ef..3962e85 100644 --- a/frontend/pages/documents.vue +++ b/frontend/pages/documents.vue @@ -11,12 +11,23 @@ - -
- +
+
+ +
+
@@ -113,6 +124,10 @@ function openPath(path: string) { load(path) } +function reload() { + load(currentPath.value) +} + function onEntryClick(entry: FileEntry) { if (entry.isDir) { openPath(entry.path) diff --git a/frontend/services/dto/task-document.ts b/frontend/services/dto/task-document.ts index 98cd045..e9a8886 100644 --- a/frontend/services/dto/task-document.ts +++ b/frontend/services/dto/task-document.ts @@ -5,7 +5,8 @@ export type TaskDocument = { id: number task: string originalName: string - fileName: string + fileName?: string | null + sharePath?: string | null mimeType: string size: number createdAt: string diff --git a/frontend/services/task-documents.ts b/frontend/services/task-documents.ts index de30ae1..0daf539 100644 --- a/frontend/services/task-documents.ts +++ b/frontend/services/task-documents.ts @@ -31,6 +31,15 @@ export function useTaskDocumentService() { return uploadWithRelation('task', `/api/tasks/${taskId}`, file) } + async function linkShare(taskId: number, sharePath: string): Promise { + return $fetch(`${baseURL}/task_documents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task: `/api/tasks/${taskId}`, sharePath }), + credentials: 'include', + }) + } + async function remove(id: number): Promise { await api.delete(`/task_documents/${id}`, {}, { toastSuccessKey: 'taskDocuments.deleted', @@ -48,5 +57,5 @@ export function useTaskDocumentService() { }) } - return { getByTask, upload, remove, getDownloadUrl, getContent } + return { getByTask, upload, linkShare, remove, getDownloadUrl, getContent } } diff --git a/migrations/Version20260612131431.php b/migrations/Version20260612131431.php new file mode 100644 index 0000000..4eac968 --- /dev/null +++ b/migrations/Version20260612131431.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE task_document ADD share_path VARCHAR(1024) DEFAULT NULL'); + $this->addSql('ALTER TABLE task_document ALTER file_name DROP NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE task_document ALTER file_name SET NOT NULL'); + $this->addSql('ALTER TABLE task_document DROP share_path'); + } +} diff --git a/src/Controller/TaskDocumentDownloadController.php b/src/Controller/TaskDocumentDownloadController.php index 10e8f47..ce05ede 100644 --- a/src/Controller/TaskDocumentDownloadController.php +++ b/src/Controller/TaskDocumentDownloadController.php @@ -5,24 +5,33 @@ declare(strict_types=1); namespace App\Controller; use App\Entity\TaskDocument; +use App\Service\Share\Exception\ShareConnectionException; +use App\Service\Share\Exception\ShareNotConfiguredException; +use App\Service\Share\FileSource; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\HeaderUtils; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +use function is_resource; + class TaskDocumentDownloadController extends AbstractController { public function __construct( private readonly EntityManagerInterface $entityManager, + private readonly FileSource $fileSource, private readonly string $uploadDir, ) {} #[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)] #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function __invoke(int $id): BinaryFileResponse + public function __invoke(int $id): Response { $document = $this->entityManager->getRepository(TaskDocument::class)->find($id); @@ -30,6 +39,20 @@ class TaskDocumentDownloadController extends AbstractController throw new NotFoundHttpException('Document not found.'); } + $mimeType = $document->getMimeType() ?? 'application/octet-stream'; + + // Inline for images (except SVG) and PDFs, attachment for everything else. + // SVG is always attachment to prevent XSS via embedded JavaScript. + $inline = 'image/svg+xml' !== $mimeType && (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType); + $disposition = $inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT; + + return $document->isShareLink() + ? $this->streamFromShare($document, $mimeType, $disposition) + : $this->streamFromDisk($document, $mimeType, $disposition); + } + + private function streamFromDisk(TaskDocument $document, string $mimeType, string $disposition): BinaryFileResponse + { $filePath = $this->uploadDir.'/'.$document->getFileName(); if (!file_exists($filePath)) { @@ -37,18 +60,32 @@ class TaskDocumentDownloadController extends AbstractController } $response = new BinaryFileResponse($filePath); - $mimeType = $document->getMimeType() ?? 'application/octet-stream'; - - // Inline for images and PDFs, attachment for everything else - // SVG files are always served as attachment to prevent XSS via embedded JavaScript - $disposition = 'image/svg+xml' === $mimeType - ? ResponseHeaderBag::DISPOSITION_ATTACHMENT - : (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType - ? ResponseHeaderBag::DISPOSITION_INLINE - : ResponseHeaderBag::DISPOSITION_ATTACHMENT); - - $response->setContentDisposition($disposition, $document->getOriginalName()); + $response->setContentDisposition($disposition, (string) $document->getOriginalName()); $response->headers->set('Content-Type', $mimeType); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + return $response; + } + + private function streamFromShare(TaskDocument $document, string $mimeType, string $disposition): StreamedResponse + { + try { + $stream = $this->fileSource->read((string) $document->getSharePath()); + } catch (ShareNotConfiguredException) { + throw new NotFoundHttpException('Share not configured.'); + } catch (ShareConnectionException) { + throw new NotFoundHttpException('File not found on the share.'); + } + + $response = new StreamedResponse(function () use ($stream): void { + if (is_resource($stream)) { + fpassthru($stream); + fclose($stream); + } + }); + $response->headers->set('Content-Type', $mimeType); + $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, (string) $document->getOriginalName())); + $response->headers->set('X-Content-Type-Options', 'nosniff'); return $response; } diff --git a/src/Entity/TaskDocument.php b/src/Entity/TaskDocument.php index 0cab31f..5e1d5f0 100644 --- a/src/Entity/TaskDocument.php +++ b/src/Entity/TaskDocument.php @@ -53,10 +53,18 @@ class TaskDocument #[Groups(['task_document:read', 'task:read'])] private ?string $originalName = null; - #[ORM\Column(length: 255)] + #[ORM\Column(length: 255, nullable: true)] #[Groups(['task_document:read', 'task:read'])] private ?string $fileName = null; + /** + * Chemin relatif sur le partage SMB lorsque le document est un lien vers un fichier du partage + * (au lieu d'un fichier uploadé stocké sur disque). Mutuellement exclusif avec fileName. + */ + #[ORM\Column(length: 1024, nullable: true)] + #[Groups(['task_document:read', 'task:read'])] + private ?string $sharePath = null; + #[ORM\Column(length: 100)] #[Groups(['task_document:read', 'task:read'])] private ?string $mimeType = null; @@ -108,13 +116,30 @@ class TaskDocument return $this->fileName; } - public function setFileName(string $fileName): static + public function setFileName(?string $fileName): static { $this->fileName = $fileName; return $this; } + public function getSharePath(): ?string + { + return $this->sharePath; + } + + public function setSharePath(?string $sharePath): static + { + $this->sharePath = $sharePath; + + return $this; + } + + public function isShareLink(): bool + { + return null !== $this->sharePath; + } + public function getMimeType(): ?string { return $this->mimeType; diff --git a/src/EventListener/TaskDocumentListener.php b/src/EventListener/TaskDocumentListener.php index 997f2f0..3e762d2 100644 --- a/src/EventListener/TaskDocumentListener.php +++ b/src/EventListener/TaskDocumentListener.php @@ -17,6 +17,11 @@ class TaskDocumentListener public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void { + // Un lien vers le partage SMB ne possède pas de fichier sur disque : rien à nettoyer. + if ($document->isShareLink()) { + return; + } + $filePath = $this->uploadDir.'/'.$document->getFileName(); if (file_exists($filePath)) { diff --git a/src/State/TaskDocumentProcessor.php b/src/State/TaskDocumentProcessor.php index feeb136..3191e2f 100644 --- a/src/State/TaskDocumentProcessor.php +++ b/src/State/TaskDocumentProcessor.php @@ -8,13 +8,22 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Task; use App\Entity\TaskDocument; +use App\Service\Share\Exception\InvalidPathException; +use App\Service\Share\Exception\ShareConnectionException; +use App\Service\Share\Exception\ShareNotConfiguredException; +use App\Service\Share\FileEntry; +use App\Service\Share\FileSource; +use App\Service\Share\SharePathResolver; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Uid\Uuid; +use function in_array; + /** * @implements ProcessorInterface */ @@ -55,6 +64,8 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface private EntityManagerInterface $entityManager, private Security $security, private RequestStack $requestStack, + private FileSource $fileSource, + private SharePathResolver $pathResolver, private string $uploadDir, ) {} @@ -69,6 +80,44 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface 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); + + $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()) { @@ -79,17 +128,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface throw new BadRequestHttpException('File size exceeds 50 MB limit.'); } - $taskIri = $request->request->get('task', ''); - - if ('' === $taskIri) { - throw new BadRequestHttpException('A task IRI is required.'); - } - - $task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri)); - - if (null === $task) { - throw new BadRequestHttpException('Task not found.'); - } + $task = $this->resolveTask($request->request->get('task', '')); // Use server-detected MIME type (finfo), not the client-supplied one $originalName = $file->getClientOriginalName(); @@ -101,8 +140,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface } $extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin'; - $uuid = Uuid::v4()->toRfc4122(); - $fileName = $uuid.'.'.$extension; + $fileName = Uuid::v4()->toRfc4122().'.'.$extension; if (!is_dir($this->uploadDir)) { mkdir($this->uploadDir, 0o775, true); @@ -116,12 +154,89 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface $document->setFileName($fileName); $document->setMimeType($mimeType); $document->setSize($fileSize); - $document->setCreatedAt(new DateTimeImmutable()); - $document->setUploadedBy($this->security->getUser()); - - $this->entityManager->persist($document); - $this->entityManager->flush(); return $document; } + + private function createShareLink(Request $request, string $rawSharePath): TaskDocument + { + $taskIri = $request->request->get('task'); + + if (!is_string($taskIri) || '' === $taskIri) { + $payload = json_decode($request->getContent() ?: '{}', true); + $taskIri = is_array($payload) ? ($payload['task'] ?? '') : ''; + } + + $task = $this->resolveTask((string) $taskIri); + + 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(); + $document->setTask($task); + $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; + } + + private function resolveTask(string $taskIri): Task + { + if ('' === $taskIri) { + throw new BadRequestHttpException('A task IRI is required.'); + } + + $task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri)); + + if (null === $task) { + throw new BadRequestHttpException('Task not found.'); + } + + return $task; + } }