+ ([])
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;
+ }
}