Compare commits

..

36 Commits

Author SHA1 Message Date
gitea-actions 4e430cca43 chore: bump version to v0.4.27
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 3m2s
2026-06-12 13:52:47 +00:00
matthieu 7f20c2ae13 Merge pull request 'feat(share) : explorateur de partage Windows (SMB) + viewer' (#8) from feat/share-explorer-impl into develop
Auto Tag Develop / tag (push) Successful in 8s
Reviewed-on: #8
2026-06-12 13:52:41 +00:00
matthieu 0dd253e483 Merge branch 'develop' into feat/share-explorer-impl 2026-06-12 13:52:34 +00:00
Matthieu 1964ea5fb4 feat(share) : recherche globale récursive par nom de fichier dans le partage SMB
Endpoint GET /api/share/search?q= parcourant tout le partage en largeur
(garde-fous 200 résultats / 2000 dossiers). Le champ de l'explorateur
déclenche une recherche globale debouncée dès 2 caractères (filtre local
en deçà), avec affichage du dossier parent de chaque résultat.
2026-06-12 15:49:57 +02:00
Matthieu 682b5747b1 feat(documents) : pastille et libellé Partage sur les documents liés au partage SMB 2026-06-12 15:30:31 +02:00
Matthieu 309f0b10ee fix(security) : double contrôle ROLE_ADMIN dans TaskDocumentProcessor (défense en profondeur) 2026-06-12 15:27:43 +02:00
Matthieu 73a34ef438 feat(documents) : bouton reload explorateur + liaison d'un fichier du partage SMB à un ticket 2026-06-12 15:23:56 +02:00
Matthieu 0f1eeeba1c feat(share) : viewer Word (docx-preview) et tableurs (SheetJS) + sanitisation DOMPurify 2026-06-12 15:04:44 +02:00
gitea-actions f8acdd9817 chore: bump version to v0.4.26
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 1m7s
2026-06-08 13:59:09 +00:00
Matthieu 920539a050 style(ui) : masquer la ligne de message Malio quand elle est vide
Auto Tag Develop / tag (push) Successful in 6s
Depuis @malio/layer-ui 1.7.5, reserveMessageSpace=true réserve ~16px
sous chaque champ même sans message. On retire cette réserve et on masque
la ligne vide (hook stable [id$=-describedby]) sans toucher la lib ni
chaque usage.
2026-06-08 15:57:50 +02:00
Matthieu 5a3be7a170 chore(deps) : bump @malio/layer-ui à 1.7.5
Corrige la lisibilité des blocs de code dans les descriptions de tâches
(overrides [&_pre_code] côté InputRichText).
2026-06-08 15:57:50 +02:00
gitea-actions 5014dd063e chore: bump version to v0.4.25
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m5s
2026-06-04 14:45:35 +00:00
Matthieu 0a6a88e2fa docs : allège CLAUDE.md (listes inline condensées, pièges conservés)
Auto Tag Develop / tag (push) Successful in 7s
2026-06-04 16:45:20 +02:00
Matthieu 4ffa19e53f fix(share) : durcissement download (allowlist inline anti-XSS + nosniff) et masquage des erreurs SMB 2026-06-03 17:42:36 +02:00
Matthieu 74b6d298fb chore(share) : retrait de vue-pdf-embed (viewer PDF via iframe natif) 2026-06-03 17:37:48 +02:00
Matthieu c1415d20f4 feat(share) : traductions explorateur et config partage 2026-06-03 17:35:57 +02:00
Matthieu 1d4dbaa766 feat(share) : page explorateur de fichiers du partage 2026-06-03 17:32:26 +02:00
Matthieu ef7b6c13da feat(share) : viewer de documents du partage (image/pdf/texte) 2026-06-03 17:26:48 +02:00
Matthieu c125566efc feat(share) : lien Documents conditionné à l'activation du partage 2026-06-03 17:23:44 +02:00
Matthieu 947d95b1f7 feat(share) : onglet admin de configuration du partage 2026-06-03 17:21:38 +02:00
Matthieu 027c1305fd feat(share) : services et DTO front (browse, settings, status) + dépendance pdf
- Ajout vue-pdf-embed@2.1.4
- DTO share.ts (FileEntry, Breadcrumb, ShareBrowseResult, ShareStatus, ShareSettings, ShareSettingsWrite, ShareTestResult)
- Service share.ts (browse, getStatus, getDownloadUrl)
- Service share-settings.ts (getSettings, saveSettings, testConnection)
2026-06-03 17:19:42 +02:00
Matthieu f25f3fa634 feat(share) : controllers status/browse/download du partage 2026-06-03 17:13:46 +02:00
Matthieu 224176d9d7 feat(share) : endpoint test de connexion (POST settings/share/test) 2026-06-03 17:10:36 +02:00
Matthieu 8c66e73e8d feat(share) : endpoints de configuration admin (GET/PUT settings/share) 2026-06-03 17:09:05 +02:00
Matthieu f9428f5c5d feat(share) : source de fichiers SMB (FileSource + SmbFileSource) 2026-06-03 17:05:08 +02:00
Matthieu f12ff87b87 feat(share) : résolution de chemin SMB anti path-traversal 2026-06-03 17:02:21 +02:00
Matthieu d0aff0fa51 feat(share) : entité ShareConfiguration + migration 2026-06-03 17:00:53 +02:00
Matthieu 879f961d88 build(share) : ajout icewind/smb et paquet smbclient (dev + prod) 2026-06-03 16:57:21 +02:00
Matthieu 6de7dfde4e docs(share) : plan d'implémentation explorateur de partage Windows 2026-06-03 16:37:12 +02:00
Matthieu 83d938fd91 docs(share) : design explorateur de partage Windows + viewer (SMB) 2026-06-03 16:30:36 +02:00
gitea-actions 8475f9604c chore: bump version to v0.4.24
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 23s
2026-06-02 07:51:11 +00:00
Matthieu 226ab8ea84 feat(mcp) : tools update et delete des documents de tâche
Auto Tag Develop / tag (push) Successful in 7s
Ajoute deux tools MCP sur le modèle de add-task-document :
- update-task-document : remplace le contenu et/ou renomme un document (MIME ré-inféré, taille rafraîchie, garde-fous vide/5 Mo)
- delete-task-document : supprime le document en base, le fichier disque étant retiré par le PreRemove listener

Met aussi à jour le compteur de tools MCP dans le CLAUDE.md (60).
2026-06-02 09:50:03 +02:00
gitea-actions d48ee8eae5 chore: bump version to v0.4.23
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-01 21:26:44 +00:00
Matthieu 1dadc31884 style(kanban) : largeur fixe des colonnes de statut + scroll horizontal conditionnel
Auto Tag Develop / tag (push) Successful in 7s
Remplace flex-1/min-w par une largeur fixe (w-72) avec shrink-0 sur les
colonnes du board projet et de Mes Taches. Les colonnes ne sont plus
ecrasees quand un workflow compte beaucoup de statuts ; le scroll
horizontal n'apparait que si elles depassent la largeur du conteneur.
2026-06-01 23:26:35 +02:00
gitea-actions cdd7ca7626 chore: bump version to v0.4.22
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-01 20:52:47 +00:00
Matthieu e1bf9ecb22 fix(frontend) : copie presse-papiers fonctionnelle en HTTP via fallback execCommand
Auto Tag Develop / tag (push) Successful in 7s
navigator.clipboard n'est disponible qu'en secure context (HTTPS/localhost),
ce qui cassait la copie en prod HTTP. Ajout d'un utilitaire copyToClipboard
avec fallback textarea + execCommand, appliqué au viewer Markdown, au token
API du profil et au nom de branche Git.
2026-06-01 22:52:32 +02:00
65 changed files with 5476 additions and 140 deletions
+18 -25
View File
@@ -13,32 +13,25 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
## Structure ## Structure
> Le détail (entités, providers, services, composants…) se découvre dans le code. Carte d'orientation :
``` ```
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration) src/Entity/ # Entités Doctrine (User, Client, Project, Task + métadonnées Task*, TimeEntry, Notification, *Configuration…)
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection) src/ApiResource/ # Ressources API Platform découplées des entités
src/Enum/ # PHP enums (RecurrenceType) src/State/ # Providers & Processors API Platform (Me, ActiveTimeEntry, TaskNumber, Notification, Gitea*, Zimbra*, RecurrenceHandler…)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator) src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController) src/Controller/ # Controllers custom (notifications, avatar, download document)
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/) src/Mcp/Tool/ # MCP tools par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP) src/Security/ # ApiTokenAuthenticator (MCP HTTP)
src/Command/ # Commandes console (GenerateApiTokenCommand) src/Command/ src/Repository/ src/DataFixtures/
src/Repository/ # Repositories Doctrine config/ # security, api_platform, lexik_jwt, nelmio_cors, doctrine — config/jwt/ = clés
src/DataFixtures/ # Fixtures migrations/ docs/plans/ docs/superpowers/
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine) frontend/pages/ # index, login, my-tasks, profile, projects/[id]/*, time-tracking, admin
config/jwt/ # Clés JWT (private.pem, public.pem) frontend/components/ # Sous-dossiers ui/ client/ project/ task/ user/ admin/ time-tracking/ notification/
migrations/ # Migrations Doctrine frontend/composables/# useApi, useAppVersion, useNotifications, useAvatarService
docs/plans/ # Plans d'implémentation frontend/stores/ # Pinia : auth, ui, timer
docs/superpowers/ # Plans et specs superpowers frontend/services/ # 1 service par ressource API (+ services/dto/ pour les types)
frontend/ # App Nuxt 4 frontend/i18n/locales/ # Traductions (langDir résolu depuis i18n/)
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
frontend/layouts/ # Layouts (default)
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, notification/) — inclut admin/AdminZimbraTab
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useAvatarService)
frontend/stores/ # Stores Pinia (auth, ui, timer)
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, notifications, task-documents, zimbra, task-recurrences)
frontend/services/dto/ # Types TypeScript
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
``` ```
## Commandes ## Commandes
@@ -109,7 +102,7 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
### MCP Server ### MCP Server
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences - 60 tools MCP exposant projets, tâches, métadonnées, time tracking, récurrences, documents et absences
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server` - Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>` - Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User` - Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
+1
View File
@@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"icewind/smb": "^3.8",
"lexik/jwt-authentication-bundle": "^3.2", "lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8", "nyholm/psr7": "^1.8",
Generated
+73 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "dc72ee68996f3f738763eafd350bc0e0", "content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -2508,6 +2508,78 @@
}, },
"time": "2026-02-08T16:21:46+00:00" "time": "2026-02-08T16:21:46+00:00"
}, },
{
"name": "icewind/smb",
"version": "3.8.1",
"source": {
"type": "git",
"url": "https://codeberg.org/icewind/SMB",
"reference": "97063a63b44edc6554966f6121679506b8d85103"
},
"require": {
"icewind/streams": ">=0.7.3",
"php": ">=8.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "v3.89.0",
"phpstan/phpstan": "^0.12.57",
"phpunit/phpunit": "10.5.58",
"psalm/phar": "6.*"
},
"type": "library",
"autoload": {
"psr-4": {
"Icewind\\SMB\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Robin Appelman",
"email": "robin@icewind.nl"
}
],
"description": "php wrapper for smbclient and libsmbclient-php",
"time": "2025-11-13T16:17:19+00:00"
},
{
"name": "icewind/streams",
"version": "v0.7.8",
"source": {
"type": "git",
"url": "https://codeberg.org/icewind/streams",
"reference": "cb2bd3ed41b516efb97e06e8da35a12ef58ba48b"
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^9"
},
"type": "library",
"autoload": {
"psr-4": {
"Icewind\\Streams\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Robin Appelman",
"email": "icewind@owncloud.com"
}
],
"description": "A set of generic stream wrappers",
"time": "2024-12-05T14:36:22+00:00"
},
{ {
"name": "illuminate/collections", "name": "illuminate/collections",
"version": "v13.8.0", "version": "v13.8.0",
+6
View File
@@ -49,6 +49,10 @@ services:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
@@ -60,3 +64,5 @@ services:
App\Controller\Absence\AbsenceJustificationDownloadController: App\Controller\Absence\AbsenceJustificationDownloadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.21' app.version: '0.4.27'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
# Explorateur de partage réseau Windows + viewer — Design
Date : 2026-06-03
Statut : design validé (brainstorming), à transformer en plan d'implémentation.
## 1. Objectif
Donner accès, **depuis Lesstime**, à un partage de fichiers Windows (SMB), avec :
- un **explorateur de fichiers façon Google Drive / SharePoint** qui parcourt le partage **en direct** (live, pas d'index) ;
- un **viewer propre** pour ouvrir les documents (image, PDF, texte) sans quitter l'app ;
- une **configuration en admin** (serveur, partage, identifiants) avec un **bouton « Tester la connexion »** et un **interrupteur d'activation**, sur le même modèle que les intégrations existantes (Zimbra, Gitea, BookStack) ;
- une **visibilité conditionnelle** : si l'option SMB est **désactivée** dans l'admin, l'entrée « Documents » et la page **n'apparaissent pas** pour les utilisateurs.
### Hors périmètre (POC)
- Pas d'index en base, pas de recherche plein texte, pas d'extraction de contenu (pas de Tika).
- Pas d'OCR.
- Pas d'écriture sur le partage (lecture seule).
- Pas de cron / synchronisation. Tout est lu **à la volée** à chaque navigation.
## 2. Décisions d'architecture
| Sujet | Décision |
|-------|----------|
| Accès au partage | **`icewind/smb`** (protocole SMB en PHP), **pas de montage CIFS**. La connexion est configurée dans l'app. |
| Configuration | Entité `ShareConfiguration` (1 ligne) saisie en admin, mot de passe chiffré au repos — calquée sur `ZimbraConfiguration`. |
| Abstraction | Interface `FileSource` (lister / lire), implémentation `SmbFileSource`. Permet de remplacer la source plus tard sans toucher au front ni aux endpoints. |
| API navigation | 2 endpoints live : `browse` (lister un dossier) et `download` (streamer un fichier). |
| Front | Explorateur **maison léger** (fil d'Ariane + tableau), cohérent avec `@malio/layer-ui`. Aucune lib de file-manager externe (elFinder/vue-finder écartés : vieux ou hors design system). |
| Rendu PDF | **PDF.js via `vue-pdf-embed`** dans le viewer (meilleur rendu qu'un `<iframe>`). Images et texte : rendu natif. |
| Sécurité chemin | Validation stricte anti path-traversal : tout chemin demandé doit rester sous la racine configurée. |
### Schéma
```
//WIN-SRV/Partage
│ SMB (icewind/smb, identifiants chiffrés en base)
Lesstime (Symfony) ──FileSource → SmbFileSource──┐
│ │
├─ GET /api/share/browse?path=/Compta/2024 → listing live (dossiers + fichiers)
├─ GET /api/share/download?path=…/x.pdf → stream du fichier (viewer / download)
├─ GET/PUT /api/settings/share → lire / enregistrer la config (admin)
└─ POST /api/settings/share/test → tester la connexion (admin)
```
## 3. Backend (Symfony)
### 3.1 Entité `ShareConfiguration`
Une seule ligne de config (singleton, comme `ZimbraConfiguration`). Champs :
- `id`
- `host` (string, ex. `WIN-SRV` ou IP)
- `shareName` (string, nom du partage SMB, ex. `Documents`)
- `basePath` (string nullable, sous-dossier racine optionnel, ex. `/Projets`) — la navigation est confinée à cette racine
- `domain` (string nullable, workgroup/domaine, défaut `WORKGROUP`)
- `username` (string nullable)
- `encryptedPassword` (text nullable) — chiffré, réutilise le mécanisme de chiffrement déjà employé par Zimbra
- `enabled` (bool, défaut `false`)
- `hasPassword()` helper
Migration Doctrine dédiée. Repository singleton (`findConfiguration()` renvoie la ligne unique ou en crée une vide), calqué sur `ZimbraConfigurationRepository`.
### 3.2 Ressources API de configuration (admin)
Calquées **à l'identique** sur Zimbra :
- `ShareSettings` (ApiResource) — `Get` + `Put` sur `/api/settings/share`, `security: ROLE_ADMIN`.
- Champs lus/écrits : `host`, `shareName`, `basePath`, `domain`, `username`, `enabled`.
- `password` : **write-only** (groupe write uniquement).
- `hasPassword` : **read-only** (indique si un mot de passe est déjà enregistré).
- Provider `ShareSettingsProvider` (lit l'entité → DTO), Processor `ShareSettingsProcessor` (DTO → entité, chiffre le mot de passe si fourni, ne l'écrase pas s'il est vide).
- `ShareTestConnection` (ApiResource) — `Post` sur `/api/settings/share/test`, `input: false`, `security: ROLE_ADMIN`.
- Renvoie `{ success: bool, message: string|null }`.
- Provider `ShareTestConnectionProvider` : tente une connexion SMB + un `dir()` sur la racine ; `success=false` + message d'erreur lisible en cas d'échec.
### 3.3 Source de fichiers
```
interface FileSource {
list(string $relativeDir): FileEntry[] // dossiers d'abord, puis fichiers
read(string $relativePath): resource // flux binaire du fichier
test(): TestResult // connexion + accès racine
}
```
`FileEntry` = `{ name, path, isDir, size, modifiedAt, mimeType }`.
`SmbFileSource` :
- construit la connexion à partir de `ShareConfiguration` (déchiffre le mot de passe) via `icewind/smb` ;
- préfixe tous les chemins par `basePath` ;
- **valide chaque chemin** (`normalize` + rejet de tout chemin qui s'échappe de la racine : pas de `..`, pas de chemin absolu hors racine) → `InvalidPathException` sinon ;
- déduit le `mimeType` à partir de l'extension (suffisant pour piloter le viewer ; pas de lecture du contenu pour le listing).
> **Dépendance infra** : `icewind/smb` requiert le binaire `smbclient` (ou l'extension `libsmbclient`) dans le conteneur PHP. Les deux images sont Debian (`apt-get`), donc une seule ligne suffit, **à appliquer dans les deux Dockerfiles** :
> - `infra/dev/Dockerfile` — ajouter `smbclient` à la liste `apt-get install` existante (~ligne 9).
> - `infra/prod/Dockerfile` — ajouter `smbclient` à l'`apt-get install` du **stage `production`** (le runtime FPM, ~ligne 41), **pas** au stage de build.
>
> Conséquence déploiement : l'image prod (`lesstime-app`) doit être **rebuildée et redéployée** pour embarquer `smbclient` ; sans ça, la fonctionnalité marcherait en dev et échouerait en prod. À inscrire comme étape du plan (avec la migration Doctrine de `ShareConfiguration`).
### 3.4 Endpoints de navigation
Controllers custom sous `/api/` (pas d'entité Doctrine derrière → controllers, avec `priority: 1` sur la route pour éviter le conflit avec API Platform `{id}`), `security: IS_AUTHENTICATED_FULLY` :
- `GET /api/share/browse?path=<rel>``ShareBrowseController`
- renvoie `{ path, breadcrumb[], entries: FileEntry[] }` ;
- si config désactivée/incomplète → `409` avec message clair ;
- chemin invalide → `400`.
- `GET /api/share/download?path=<rel>&disposition=inline|attachment``ShareDownloadController`
- streame le fichier (`StreamedResponse`) avec le bon `Content-Type` ;
- `inline` par défaut (pour le viewer), `attachment` pour le téléchargement ;
- fichier absent → `404`.
- `GET /api/share/status``ShareStatusController`, `security: IS_AUTHENTICATED_FULLY`
- renvoie `{ enabled: bool }`**uniquement le booléen**, aucune donnée de connexion ;
- utilisé par le front pour afficher/masquer l'entrée « Documents » et garder la page.
## 4. Frontend (Nuxt)
### 4.1 Explorateur — `pages/documents.vue`
- **Fil d'Ariane** du chemin courant (cliquable pour remonter).
- **Tableau** des entrées : dossiers d'abord, puis fichiers ; colonnes nom (icône par type), taille, date de modification.
- clic dossier → on descend (met à jour `path`, recharge `browse`) ;
- clic fichier → ouvre le viewer.
- **Filtre par nom** du dossier courant, **côté client** (live, non-indexé) — filtre simplement la liste déjà chargée.
- États : chargement, dossier vide, erreur (config désactivée / connexion KO) avec message.
### 4.2 Viewer — `components/share/SharedFilePreview.vue`
Adapté de `TaskDocumentPreview.vue` existant :
- **Image** : `<img>` sur l'URL `download?disposition=inline`.
- **PDF** : **`vue-pdf-embed`** (PDF.js) — rendu, pagination, zoom.
- **Texte/markdown/csv/json** : chargement du contenu + `<pre>` (comme l'existant).
- **Autre** : carte « fichier » + bouton de téléchargement (`attachment`).
- Navigation précédent/suivant dans la liste du dossier courant, fermeture clavier — repris de l'existant.
### 4.3 Service & config admin
- `services/share.ts` : `browse(path)`, `getDownloadUrl(path, disposition)` + DTO `FileEntry`.
- `services/share-settings.ts` (+ DTO) : `get()`, `update(payload)`, `test()` — calqué sur `services/zimbra.ts`.
- `components/admin/AdminShareTab.vue` : calqué sur `Admin ZimbraTab.vue` — champs host / shareName / basePath / domain / username / password + toggle `enabled`, bouton **« Tester la connexion »** (toast succès/échec) et **« Enregistrer »**. Onglet ajouté à la page admin.
- **i18n** : nouvelles clés (`sharedFiles.*`, `adminShare.*`) dans `frontend/i18n/locales/`.
- **Navigation conditionnelle** : le lien « Documents » du layout n'est affiché **que si** `GET /api/share/status` renvoie `enabled=true` (récupéré via un composable, ex. `useShareStatus`, mis en cache). Le middleware/garde de `pages/documents.vue` redirige vers l'accueil si la fonctionnalité est désactivée (défense en profondeur, en plus du `409` backend).
### 4.4 Dépendance frontend
`vue-pdf-embed` (+ `pdfjs-dist`) ajouté au `package.json` du frontend.
## 5. Flux
- **Configuration** (admin) : saisie host/partage/identifiants → « Tester » (`POST /settings/share/test`) → « Enregistrer » (`PUT /settings/share`).
- **Navigation** (utilisateur) : ouverture `/documents``GET /share/browse?path=/` → tableau ; clic dossier → re-`browse` ; clic fichier → viewer → `GET /share/download?...inline`.
- **Téléchargement** : bouton → `GET /share/download?...attachment`.
## 6. Gestion des erreurs
- **SMB injoignable / identifiants faux** → `browse`/`download` renvoient une erreur ; l'UI affiche un message clair. Le test de connexion renvoie `success=false` + message.
- **Config désactivée ou incomplète** → `browse` `409`, UI invite à configurer (admin).
- **Path-traversal** (`..`, chemin hors racine) → `400`, jamais d'accès hors `basePath`.
- **Fichier supprimé/déplacé entre listing et ouverture** → `download` `404`, message dans le viewer.
## 7. Sécurité
- **Lecture seule** : aucune écriture sur le partage.
- **Rôles** : navigation/lecture = utilisateur authentifié (`IS_AUTHENTICATED_FULLY`) ; configuration = `ROLE_ADMIN`.
- **Mot de passe chiffré au repos** (réutilise le mécanisme Zimbra), jamais renvoyé au front (`hasPassword` seulement).
- **Confinement** strict à `basePath` (anti path-traversal).
## 8. Tests
- **Unitaire**
- `SmbFileSource` : validation/normalisation de chemin, rejet `..` et chemins hors racine (connexion SMB mockée).
- Déduction du `mimeType` par extension.
- **Fonctionnel**
- `GET/PUT /api/settings/share` et `POST /api/settings/share/test` exigent `ROLE_ADMIN` ; le mot de passe n'est jamais exposé en lecture.
- `GET /api/share/browse` et `/download` exigent l'authentification ; un chemin `..` est rejeté (`400`).
## 9. Notes & suites possibles
- Perf : chaque `browse` = un aller-retour SMB live ; acceptable pour un POC. Gros dossiers = listing potentiellement lent (pas de pagination au POC).
- Évolutions naturelles (non incluses) : index + recherche plein texte (Tika), miniatures, multi-partages, restriction par dossier/rôle, mise en cache des listings.
```
+17
View File
@@ -19,3 +19,20 @@
padding-top: 1rem; padding-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
/*
* Champs Malio (@malio/layer-ui >= 1.7.5) : depuis cette version, la ligne de
* message sous chaque champ est toujours rendue (`reserveMessageSpace` à `true`
* par défaut) et réserve ~1rem (16px) même sans erreur/hint, ce qui décale les
* formulaires denses. On retire cette réserve et on masque la ligne quand elle
* est vide, sans désactiver l'option champ par champ ni perdre l'affichage des
* vraies erreurs/hints.
*
* Hook stable : la ligne de message a un id se terminant par "-describedby".
*/
[id$="-describedby"] {
min-height: 0;
}
[id$="-describedby"]:empty {
display: none;
}
+144
View File
@@ -0,0 +1,144 @@
<template>
<div>
<h2 class="text-lg font-bold text-neutral-900">{{ $t('adminShare.title') }}</h2>
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.host"
:label="$t('adminShare.host')"
:placeholder="$t('adminShare.hostPlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.shareName"
:label="$t('adminShare.shareName')"
:placeholder="$t('adminShare.shareNamePlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.basePath"
:label="$t('adminShare.basePath')"
:placeholder="$t('adminShare.basePathPlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.domain"
:label="$t('adminShare.domain')"
:placeholder="$t('adminShare.domainPlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.username"
:label="$t('adminShare.username')"
:placeholder="$t('adminShare.usernamePlaceholder')"
input-class="w-full"
/>
<div>
<MalioInputPassword
v-model="form.password"
:label="$t('adminShare.password')"
input-class="w-full"
/>
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
{{ $t('adminShare.passwordConfigured') }}
</p>
</div>
<label class="flex cursor-pointer items-center gap-2">
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
<span class="text-sm">{{ $t('adminShare.enabled') }}</span>
</label>
<div class="flex gap-3">
<MalioButton
:label="$t('adminShare.save')"
button-class="w-auto px-4"
:disabled="isSaving"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('adminShare.testConnection')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
/>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
{{ testResult ? $t('adminShare.testSuccess') : (testMessage ?? $t('adminShare.testFailed')) }}
</p>
</form>
</div>
</template>
<script setup lang="ts">
import { useShareSettingsService } from '~/services/share-settings'
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
const form = reactive({
host: '',
shareName: '',
basePath: '',
domain: '',
username: '',
password: '',
enabled: false,
})
const hasPassword = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testResult = ref<boolean | null>(null)
const testMessage = ref<string | null>(null)
async function loadSettings() {
const settings = await getSettings()
form.host = settings.host ?? ''
form.shareName = settings.shareName ?? ''
form.basePath = settings.basePath ?? ''
form.domain = settings.domain ?? ''
form.username = settings.username ?? ''
form.enabled = settings.enabled
hasPassword.value = settings.hasPassword
}
async function handleSave() {
isSaving.value = true
try {
const result = await saveSettings({
host: form.host.trim() || null,
shareName: form.shareName.trim() || null,
basePath: form.basePath.trim() || null,
domain: form.domain.trim() || null,
username: form.username.trim() || null,
password: form.password || null,
enabled: form.enabled,
})
hasPassword.value = result.hasPassword
form.password = ''
testResult.value = null
testMessage.value = null
} finally {
isSaving.value = false
}
}
async function handleTest() {
isTesting.value = true
testResult.value = null
testMessage.value = null
try {
const result = await testConnection()
testResult.value = result.success
testMessage.value = result.message
} catch {
testResult.value = false
testMessage.value = null
} finally {
isTesting.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
@@ -0,0 +1,336 @@
<template>
<Teleport to="body">
<Transition name="fade" appear>
<div
v-if="entry"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
@click.self="$emit('close')"
@keydown.escape="$emit('close')"
@keydown.left="$emit('prev')"
@keydown.right="$emit('next')"
tabindex="0"
ref="overlayRef"
>
<!-- Close button -->
<MalioButtonIcon
icon="heroicons:x-mark"
aria-label="Fermer"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('close')"
/>
<!-- Navigation arrows -->
<MalioButtonIcon
v-if="hasPrev"
icon="heroicons:chevron-left"
aria-label="Précédent"
variant="ghost"
icon-size="24"
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('prev')"
/>
<MalioButtonIcon
v-if="hasNext"
icon="heroicons:chevron-right"
aria-label="Suivant"
variant="ghost"
icon-size="24"
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
@click="$emit('next')"
/>
<!-- Content -->
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
<!-- Image preview -->
<img
v-if="isImage"
:src="inlineUrl"
:alt="entry.name"
class="max-h-[85vh] max-w-[90vw] object-contain"
/>
<!-- PDF preview iframe pattern, même approche que TaskDocumentPreview -->
<iframe
v-else-if="isPdf"
:src="inlineUrl"
class="h-[85vh] w-[80vw] rounded-lg bg-white"
/>
<!-- Text / Markdown / JSON / XML / CSV / Log preview -->
<div
v-else-if="isText"
class="flex max-h-[85vh] w-[85vw] max-w-3xl flex-col overflow-hidden rounded-xl bg-white"
>
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
<p class="truncate text-sm font-medium text-neutral-700">{{ entry.name }}</p>
<a
:href="downloadUrl"
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('sharedFiles.download') }}
</a>
</div>
<div class="overflow-auto p-4">
<div v-if="loadingText" class="flex justify-center py-10">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<pre
v-else
class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-neutral-800"
>{{ textContent }}</pre>
</div>
</div>
<!-- DOCX preview rendu HTML via docx-preview (lazy) -->
<div
v-else-if="isDocx"
class="flex max-h-[85vh] w-[85vw] max-w-4xl flex-col overflow-hidden rounded-xl bg-white"
>
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
<p class="truncate text-sm font-medium text-neutral-700">{{ entry.name }}</p>
<a
:href="downloadUrl"
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('sharedFiles.download') }}
</a>
</div>
<div class="overflow-auto bg-neutral-100 p-4">
<div v-if="loadingOffice" class="flex justify-center py-10">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<p v-else-if="officeError" class="py-10 text-center text-sm text-red-600">
{{ $t('sharedFiles.previewError') }}
</p>
<div v-show="!loadingOffice && !officeError" ref="docxContainer" class="mx-auto bg-white" />
</div>
</div>
<!-- Spreadsheet preview rendu table via SheetJS (lazy) -->
<div
v-else-if="isSpreadsheet"
class="flex max-h-[85vh] w-[88vw] max-w-5xl flex-col overflow-hidden rounded-xl bg-white"
>
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
<p class="truncate text-sm font-medium text-neutral-700">{{ entry.name }}</p>
<a
:href="downloadUrl"
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('sharedFiles.download') }}
</a>
</div>
<div v-if="sheetNames.length > 1" class="flex gap-1 overflow-x-auto border-b border-neutral-100 px-3 py-2">
<button
v-for="(name, i) in sheetNames"
:key="name"
class="whitespace-nowrap rounded px-2 py-1 text-xs"
:class="i === activeSheet ? 'bg-blue-600 text-white' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
@click="selectSheet(i)"
>
{{ name }}
</button>
</div>
<div class="overflow-auto p-4">
<div v-if="loadingOffice" class="flex justify-center py-10">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<p v-else-if="officeError" class="py-10 text-center text-sm text-red-600">
{{ $t('sharedFiles.previewError') }}
</p>
<!-- eslint-disable-next-line vue/no-v-html -- HTML généré par SheetJS, valeurs de cellule échappées -->
<div v-else class="xlsx-host" v-html="sheetHtml" />
</div>
</div>
<!-- Generic file download fallback -->
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ entry.name }}</p>
<p class="text-sm text-neutral-400">{{ formatFileSize(entry.size) }}</p>
<a
:href="downloadUrl"
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('sharedFiles.download') }}
</a>
</div>
<!-- File name footer (masqué pour les vues qui affichent déjà le nom dans leur en-tête) -->
<p v-if="!isText && !isDocx && !isSpreadsheet" class="mt-3 text-sm text-white/70">{{ entry.name }}</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
entry: FileEntry | null
hasPrev: boolean
hasNext: boolean
}>()
defineEmits<{
close: []
prev: []
next: []
}>()
const overlayRef = ref<HTMLElement | null>(null)
const textContent = ref('')
const loadingText = ref(false)
// Office previews (rendus côté client, libs chargées à la demande)
const docxContainer = ref<HTMLElement | null>(null)
const loadingOffice = ref(false)
const officeError = ref(false)
const sheetNames = ref<string[]>([])
const activeSheet = ref(0)
const sheetHtml = ref('')
// Workbook SheetJS courant (type laissé libre : la lib est importée dynamiquement)
let workbook: { SheetNames: string[]; Sheets: Record<string, unknown> } | null = null
const { getDownloadUrl } = useShareService()
const TEXT_RE = /\.(md|markdown|txt|csv|json|xml|log)$/i
const DOCX_RE = /\.docx$/i
const SHEET_RE = /\.(xlsx|xlsm|xls)$/i
const inlineUrl = computed(() => props.entry ? getDownloadUrl(props.entry.path, 'inline') : '')
const downloadUrl = computed(() => props.entry ? getDownloadUrl(props.entry.path, 'attachment') : '')
const isImage = computed(() => props.entry?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.entry?.mimeType === 'application/pdf')
const isText = computed(() =>
props.entry
? (props.entry.mimeType.startsWith('text/') || TEXT_RE.test(props.entry.name))
: false
)
const isDocx = computed(() => props.entry ? DOCX_RE.test(props.entry.name) : false)
const isSpreadsheet = computed(() => props.entry ? SHEET_RE.test(props.entry.name) : false)
async function fetchBlob(): Promise<Blob> {
return $fetch<Blob>(downloadUrl.value, {
credentials: 'include',
responseType: 'blob' as never,
})
}
async function renderDocx(blob: Blob) {
const [{ renderAsync }, DOMPurify] = await Promise.all([
import('docx-preview'),
import('dompurify'),
])
loadingOffice.value = false
await nextTick()
if (docxContainer.value) {
docxContainer.value.innerHTML = ''
await renderAsync(blob, docxContainer.value, undefined, { inWrapper: true, ignoreLastRenderedPageBreak: true })
// Anti-XSS : neutralise tout script injecté via un .docx piégé, en gardant la mise en forme (style)
docxContainer.value.innerHTML = DOMPurify.default.sanitize(docxContainer.value.innerHTML, {
ADD_TAGS: ['style'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
})
}
}
async function renderSpreadsheet(blob: Blob) {
const [XLSX, DOMPurify] = await Promise.all([import('xlsx'), import('dompurify')])
const buf = await blob.arrayBuffer()
workbook = XLSX.read(buf, { type: 'array' }) as typeof workbook
sheetNames.value = workbook?.SheetNames ?? []
await selectSheet(0, XLSX, DOMPurify)
loadingOffice.value = false
}
async function selectSheet(
index: number,
xlsx?: typeof import('xlsx'),
purify?: typeof import('dompurify'),
) {
if (!workbook) return
activeSheet.value = index
const XLSX = xlsx ?? (await import('xlsx'))
const DOMPurify = purify ?? (await import('dompurify'))
const ws = workbook.Sheets[workbook.SheetNames[index]!]
const rawHtml = XLSX.utils.sheet_to_html(ws as never, { editable: false })
sheetHtml.value = DOMPurify.default.sanitize(rawHtml, { FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'] })
}
function resetOffice() {
loadingOffice.value = false
officeError.value = false
sheetHtml.value = ''
sheetNames.value = []
activeSheet.value = 0
workbook = null
}
watch(() => props.entry, async (entry) => {
textContent.value = ''
resetOffice()
if (!entry) return
nextTick(() => overlayRef.value?.focus())
if (isText.value) {
loadingText.value = true
try {
textContent.value = await $fetch<string>(inlineUrl.value, {
credentials: 'include',
responseType: 'text' as never,
})
} catch {
textContent.value = ''
} finally {
loadingText.value = false
}
return
}
if (isDocx.value || isSpreadsheet.value) {
loadingOffice.value = true
try {
const blob = await fetchBlob()
if (isDocx.value) {
await renderDocx(blob)
} else {
await renderSpreadsheet(blob)
}
} catch {
officeError.value = true
loadingOffice.value = false
}
}
}, { immediate: true })
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Rendu des tableurs (HTML généré par SheetJS) */
.xlsx-host :deep(table) {
border-collapse: collapse;
font-size: 12px;
}
.xlsx-host :deep(td),
.xlsx-host :deep(th) {
border: 1px solid #e5e5e5;
padding: 2px 6px;
white-space: nowrap;
text-align: left;
}
</style>
+26 -13
View File
@@ -11,24 +11,37 @@
@click="$emit('preview', doc)" @click="$emit('preview', doc)"
> >
<!-- Thumbnail or icon --> <!-- Thumbnail or icon -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded"> <div class="relative h-10 w-10 shrink-0">
<img <div class="flex h-10 w-10 items-center justify-center overflow-hidden rounded">
v-if="isImage(doc.mimeType)" <img
:src="getDownloadUrl(doc.id)" v-if="isImage(doc.mimeType)"
:alt="doc.originalName" :src="getDownloadUrl(doc.id)"
class="h-10 w-10 object-cover" :alt="doc.originalName"
/> class="h-10 w-10 object-cover"
<Icon />
v-else <Icon
:name="getIconForMime(doc.mimeType)" v-else
class="h-6 w-6 text-neutral-400" :name="getIconForMime(doc.mimeType)"
/> class="h-6 w-6 text-neutral-400"
/>
</div>
<!-- Pastille : document lié depuis le partage SMB -->
<span
v-if="doc.sharePath"
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary-500 ring-2 ring-white"
:title="$t('taskDocuments.shareLinkBadge')"
>
<Icon name="heroicons:link" class="h-2.5 w-2.5 text-white" />
</span>
</div> </div>
<!-- File info --> <!-- File info -->
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p> <p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
<p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p> <p class="text-xs text-neutral-400">
<span v-if="doc.sharePath" class="font-medium text-primary-500">{{ $t('taskDocuments.shareLinkLabel') }}</span>
<span v-if="doc.sharePath"> · </span>{{ formatFileSize(doc.size) }}
</p>
</div> </div>
<!-- Delete button --> <!-- Delete button -->
@@ -124,6 +124,7 @@
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard'
const props = defineProps<{ const props = defineProps<{
document: TaskDocument | null document: TaskDocument | null
@@ -159,13 +160,10 @@ const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
const isText = computed(() => isTextDocument(props.document)) const isText = computed(() => isTextDocument(props.document))
async function copyContent() { async function copyContent() {
try { if (await copyToClipboard(textContent.value)) {
await navigator.clipboard.writeText(textContent.value)
copied.value = true copied.value = true
useToast().success(t('taskDocuments.copied')) useToast().success(t('taskDocuments.copied'))
setTimeout(() => { copied.value = false }, 2000) setTimeout(() => { copied.value = false }, 2000)
} catch {
// Clipboard unavailable
} }
} }
@@ -0,0 +1,156 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="close" />
<div class="relative z-10 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl">
<!-- En-tête -->
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.linkShareTitle') }}</h3>
<MalioButtonIcon
icon="heroicons:x-mark"
:aria-label="$t('common.cancel')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-neutral-700"
@click="close"
/>
</div>
<!-- Fil d'Ariane -->
<nav class="flex flex-wrap items-center gap-1 border-b border-neutral-100 px-6 py-2 text-sm text-neutral-500">
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
<template v-for="crumb in breadcrumb" :key="crumb.path">
<span>/</span>
<button class="hover:text-primary-500" @click="openPath(crumb.path)">{{ crumb.name }}</button>
</template>
</nav>
<!-- Contenu -->
<div class="min-h-[12rem] flex-1 overflow-auto px-2 py-2">
<div v-if="loading" class="flex justify-center py-12">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<p v-else-if="error" class="px-4 py-12 text-center text-sm text-red-600">{{ error }}</p>
<p v-else-if="entries.length === 0" class="px-4 py-12 text-center text-sm text-neutral-400">{{ $t('sharedFiles.empty') }}</p>
<ul v-else class="text-sm">
<li
v-for="entry in entries"
:key="entry.path"
class="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-neutral-50"
:class="{ 'opacity-60': linking }"
@click="onEntryClick(entry)"
>
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
<span class="flex-1 truncate">{{ entry.name }}</span>
<span class="shrink-0 text-xs text-neutral-400">{{ entry.isDir ? '' : formatFileSize(entry.size) }}</span>
</li>
</ul>
</div>
<p class="border-t border-neutral-100 px-6 py-3 text-xs text-neutral-400">{{ $t('taskDocuments.linkShareHint') }}</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
modelValue: boolean
taskId: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'linked'): void
}>()
const { browse } = useShareService()
const { linkShare } = useTaskDocumentService()
const toast = useToast()
const { t } = useI18n()
const currentPath = ref('')
const breadcrumb = ref<Breadcrumb[]>([])
const entries = ref<FileEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const linking = ref(false)
async function load(path: string) {
loading.value = true
error.value = null
try {
const result = await browse(path)
currentPath.value = result.path
breadcrumb.value = result.breadcrumb
entries.value = result.entries
} catch (e: unknown) {
error.value = (e as Error)?.message ?? t('sharedFiles.previewError')
entries.value = []
} finally {
loading.value = false
}
}
function openPath(path: string) {
load(path)
}
async function onEntryClick(entry: FileEntry) {
if (linking.value) return
if (entry.isDir) {
load(entry.path)
return
}
linking.value = true
try {
await linkShare(props.taskId, entry.path)
toast.success({ title: '', message: t('taskDocuments.linkShareSuccess') })
emit('linked')
close()
} catch {
toast.error({ title: 'Erreur', message: t('taskDocuments.linkShareError') })
} finally {
linking.value = false
}
}
function iconForMime(mime: string): string {
if (mime.startsWith('image/')) return 'mdi:file-image-outline'
if (mime === 'application/pdf') return 'mdi:file-pdf-box'
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'mdi:file-word-outline'
if (mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'mdi:file-excel-outline'
if (mime.startsWith('text/')) return 'mdi:file-document-outline'
return 'mdi:file-outline'
}
function close() {
emit('update:modelValue', false)
}
watch(() => props.modelValue, (open) => {
if (open) {
entries.value = []
load('')
}
})
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
+2 -1
View File
@@ -229,6 +229,7 @@
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea' import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
@@ -374,7 +375,7 @@ async function handleCreate() {
async function handleCopy() { async function handleCopy() {
try { try {
const result = await getBranchName(props.task.id, branchForm.type) const result = await getBranchName(props.task.id, branchForm.type)
await navigator.clipboard.writeText(result.name) await copyToClipboard(result.name)
const { success } = useToast() const { success } = useToast()
success(t('gitea.branch.copied')) success(t('gitea.branch.copied'))
} catch { } catch {
+19
View File
@@ -184,6 +184,20 @@
:task-id="task.id" :task-id="task.id"
@uploaded="handleDocumentUploaded" @uploaded="handleDocumentUploaded"
/> />
<div v-if="isEditing && task && isAdmin && shareEnabled" class="mt-2">
<MalioButton
variant="tertiary"
:label="$t('taskDocuments.linkShareButton')"
button-class="w-auto px-3"
@click="showShareLinker = true"
/>
</div>
<TaskDocumentShareLinker
v-if="isEditing && task && isAdmin"
v-model="showShareLinker"
:task-id="task.id"
@linked="handleDocumentUploaded"
/>
<TaskDocumentList <TaskDocumentList
v-if="isEditing && task" v-if="isEditing && task"
:documents="localDocuments" :documents="localDocuments"
@@ -869,6 +883,11 @@ function formatMailDate(iso: string | null): string {
const localDocuments = ref<TaskDocument[]>([]) const localDocuments = ref<TaskDocument[]>([])
const previewDoc = ref<TaskDocument | null>(null) const previewDoc = ref<TaskDocument | null>(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 // Sync documents from task prop when modal opens or task changes
watch(() => props.task?.documents, (docs) => { watch(() => props.task?.documents, (docs) => {
localDocuments.value = docs ? [...docs] : [] localDocuments.value = docs ? [...docs] : []
+23
View File
@@ -0,0 +1,23 @@
import { useShareService } from '~/services/share'
export function useShareStatus() {
const enabled = useState<boolean | null>('share-enabled', () => null)
const { getStatus } = useShareService()
async function refresh() {
try {
const status = await getStatus()
enabled.value = status.enabled
} catch {
enabled.value = false
}
}
async function ensureLoaded() {
if (enabled.value === null) {
await refresh()
}
}
return { enabled, refresh, ensureLoaded }
}
+45 -1
View File
@@ -128,7 +128,14 @@
"download": "Télécharger", "download": "Télécharger",
"copy": "Copier", "copy": "Copier",
"copied": "Contenu copié !", "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",
"shareLinkLabel": "Partage"
}, },
"tasks": { "tasks": {
"created": "Ticket créé avec succès.", "created": "Ticket créé avec succès.",
@@ -428,6 +435,43 @@
"testFailed": "Connexion échouée" "testFailed": "Connexion échouée"
} }
}, },
"sharedFiles": {
"title": "Documents",
"root": "Racine",
"empty": "Ce dossier est vide.",
"noResults": "Aucun document ne correspond à votre recherche.",
"searchPlaceholder": "Rechercher dans tout le partage…",
"download": "Télécharger",
"reload": "Recharger",
"previewError": "Aperçu impossible. Téléchargez le fichier pour l'ouvrir.",
"colName": "Nom",
"colSize": "Taille",
"colModified": "Modifié le",
"sidebar": {
"title": "Documents"
}
},
"adminShare": {
"title": "Partage réseau (SMB)",
"host": "Serveur",
"hostPlaceholder": "ex. WIN-SRV ou 192.168.1.10",
"shareName": "Nom du partage",
"shareNamePlaceholder": "ex. Documents",
"basePath": "Sous-dossier racine (optionnel)",
"basePathPlaceholder": "ex. /Projets",
"domain": "Domaine / groupe de travail",
"domainPlaceholder": "WORKGROUP",
"username": "Identifiant",
"usernamePlaceholder": "ex. lesstime",
"password": "Mot de passe",
"passwordConfigured": "Un mot de passe est déjà enregistré.",
"enabled": "Activer l'accès au partage",
"save": "Enregistrer",
"saved": "Configuration enregistrée.",
"testConnection": "Tester la connexion",
"testSuccess": "Connexion réussie.",
"testFailed": "Échec de la connexion."
},
"taskRecurrence": { "taskRecurrence": {
"created": "Récurrence créée", "created": "Récurrence créée",
"updated": "Récurrence mise à jour", "updated": "Récurrence mise à jour",
+17 -2
View File
@@ -100,6 +100,14 @@
:collapsed="sidebarIsCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()" @click="ui.closeMobileSidebar()"
/> />
<SidebarLink
v-if="isDocumentsVisible"
to="/documents"
icon="mdi:folder-network-outline"
:label="$t('sharedFiles.sidebar.title')"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<div v-if="isMailVisible" class="relative"> <div v-if="isMailVisible" class="relative">
<SidebarLink <SidebarLink
to="/mail" to="/mail"
@@ -222,6 +230,9 @@ const isMailVisible = computed(() => {
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN') return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
}) })
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
const isDocumentsVisible = computed(() => shareEnabled.value === true)
// On mobile, sidebar is always expanded (not collapsed icon mode) // On mobile, sidebar is always expanded (not collapsed icon mode)
const sidebarIsCollapsed = computed(() => { const sidebarIsCollapsed = computed(() => {
if (ui.sidebarOpen) return false if (ui.sidebarOpen) return false
@@ -267,13 +278,17 @@ onMounted(() => {
if (isMailVisible.value) { if (isMailVisible.value) {
mailStore.startPolling() mailStore.startPolling()
} }
ensureShareStatus()
}) })
watch(() => auth.user, (user) => { watch(() => auth.user, (user) => {
if (!user) { if (!user) {
mailStore.stopPolling() mailStore.stopPolling()
} else if (isMailVisible.value) { } else {
mailStore.startPolling() if (isMailVisible.value) {
mailStore.startPolling()
}
ensureShareStatus()
} }
}) })
+160 -49
View File
@@ -7,7 +7,7 @@
"name": "nuxt-app", "name": "nuxt-app",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.6.0", "@malio/layer-ui": "^1.7.5",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -15,7 +15,8 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@vuepic/vue-datepicker": "^12.1.0", "@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"dompurify": "^3.4.5", "docx-preview": "^0.3.7",
"dompurify": "^3.4.9",
"marked": "^18.0.0", "marked": "^18.0.0",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0", "nuxt-toast": "^1.4.0",
@@ -23,7 +24,8 @@
"vue": "^3.5.29", "vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4" "vue-router": "^4.6.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5" "@types/dompurify": "^3.0.5"
@@ -82,7 +84,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1044,6 +1045,7 @@
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0" "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
} }
@@ -1053,6 +1055,7 @@
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@eslint/object-schema": "^3.0.3", "@eslint/object-schema": "^3.0.3",
"debug": "^4.3.1", "debug": "^4.3.1",
@@ -1067,6 +1070,7 @@
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@eslint/core": "^1.1.1" "@eslint/core": "^1.1.1"
}, },
@@ -1079,6 +1083,7 @@
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15"
}, },
@@ -1091,6 +1096,7 @@
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
@@ -1100,6 +1106,7 @@
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@eslint/core": "^1.1.1", "@eslint/core": "^1.1.1",
"levn": "^0.4.1" "levn": "^0.4.1"
@@ -1122,7 +1129,6 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.5", "@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11" "@floating-ui/utils": "^0.2.11"
@@ -1176,6 +1182,7 @@
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=18.18.0" "node": ">=18.18.0"
} }
@@ -1185,6 +1192,7 @@
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@humanfs/core": "^0.19.1", "@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0" "@humanwhocodes/retry": "^0.4.0"
@@ -1198,6 +1206,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=12.22" "node": ">=12.22"
}, },
@@ -1211,6 +1220,7 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=18.18" "node": ">=18.18"
}, },
@@ -2210,9 +2220,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.6.0", "version": "1.7.5",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.6.0/layer-ui-1.6.0.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.5/layer-ui-1.7.5.tgz",
"integrity": "sha512-2sN4mL1Jf984oeE4N4yEv6XFgSz0Gc+uSG+HLGfRrdzjAsMcU9hbb7HSAo3Q6MBvQHZn3ZBr1cK+VUM0kXY4NA==", "integrity": "sha512-xryrAYgVgX3eurEWXT/d0p4r/MBYNBB3mBnvV6xVcFhzxW+HfOra8hsVHLvrCtd+m5E1t7PDRzjw1FObkV6fdQ==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -2236,7 +2246,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -2485,7 +2494,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -2591,7 +2599,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==", "integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -2618,7 +2625,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@@ -2633,7 +2639,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.3.0", "prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2", "prosemirror-commands": "^1.6.2",
@@ -3016,7 +3021,6 @@
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==", "integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"c12": "^3.3.3", "c12": "^3.3.3",
"consola": "^3.4.2", "consola": "^3.4.2",
@@ -3089,7 +3093,6 @@
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==", "integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/shared": "^3.5.27", "@vue/shared": "^3.5.27",
"defu": "^6.1.4", "defu": "^6.1.4",
@@ -3736,7 +3739,6 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==", "integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/types": "^0.95.0" "@oxc-project/types": "^0.95.0"
}, },
@@ -5877,7 +5879,8 @@
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
@@ -5889,7 +5892,8 @@
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/linkify-it": { "node_modules/@types/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
@@ -6259,7 +6263,6 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.29", "@vue/compiler-core": "3.5.29",
@@ -6509,7 +6512,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -6549,6 +6551,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -6880,7 +6883,6 @@
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"peerDependencies": { "peerDependencies": {
"bare-abort-controller": "*" "bare-abort-controller": "*"
}, },
@@ -7074,7 +7076,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -7203,7 +7204,6 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -7345,7 +7345,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -7382,7 +7381,6 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"consola": "^3.2.3" "consola": "^3.2.3"
} }
@@ -7938,7 +7936,8 @@
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
@@ -8068,6 +8067,15 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/docx-preview": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
"integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
"license": "Apache-2.0",
"dependencies": {
"jszip": ">=3.0.0"
}
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -8122,9 +8130,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.5", "version": "3.4.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", "integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -8453,6 +8461,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@types/esrecurse": "^4.3.1", "@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@@ -8483,6 +8492,7 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -8495,6 +8505,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
@@ -8507,6 +8518,7 @@
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"is-glob": "^4.0.3" "is-glob": "^4.0.3"
}, },
@@ -8519,6 +8531,7 @@
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 4" "node": ">= 4"
} }
@@ -8528,6 +8541,7 @@
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"acorn": "^8.16.0", "acorn": "^8.16.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
@@ -8545,6 +8559,7 @@
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
@@ -8570,6 +8585,7 @@
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"estraverse": "^5.1.0" "estraverse": "^5.1.0"
}, },
@@ -8582,6 +8598,7 @@
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"estraverse": "^5.2.0" "estraverse": "^5.2.0"
}, },
@@ -8682,7 +8699,8 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fast-fifo": { "node_modules/fast-fifo": {
"version": "1.3.2", "version": "1.3.2",
@@ -8710,13 +8728,15 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fast-levenshtein": { "node_modules/fast-levenshtein": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fast-npm-meta": { "node_modules/fast-npm-meta": {
"version": "1.4.0", "version": "1.4.0",
@@ -8761,6 +8781,7 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"flat-cache": "^4.0.0" "flat-cache": "^4.0.0"
}, },
@@ -8791,6 +8812,7 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"locate-path": "^6.0.0", "locate-path": "^6.0.0",
"path-exists": "^4.0.0" "path-exists": "^4.0.0"
@@ -8807,6 +8829,7 @@
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"flatted": "^3.2.9", "flatted": "^3.2.9",
"keyv": "^4.5.4" "keyv": "^4.5.4"
@@ -8819,7 +8842,8 @@
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==", "integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
"license": "ISC" "license": "ISC",
"peer": true
}, },
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
@@ -9335,6 +9359,12 @@
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==", "integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/impound": { "node_modules/impound": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/impound/-/impound-1.1.2.tgz", "resolved": "https://registry.npmjs.org/impound/-/impound-1.1.2.tgz",
@@ -9352,6 +9382,7 @@
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.8.19" "node": ">=0.8.19"
} }
@@ -9728,19 +9759,22 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
@@ -9801,6 +9835,48 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keygrip": { "node_modules/keygrip": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
@@ -9818,6 +9894,7 @@
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
@@ -10078,6 +10155,7 @@
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prelude-ls": "^1.2.1", "prelude-ls": "^1.2.1",
"type-check": "~0.4.0" "type-check": "~0.4.0"
@@ -10086,6 +10164,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -10177,6 +10264,7 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"p-locate": "^5.0.0" "p-locate": "^5.0.0"
}, },
@@ -10621,7 +10709,8 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
@@ -10857,7 +10946,6 @@
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz", "resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==", "integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dxup/nuxt": "^0.3.2", "@dxup/nuxt": "^0.3.2",
"@nuxt/cli": "^3.33.0", "@nuxt/cli": "^3.33.0",
@@ -11128,6 +11216,7 @@
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deep-is": "^0.1.3", "deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6", "fast-levenshtein": "^2.0.6",
@@ -11185,7 +11274,6 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==", "integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/types": "^0.112.0" "@oxc-project/types": "^0.112.0"
}, },
@@ -11269,6 +11357,7 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"yocto-queue": "^0.1.0" "yocto-queue": "^0.1.0"
}, },
@@ -11284,6 +11373,7 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"p-limit": "^3.0.2" "p-limit": "^3.0.2"
}, },
@@ -11306,6 +11396,12 @@
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -11326,6 +11422,7 @@
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -11429,7 +11526,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^7.7.7" "@vue/devtools-api": "^7.7.7"
}, },
@@ -11546,7 +11642,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -12090,7 +12185,6 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
"util-deprecate": "^1.0.2" "util-deprecate": "^1.0.2"
@@ -12141,6 +12235,7 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
@@ -12317,6 +12412,7 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -12687,7 +12783,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -12946,6 +13041,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -13476,7 +13577,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -13817,6 +13917,7 @@
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prelude-ls": "^1.2.1" "prelude-ls": "^1.2.1"
}, },
@@ -13884,7 +13985,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -14326,6 +14426,7 @@
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
@@ -14350,7 +14451,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -14712,7 +14812,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29", "@vue/compiler-sfc": "3.5.29",
@@ -14777,7 +14876,6 @@
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==", "integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@intlify/core-base": "11.3.0", "@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0", "@intlify/devtools-types": "11.3.0",
@@ -14799,7 +14897,6 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^6.6.4" "@vue/devtools-api": "^6.6.4"
}, },
@@ -14858,6 +14955,7 @@
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -14939,6 +15037,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -15026,6 +15136,7 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
+5 -3
View File
@@ -11,7 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist" "build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.6.0", "@malio/layer-ui": "^1.7.5",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -19,7 +19,8 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@vuepic/vue-datepicker": "^12.1.0", "@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"dompurify": "^3.4.5", "docx-preview": "^0.3.7",
"dompurify": "^3.4.9",
"marked": "^18.0.0", "marked": "^18.0.0",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
"nuxt-toast": "^1.4.0", "nuxt-toast": "^1.4.0",
@@ -27,7 +28,8 @@
"vue": "^3.5.29", "vue": "^3.5.29",
"vue-advanced-cropper": "^2.8.9", "vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4" "vue-router": "^4.6.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5" "@types/dompurify": "^3.0.5"
+2
View File
@@ -30,6 +30,7 @@
<AdminGiteaTab v-if="activeTab === 'gitea'" /> <AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" /> <AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" /> <AdminZimbraTab v-if="activeTab === 'zimbra'" />
<AdminShareTab v-if="activeTab === 'share'" />
<AdminMailTab v-if="activeTab === 'mail'" /> <AdminMailTab v-if="activeTab === 'mail'" />
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" /> <AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
</div> </div>
@@ -50,6 +51,7 @@ const tabs = [
{ key: 'gitea', label: 'Gitea' }, { key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' }, { key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' }, { key: 'zimbra', label: 'Zimbra' },
{ key: 'share', label: 'Partage' },
{ key: 'mail', label: 'Mail' }, { key: 'mail', label: 'Mail' },
{ key: 'absences', label: 'Absences' }, { key: 'absences', label: 'Absences' },
] as const ] as const
+227
View File
@@ -0,0 +1,227 @@
<template>
<div>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('sharedFiles.title') }}</h1>
<!-- Fil d'Ariane -->
<nav class="mt-4 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
<template v-for="crumb in breadcrumb" :key="crumb.path">
<span>/</span>
<button class="hover:text-primary-500" @click="openPath(crumb.path)">{{ crumb.name }}</button>
</template>
</nav>
<!-- Filtre local + rechargement -->
<div class="mt-4 flex items-center gap-2">
<div class="max-w-sm flex-1">
<MalioInputText
v-model="filter"
:placeholder="$t('sharedFiles.searchPlaceholder')"
input-class="w-full"
/>
</div>
<MalioButtonIcon
icon="heroicons:arrow-path"
:aria-label="$t('sharedFiles.reload')"
variant="ghost"
icon-size="20"
:disabled="loading"
button-class="text-neutral-500 hover:text-primary-500"
@click="reload"
/>
</div>
<!-- États -->
<div v-if="loading || searching" class="mt-10 flex justify-center">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<p v-else-if="error || searchError" class="mt-10 text-sm text-red-600">{{ error || searchError }}</p>
<p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">
{{ isSearchMode ? $t('sharedFiles.noResults') : $t('sharedFiles.empty') }}
</p>
<!-- Tableau -->
<table v-else class="mt-6 w-full text-sm">
<thead class="border-b border-neutral-200 text-left text-xs uppercase tracking-wider text-neutral-400">
<tr>
<th class="py-2">{{ $t('sharedFiles.colName') }}</th>
<th class="py-2">{{ $t('sharedFiles.colSize') }}</th>
<th class="py-2">{{ $t('sharedFiles.colModified') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in visibleEntries"
:key="entry.path"
class="cursor-pointer border-b border-neutral-100 hover:bg-neutral-50"
@click="onEntryClick(entry)"
>
<td class="flex items-center gap-2 py-2">
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
<span class="flex min-w-0 flex-col">
<span class="truncate">{{ entry.name }}</span>
<span v-if="isSearchMode && parentDir(entry)" class="truncate text-xs text-neutral-400">{{ parentDir(entry) }}</span>
</span>
</td>
<td class="py-2 text-neutral-500">{{ entry.isDir ? '' : formatFileSize(entry.size) }}</td>
<td class="py-2 text-neutral-500">{{ formatDate(entry.modifiedAt) }}</td>
</tr>
</tbody>
</table>
<SharedFilePreview
:entry="previewEntry"
:has-prev="previewIndex > 0"
:has-next="previewIndex >= 0 && previewIndex < fileEntries.length - 1"
@close="previewEntry = null"
@prev="stepPreview(-1)"
@next="stepPreview(1)"
/>
</div>
</template>
<script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format'
useHead({ title: 'Documents' })
const { browse, search } = useShareService()
const { enabled, ensureLoaded } = useShareStatus()
const MIN_SEARCH_LENGTH = 2
const currentPath = ref('')
const breadcrumb = ref<Breadcrumb[]>([])
const entries = ref<FileEntry[]>([])
const filter = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const searchResults = ref<FileEntry[]>([])
const searching = ref(false)
const searchError = ref<string | null>(null)
const previewEntry = ref<FileEntry | null>(null)
// Recherche globale (récursive sur tout le partage) dès 2 caractères ;
// en deçà, simple filtre local sur le dossier courant.
const isSearchMode = computed(() => filter.value.trim().length >= MIN_SEARCH_LENGTH)
const visibleEntries = computed(() => {
const f = filter.value.trim().toLowerCase()
if (!f) return entries.value
if (f.length < MIN_SEARCH_LENGTH) {
return entries.value.filter((e) => e.name.toLowerCase().includes(f))
}
return searchResults.value
})
function parentDir(entry: FileEntry): string {
const idx = entry.path.lastIndexOf('/')
return idx === -1 ? '' : entry.path.slice(0, idx)
}
let searchTimer: ReturnType<typeof setTimeout> | null = null
let searchSeq = 0
async function runSearch(query: string) {
const seq = ++searchSeq
searching.value = true
searchError.value = null
try {
const result = await search(query)
if (seq !== searchSeq) return // une frappe plus récente a pris le relais
searchResults.value = result.entries
} catch (e: unknown) {
if (seq !== searchSeq) return
searchError.value = (e as Error)?.message ?? 'Erreur'
searchResults.value = []
} finally {
if (seq === searchSeq) searching.value = false
}
}
watch(filter, (value) => {
if (searchTimer) clearTimeout(searchTimer)
const q = value.trim()
if (q.length < MIN_SEARCH_LENGTH) {
searchSeq++ // invalide toute recherche en vol
searching.value = false
searchError.value = null
searchResults.value = []
return
}
searching.value = true
searchTimer = setTimeout(() => runSearch(q), 300)
})
const fileEntries = computed(() => visibleEntries.value.filter((e) => !e.isDir))
const previewIndex = computed(() => previewEntry.value ? fileEntries.value.findIndex((e) => e.path === previewEntry.value!.path) : -1)
async function load(path: string) {
loading.value = true
error.value = null
try {
const result = await browse(path)
currentPath.value = result.path
breadcrumb.value = result.breadcrumb
entries.value = result.entries
} catch (e: unknown) {
error.value = (e as Error)?.message ?? 'Erreur'
entries.value = []
} finally {
loading.value = false
}
}
function openPath(path: string) {
filter.value = ''
load(path)
}
function reload() {
load(currentPath.value)
}
function onEntryClick(entry: FileEntry) {
if (entry.isDir) {
openPath(entry.path)
} else {
previewEntry.value = entry
}
}
function stepPreview(delta: number) {
const idx = previewIndex.value + delta
if (idx >= 0 && idx < fileEntries.value.length) {
previewEntry.value = fileEntries.value[idx] ?? null
}
}
function iconForMime(mime: string): string {
if (mime.startsWith('image/')) return 'mdi:file-image-outline'
if (mime === 'application/pdf') return 'mdi:file-pdf-box'
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'mdi:file-word-outline'
if (mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'mdi:file-excel-outline'
if (mime.startsWith('text/')) return 'mdi:file-document-outline'
return 'mdi:file-outline'
}
function formatDate(ts: number | null): string {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString()
}
onMounted(async () => {
await ensureLoaded()
if (enabled.value === false) {
await navigateTo('/')
return
}
load('')
})
</script>
+1 -1
View File
@@ -439,7 +439,7 @@ onMounted(async () => {
<div <div
v-for="cat in CATEGORIES" v-for="cat in CATEGORIES"
:key="cat" :key="cat"
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition" class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50 transition"
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''" :class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
@dragover.prevent="dragOverCategory = cat" @dragover.prevent="dragOverCategory = cat"
@dragleave="dragOverCategory = null" @dragleave="dragOverCategory = null"
+3 -3
View File
@@ -129,6 +129,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService' import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token' import { useApiTokenService } from '~/services/api-token'
import { copyToClipboard } from '~/utils/clipboard'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
@@ -181,10 +182,9 @@ async function onRemove() {
async function onCopy() { async function onCopy() {
if (!auth.user?.apiToken) return if (!auth.user?.apiToken) return
try { if (await copyToClipboard(auth.user.apiToken)) {
await navigator.clipboard.writeText(auth.user.apiToken)
toast.success({ message: t('profile.apiToken.copied') }) toast.success({ message: t('profile.apiToken.copied') })
} catch { } else {
toast.error({ message: t('profile.apiToken.copyFailed') }) toast.error({ message: t('profile.apiToken.copyFailed') })
} }
} }
+1 -1
View File
@@ -96,7 +96,7 @@
<div <div
v-for="status in statuses" v-for="status in statuses"
:key="status.id" :key="status.id"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors" class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'" :class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(status.id)" @dragenter.prevent="onDragEnter(status.id)"
+53
View File
@@ -0,0 +1,53 @@
export type FileEntry = {
name: string
path: string
isDir: boolean
size: number
modifiedAt: number | null
mimeType: string
}
export type Breadcrumb = {
name: string
path: string
}
export type ShareBrowseResult = {
path: string
breadcrumb: Breadcrumb[]
entries: FileEntry[]
}
export type ShareSearchResult = {
query: string
entries: FileEntry[]
}
export type ShareStatus = {
enabled: boolean
}
export type ShareSettings = {
host: string | null
shareName: string | null
basePath: string | null
domain: string | null
username: string | null
enabled: boolean
hasPassword: boolean
}
export type ShareSettingsWrite = {
host: string | null
shareName: string | null
basePath: string | null
domain: string | null
username: string | null
password?: string | null
enabled: boolean
}
export type ShareTestResult = {
success: boolean
message: string | null
}
+2 -1
View File
@@ -5,7 +5,8 @@ export type TaskDocument = {
id: number id: number
task: string task: string
originalName: string originalName: string
fileName: string fileName?: string | null
sharePath?: string | null
mimeType: string mimeType: string
size: number size: number
createdAt: string createdAt: string
+21
View File
@@ -0,0 +1,21 @@
import type { ShareSettings, ShareSettingsWrite, ShareTestResult } from './dto/share'
export function useShareSettingsService() {
const api = useApi()
async function getSettings(): Promise<ShareSettings> {
return api.get<ShareSettings>('/settings/share')
}
async function saveSettings(payload: ShareSettingsWrite): Promise<ShareSettings> {
return api.put<ShareSettings>('/settings/share', payload as Record<string, unknown>, {
toastSuccessKey: 'adminShare.saved',
})
}
async function testConnection(): Promise<ShareTestResult> {
return api.post<ShareTestResult>('/settings/share/test', {})
}
return { getSettings, saveSettings, testConnection }
}
+26
View File
@@ -0,0 +1,26 @@
import type { ShareBrowseResult, ShareSearchResult, ShareStatus } from './dto/share'
export function useShareService() {
const api = useApi()
const config = useRuntimeConfig()
async function browse(path: string): Promise<ShareBrowseResult> {
const query = path ? `?path=${encodeURIComponent(path)}` : ''
return api.get<ShareBrowseResult>(`/share/browse${query}`)
}
async function search(query: string): Promise<ShareSearchResult> {
return api.get<ShareSearchResult>(`/share/search?q=${encodeURIComponent(query)}`)
}
async function getStatus(): Promise<ShareStatus> {
return api.get<ShareStatus>('/share/status')
}
function getDownloadUrl(path: string, disposition: 'inline' | 'attachment' = 'inline'): string {
const base = config.public.apiBase || '/api'
return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}`
}
return { browse, search, getStatus, getDownloadUrl }
}
+10 -1
View File
@@ -31,6 +31,15 @@ export function useTaskDocumentService() {
return uploadWithRelation('task', `/api/tasks/${taskId}`, file) return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
} }
async function linkShare(taskId: number, sharePath: string): Promise<TaskDocument> {
return $fetch<TaskDocument>(`${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<void> { async function remove(id: number): Promise<void> {
await api.delete(`/task_documents/${id}`, {}, { await api.delete(`/task_documents/${id}`, {}, {
toastSuccessKey: 'taskDocuments.deleted', toastSuccessKey: 'taskDocuments.deleted',
@@ -48,5 +57,5 @@ export function useTaskDocumentService() {
}) })
} }
return { getByTask, upload, remove, getDownloadUrl, getContent } return { getByTask, upload, linkShare, remove, getDownloadUrl, getContent }
} }
+40
View File
@@ -0,0 +1,40 @@
/**
* Copy text to the clipboard with a fallback for non-secure contexts.
*
* `navigator.clipboard` is only available in secure contexts (HTTPS or
* localhost). On a plain HTTP origin (e.g. an internal/prod server without
* TLS) the API is missing, so we fall back to the legacy
* `document.execCommand('copy')` using a temporary off-screen textarea.
*
* @returns `true` if the copy succeeded, `false` otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Preferred path: available in secure contexts (HTTPS / localhost).
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the legacy fallback below.
}
}
// Legacy fallback: works on plain HTTP origins.
try {
const textarea = document.createElement('textarea')
textarea.value = text
// Keep it out of view and prevent layout shift / scrolling.
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.left = '-9999px'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
+1
View File
@@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y \
wget \ wget \
git \ git \
unzip \ unzip \
smbclient \
&& docker-php-ext-install -j$(nproc) \ && docker-php-ext-install -j$(nproc) \
intl \ intl \
zip \ zip \
+1 -1
View File
@@ -40,7 +40,7 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \ libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \ nginx supervisor smbclient \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \ && docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add share_configuration table for SMB/Windows share explorer feature.
*/
final class Version20260603165850 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create share_configuration table (SMB/Windows share explorer)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE share_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, host VARCHAR(255) DEFAULT NULL, share_name VARCHAR(255) DEFAULT NULL, base_path VARCHAR(255) DEFAULT NULL, domain VARCHAR(255) DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, encrypted_password TEXT DEFAULT NULL, enabled BOOLEAN NOT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE share_configuration');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Ajoute le support des documents de tâche liés au partage SMB :
* colonne share_path (chemin relatif sur le partage) et file_name rendu nullable
* (un lien SMB n'a pas de fichier sur disque).
*/
final class Version20260612131431 extends AbstractMigration
{
public function getDescription(): string
{
return 'TaskDocument: add share_path column and make file_name nullable (SMB share links)';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\ShareSettingsProcessor;
use App\State\ShareSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/share',
normalizationContext: ['groups' => ['share_settings:read']],
provider: ShareSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/share',
denormalizationContext: ['groups' => ['share_settings:write']],
normalizationContext: ['groups' => ['share_settings:read']],
provider: ShareSettingsProvider::class,
processor: ShareSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ShareSettings
{
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $host = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $shareName = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $basePath = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $domain = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $username = null;
#[Groups(['share_settings:write'])]
public ?string $password = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public bool $enabled = false;
#[Groups(['share_settings:read'])]
public bool $hasPassword = false;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\ShareTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/share/test',
input: false,
normalizationContext: ['groups' => ['share_test:read']],
provider: ShareTestConnectionProvider::class,
processor: ShareTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ShareTestConnection
{
#[Groups(['share_test:read'])]
public bool $success = false;
#[Groups(['share_test:read'])]
public ?string $message = null;
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareBrowseController extends AbstractController
{
public function __construct(
private readonly FileSource $fileSource,
private readonly SharePathResolver $pathResolver,
) {}
#[Route('/api/share/browse', name: 'share_browse', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): JsonResponse
{
$rawPath = (string) $request->query->get('path', '');
try {
$path = $this->pathResolver->normalizeRelative($rawPath);
} catch (InvalidPathException) {
return new JsonResponse(['error' => 'Invalid path.'], 400);
}
try {
$entries = $this->fileSource->dir($path);
} catch (ShareNotConfiguredException) {
return new JsonResponse(['error' => 'Share not configured.'], 409);
} catch (ShareConnectionException) {
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
}
return new JsonResponse([
'path' => $path,
'breadcrumb' => $this->breadcrumb($path),
'entries' => array_map(static fn (FileEntry $e): array => [
'name' => $e->name,
'path' => $e->path,
'isDir' => $e->isDir,
'size' => $e->size,
'modifiedAt' => $e->modifiedAt,
'mimeType' => $e->mimeType,
], $entries),
]);
}
/**
* @return array<int, array{name: string, path: string}>
*/
private function breadcrumb(string $path): array
{
if ('' === $path) {
return [];
}
$crumbs = [];
$acc = '';
foreach (explode('/', $path) as $segment) {
$acc = '' === $acc ? $segment : $acc.'/'.$segment;
$crumbs[] = ['name' => $segment, 'path' => $acc];
}
return $crumbs;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function is_resource;
class ShareDownloadController extends AbstractController
{
public function __construct(
private readonly FileSource $fileSource,
private readonly SharePathResolver $pathResolver,
) {}
#[Route('/api/share/download', name: 'share_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): Response
{
$rawPath = (string) $request->query->get('path', '');
try {
$path = $this->pathResolver->normalizeRelative($rawPath);
} catch (InvalidPathException) {
return new Response('Invalid path.', 400);
}
if ('' === $path) {
throw new NotFoundHttpException('No file requested.');
}
try {
$stream = $this->fileSource->read($path);
} catch (ShareNotConfiguredException) {
return new Response('Share not configured.', 409);
} catch (ShareConnectionException) {
throw new NotFoundHttpException('File not found.');
}
$name = basename($path);
$extension = pathinfo($name, PATHINFO_EXTENSION);
$mime = MimeTypes::getDefault()->getMimeTypes($extension)[0] ?? 'application/octet-stream';
// Anti-XSS : seuls des types non exécutables sont servis inline (images hors SVG, PDF).
// Tout le reste (HTML, SVG, octet-stream, etc.) est forcé en attachment, même si inline est demandé.
$inlineSafe = ('image/svg+xml' !== $mime && str_starts_with($mime, 'image/')) || 'application/pdf' === $mime;
$wantInline = 'attachment' !== $request->query->get('disposition');
$disposition = ($inlineSafe && $wantInline) ? HeaderUtils::DISPOSITION_INLINE : HeaderUtils::DISPOSITION_ATTACHMENT;
$response = new StreamedResponse(function () use ($stream): void {
if (is_resource($stream)) {
fpassthru($stream);
fclose($stream);
}
});
$response->headers->set('Content-Type', $mime);
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, $name));
// Empêche le navigateur de "deviner" un type exécutable à partir du contenu.
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileEntry;
use App\Service\Share\FileSource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareSearchController extends AbstractController
{
/** Longueur minimale du terme de recherche (évite un parcours global trop large). */
private const int MIN_QUERY_LENGTH = 2;
public function __construct(
private readonly FileSource $fileSource,
) {}
#[Route('/api/share/search', name: 'share_search', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): JsonResponse
{
$query = trim((string) $request->query->get('q', ''));
if (mb_strlen($query) < self::MIN_QUERY_LENGTH) {
return new JsonResponse(['query' => $query, 'entries' => []]);
}
try {
$entries = $this->fileSource->search($query);
} catch (ShareNotConfiguredException) {
return new JsonResponse(['error' => 'Share not configured.'], 409);
} catch (ShareConnectionException) {
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
}
return new JsonResponse([
'query' => $query,
'entries' => array_map(static fn (FileEntry $e): array => [
'name' => $e->name,
'path' => $e->path,
'isDir' => $e->isDir,
'size' => $e->size,
'modifiedAt' => $e->modifiedAt,
'mimeType' => $e->mimeType,
], $entries),
]);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
use App\Repository\ShareConfigurationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareStatusController extends AbstractController
{
public function __construct(
private readonly ShareConfigurationRepository $configRepository,
) {}
#[Route('/api/share/status', name: 'share_status', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
$config = $this->configRepository->findSingleton();
return new JsonResponse(['enabled' => null !== $config && $config->isUsable()]);
}
}
@@ -5,24 +5,33 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\TaskDocument; 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 Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use function is_resource;
class TaskDocumentDownloadController extends AbstractController class TaskDocumentDownloadController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly FileSource $fileSource,
private readonly string $uploadDir, private readonly string $uploadDir,
) {} ) {}
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)] #[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')] #[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(int $id): BinaryFileResponse public function __invoke(int $id): Response
{ {
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id); $document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
@@ -30,6 +39,20 @@ class TaskDocumentDownloadController extends AbstractController
throw new NotFoundHttpException('Document not found.'); 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(); $filePath = $this->uploadDir.'/'.$document->getFileName();
if (!file_exists($filePath)) { if (!file_exists($filePath)) {
@@ -37,18 +60,32 @@ class TaskDocumentDownloadController extends AbstractController
} }
$response = new BinaryFileResponse($filePath); $response = new BinaryFileResponse($filePath);
$mimeType = $document->getMimeType() ?? 'application/octet-stream'; $response->setContentDisposition($disposition, (string) $document->getOriginalName());
// 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->headers->set('Content-Type', $mimeType); $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; return $response;
} }
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ShareConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ShareConfigurationRepository::class)]
class ShareConfiguration
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $host = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shareName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $basePath = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $domain = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $username = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedPassword = null;
#[ORM\Column(type: 'boolean')]
private bool $enabled = false;
public function getId(): ?int
{
return $this->id;
}
public function getHost(): ?string
{
return $this->host;
}
public function setHost(?string $host): static
{
$this->host = $host;
return $this;
}
public function getShareName(): ?string
{
return $this->shareName;
}
public function setShareName(?string $shareName): static
{
$this->shareName = $shareName;
return $this;
}
public function getBasePath(): ?string
{
return $this->basePath;
}
public function setBasePath(?string $basePath): static
{
$this->basePath = $basePath;
return $this;
}
public function getDomain(): ?string
{
return $this->domain;
}
public function setDomain(?string $domain): static
{
$this->domain = $domain;
return $this;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
public function getEncryptedPassword(): ?string
{
return $this->encryptedPassword;
}
public function setEncryptedPassword(?string $encryptedPassword): static
{
$this->encryptedPassword = $encryptedPassword;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
public function hasPassword(): bool
{
return null !== $this->encryptedPassword;
}
public function isUsable(): bool
{
return $this->enabled
&& null !== $this->host && '' !== $this->host
&& null !== $this->shareName && '' !== $this->shareName;
}
}
+27 -2
View File
@@ -53,10 +53,18 @@ class TaskDocument
#[Groups(['task_document:read', 'task:read'])] #[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null; private ?string $originalName = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255, nullable: true)]
#[Groups(['task_document:read', 'task:read'])] #[Groups(['task_document:read', 'task:read'])]
private ?string $fileName = null; 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)] #[ORM\Column(length: 100)]
#[Groups(['task_document:read', 'task:read'])] #[Groups(['task_document:read', 'task:read'])]
private ?string $mimeType = null; private ?string $mimeType = null;
@@ -108,13 +116,30 @@ class TaskDocument
return $this->fileName; return $this->fileName;
} }
public function setFileName(string $fileName): static public function setFileName(?string $fileName): static
{ {
$this->fileName = $fileName; $this->fileName = $fileName;
return $this; 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 public function getMimeType(): ?string
{ {
return $this->mimeType; return $this->mimeType;
@@ -17,6 +17,11 @@ class TaskDocumentListener
public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void 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(); $filePath = $this->uploadDir.'/'.$document->getFileName();
if (file_exists($filePath)) { if (file_exists($filePath)) {
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task-document', description: 'Delete a document attached to a task, permanently. The underlying file is also removed from disk.')]
class DeleteTaskDocumentTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param int $id ID of the task document to delete
*/
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
$taskId = $document->getTask()?->getId();
$originalName = $document->getOriginalName();
$this->entityManager->remove($document);
$this->entityManager->flush();
return json_encode([
'success' => true,
'message' => sprintf('Document "%s" (ID %d) deleted.', $originalName, $id),
'id' => $id,
'taskId' => $taskId,
'originalName' => $originalName,
]);
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
use function strlen;
#[McpTool(name: 'update-task-document', description: 'Update a document attached to a task: replace its text content and/or rename it. Pass the new raw content (verbatim UTF-8) and/or a new fileName. The MIME type is re-inferred from the fileName extension. At least one of content or fileName must be provided.')]
class UpdateTaskDocumentTool
{
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
private const EXTENSION_TO_MIME = [
'md' => 'text/markdown',
'markdown' => 'text/markdown',
'txt' => 'text/plain',
'csv' => 'text/csv',
'json' => 'application/json',
'xml' => 'text/xml',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
/**
* @param int $id ID of the task document to update
* @param null|string $content New raw text content of the document (e.g. Markdown). Omit to keep the current content.
* @param null|string $fileName New display name of the document, including extension. Omit to keep the current name.
*/
public function __invoke(
int $id,
?string $content = null,
?string $fileName = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
if (null === $content && null === $fileName) {
throw new InvalidArgumentException('At least one of content or fileName must be provided.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
// Rename: update the display name and re-infer the MIME type from its extension.
if (null !== $fileName) {
$originalName = trim($fileName);
if ('' === $originalName) {
throw new InvalidArgumentException('fileName cannot be empty.');
}
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if ('' === $extension) {
$originalName .= '.md';
$extension = 'md';
}
$document->setOriginalName($originalName);
$document->setMimeType(self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown');
}
// Replace content: overwrite the stored file in place and refresh its size.
if (null !== $content) {
if ('' === $content) {
throw new InvalidArgumentException('Document content cannot be empty.');
}
$size = strlen($content);
if ($size > self::MAX_CONTENT_SIZE) {
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (false === file_put_contents($filePath, $content)) {
throw new InvalidArgumentException('Failed to write document to disk.');
}
$document->setSize($size);
}
$this->entityManager->flush();
return json_encode([
'id' => $document->getId(),
'taskId' => $document->getTask()?->getId(),
'originalName' => $document->getOriginalName(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'createdAt' => $document->getCreatedAt()?->format('c'),
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
]);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ShareConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ShareConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ShareConfiguration::class);
}
public function findSingleton(): ?ShareConfiguration
{
return $this->createQueryBuilder('s')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Share\Exception;
use RuntimeException;
final class InvalidPathException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Share\Exception;
use RuntimeException;
final class ShareConnectionException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Share\Exception;
use RuntimeException;
final class ShareNotConfiguredException extends RuntimeException {}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
final readonly class FileEntry
{
public function __construct(
public string $name,
public string $path,
public bool $isDir,
public int $size,
public ?int $modifiedAt,
public string $mimeType,
) {}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
interface FileSource
{
/**
* @return FileEntry[] dossiers d'abord, puis fichiers, triés par nom
*/
public function dir(string $relativePath): array;
/**
* Recherche récursive, par fragment de nom (insensible à la casse), dans tout le partage.
*
* @return FileEntry[] dossiers d'abord, puis fichiers, triés par nom (limités)
*/
public function search(string $query, int $limit = 200): array;
/**
* @return resource flux binaire en lecture
*/
public function read(string $relativePath);
public function test(): ShareTestResult;
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
use App\Service\Share\Exception\InvalidPathException;
final class SharePathResolver
{
/**
* Normalise un chemin relatif et rejette toute tentative de sortie de racine.
*/
public function normalizeRelative(string $path): string
{
$path = str_replace('\\', '/', $path);
$segments = [];
foreach (explode('/', $path) as $segment) {
if ('' === $segment || '.' === $segment) {
continue;
}
if ('..' === $segment) {
throw new InvalidPathException('Path traversal is not allowed.');
}
$segments[] = $segment;
}
return implode('/', $segments);
}
/**
* Construit le chemin SMB absolu (toujours sous basePath).
*/
public function fullPath(string $basePath, string $relativePath): string
{
$base = trim(str_replace('\\', '/', $basePath), '/');
$relative = $this->normalizeRelative($relativePath);
$parts = array_values(array_filter([$base, $relative], static fn (string $p): bool => '' !== $p));
return '/'.implode('/', $parts);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
final readonly class ShareTestResult
{
public function __construct(
public bool $success,
public ?string $message = null,
) {}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
use App\Entity\ShareConfiguration;
use App\Repository\ShareConfigurationRepository;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\TokenEncryptor;
use Icewind\SMB\BasicAuth;
use Icewind\SMB\IFileInfo;
use Icewind\SMB\IShare;
use Icewind\SMB\ServerFactory;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
use function count;
final class SmbFileSource implements FileSource
{
/** Garde-fou : nombre maximum de dossiers explorés par recherche (évite de bloquer sur un très gros partage). */
private const int SEARCH_MAX_DIRS = 2000;
public function __construct(
private readonly ShareConfigurationRepository $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
private readonly SharePathResolver $pathResolver,
) {}
public function dir(string $relativePath): array
{
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);
try {
$infos = $share->dir($full);
} catch (Throwable $e) {
throw new ShareConnectionException($e->getMessage(), 0, $e);
}
$entries = array_map(fn (IFileInfo $i): FileEntry => $this->toEntry($i, $relativePath), $infos);
$this->sortEntries($entries);
return $entries;
}
public function search(string $query, int $limit = 200): array
{
$needle = trim($query);
if ('' === $needle) {
return [];
}
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$base = (string) $config->getBasePath();
$results = [];
$queue = ['']; // chemins relatifs des dossiers à explorer, racine en premier (parcours en largeur)
$visitedDirs = 0;
while ([] !== $queue && count($results) < $limit && $visitedDirs < self::SEARCH_MAX_DIRS) {
$relative = array_shift($queue);
$full = $this->pathResolver->fullPath($base, $relative);
try {
$infos = $share->dir($full);
} catch (Throwable) {
continue; // dossier illisible (droits, lien mort…) : on l'ignore et on poursuit
}
++$visitedDirs;
foreach ($infos as $info) {
$entry = $this->toEntry($info, $relative);
if ($entry->isDir) {
$queue[] = $entry->path;
}
if (false !== mb_stripos($entry->name, $needle)) {
$results[] = $entry;
if (count($results) >= $limit) {
break;
}
}
}
}
$this->sortEntries($results);
return $results;
}
public function read(string $relativePath)
{
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$full = $this->pathResolver->fullPath((string) $config->getBasePath(), $relativePath);
try {
return $share->read($full);
} catch (Throwable $e) {
throw new ShareConnectionException($e->getMessage(), 0, $e);
}
}
public function test(): ShareTestResult
{
try {
$config = $this->requireUsableConfig();
$share = $this->connect($config);
$share->dir($this->pathResolver->fullPath((string) $config->getBasePath(), ''));
return new ShareTestResult(true);
} catch (ShareNotConfiguredException $e) {
return new ShareTestResult(false, 'Configuration incomplète ou désactivée.');
} catch (Throwable $e) {
return new ShareTestResult(false, $e->getMessage());
}
}
private function requireUsableConfig(): ShareConfiguration
{
$config = $this->configRepository->findSingleton();
if (null === $config || !$config->isUsable()) {
throw new ShareNotConfiguredException('Share is not configured or disabled.');
}
return $config;
}
private function connect(ShareConfiguration $config): IShare
{
$password = null !== $config->getEncryptedPassword()
? $this->tokenEncryptor->decrypt($config->getEncryptedPassword())
: '';
$auth = new BasicAuth(
(string) $config->getUsername(),
$config->getDomain() ?: 'WORKGROUP',
$password,
);
$server = new ServerFactory()->createServer((string) $config->getHost(), $auth);
try {
return $server->getShare((string) $config->getShareName());
} catch (Throwable $e) {
throw new ShareConnectionException($e->getMessage(), 0, $e);
}
}
/**
* Trie en place : dossiers d'abord, puis tri alphabétique insensible à la casse.
*
* @param FileEntry[] $entries
*/
private function sortEntries(array &$entries): void
{
usort($entries, static function (FileEntry $a, FileEntry $b): int {
if ($a->isDir !== $b->isDir) {
return $a->isDir ? -1 : 1;
}
return strcasecmp($a->name, $b->name);
});
}
private function toEntry(IFileInfo $info, string $parentRelative): FileEntry
{
$parent = '' === $parentRelative ? '' : rtrim($parentRelative, '/').'/';
$path = $parent.$info->getName();
$isDir = $info->isDirectory();
$mime = 'application/octet-stream';
if (!$isDir) {
$guessed = MimeTypes::getDefault()->getMimeTypes(pathinfo($info->getName(), PATHINFO_EXTENSION));
$mime = $guessed[0] ?? 'application/octet-stream';
}
return new FileEntry(
name: $info->getName(),
path: $path,
isDir: $isDir,
size: $isDir ? 0 : $info->getSize(),
modifiedAt: $info->getMTime(),
mimeType: $mime,
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ShareSettings;
use App\Entity\ShareConfiguration;
use App\Repository\ShareConfigurationRepository;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class ShareSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private ShareConfigurationRepository $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
{
assert($data instanceof ShareSettings);
$config = $this->configRepository->findSingleton() ?? new ShareConfiguration();
$config->setHost($data->host);
$config->setShareName($data->shareName);
$config->setBasePath($data->basePath);
$config->setDomain($data->domain);
$config->setUsername($data->username);
$config->setEnabled($data->enabled);
if (null !== $data->password && '' !== $data->password) {
$config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
}
$this->em->persist($config);
$this->em->flush();
$result = new ShareSettings();
$result->host = $config->getHost();
$result->shareName = $config->getShareName();
$result->basePath = $config->getBasePath();
$result->domain = $config->getDomain();
$result->username = $config->getUsername();
$result->enabled = $config->isEnabled();
$result->hasPassword = $config->hasPassword();
return $result;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ShareSettings;
use App\Repository\ShareConfigurationRepository;
final readonly class ShareSettingsProvider implements ProviderInterface
{
public function __construct(
private ShareConfigurationRepository $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
{
$config = $this->configRepository->findSingleton();
$dto = new ShareSettings();
if (null !== $config) {
$dto->host = $config->getHost();
$dto->shareName = $config->getShareName();
$dto->basePath = $config->getBasePath();
$dto->domain = $config->getDomain();
$dto->username = $config->getUsername();
$dto->enabled = $config->isEnabled();
$dto->hasPassword = $config->hasPassword();
}
return $dto;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ShareTestConnection;
use App\Service\Share\FileSource;
final readonly class ShareTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private FileSource $fileSource,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
{
return new ShareTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
{
$result = $this->fileSource->test();
$dto = new ShareTestConnection();
$dto->success = $result->success;
$dto->message = $result->message;
return $dto;
}
}
+141 -18
View File
@@ -8,13 +8,23 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Entity\Task; use App\Entity\Task;
use App\Entity\TaskDocument; 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 DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use function in_array;
/** /**
* @implements ProcessorInterface<TaskDocument, TaskDocument> * @implements ProcessorInterface<TaskDocument, TaskDocument>
*/ */
@@ -55,6 +65,8 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private Security $security, private Security $security,
private RequestStack $requestStack, private RequestStack $requestStack,
private FileSource $fileSource,
private SharePathResolver $pathResolver,
private string $uploadDir, private string $uploadDir,
) {} ) {}
@@ -63,12 +75,57 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
*/ */
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument 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, 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')) {
throw new AccessDeniedHttpException('Creating task documents requires admin privileges.');
}
$request = $this->requestStack->getCurrentRequest(); $request = $this->requestStack->getCurrentRequest();
if (null === $request) { if (null === $request) {
throw new BadRequestHttpException('No request available.'); 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'); $file = $request->files->get('file');
if (null === $file || !$file->isValid()) { if (null === $file || !$file->isValid()) {
@@ -79,17 +136,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
throw new BadRequestHttpException('File size exceeds 50 MB limit.'); throw new BadRequestHttpException('File size exceeds 50 MB limit.');
} }
$taskIri = $request->request->get('task', ''); $task = $this->resolveTask($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.');
}
// Use server-detected MIME type (finfo), not the client-supplied one // Use server-detected MIME type (finfo), not the client-supplied one
$originalName = $file->getClientOriginalName(); $originalName = $file->getClientOriginalName();
@@ -101,8 +148,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
} }
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin'; $extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
$uuid = Uuid::v4()->toRfc4122(); $fileName = Uuid::v4()->toRfc4122().'.'.$extension;
$fileName = $uuid.'.'.$extension;
if (!is_dir($this->uploadDir)) { if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true); mkdir($this->uploadDir, 0o775, true);
@@ -116,12 +162,89 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
$document->setFileName($fileName); $document->setFileName($fileName);
$document->setMimeType($mimeType); $document->setMimeType($mimeType);
$document->setSize($fileSize); $document->setSize($fileSize);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return $document; 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;
}
} }
+12
View File
@@ -184,6 +184,18 @@
"symfony/mcp-bundle": { "symfony/mcp-bundle": {
"version": "v0.6.0" "version": "v0.6.0"
}, },
"symfony/messenger": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/monolog-bundle": { "symfony/monolog-bundle": {
"version": "4.0", "version": "4.0",
"recipe": { "recipe": {
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class ShareBrowseTest extends WebTestCase
{
public function testBrowseRequiresAuthentication(): void
{
$client = self::createClient();
$client->request('GET', '/api/share/browse?path=/');
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testBrowseRejectsPathTraversal(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/browse?path='.urlencode('../etc'));
self::assertSame(400, $client->getResponse()->getStatusCode());
}
public function testBrowseReturns409WhenNotConfigured(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/browse?path=');
self::assertSame(409, $client->getResponse()->getStatusCode());
}
public function testStatusReturnsDisabledByDefault(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/status');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertFalse($data['enabled']);
}
private function login(KernelBrowser $client): void
{
$em = self::getContainer()->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user);
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class ShareSearchTest extends WebTestCase
{
public function testSearchRequiresAuthentication(): void
{
$client = self::createClient();
$client->request('GET', '/api/share/search?q=rapport');
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testSearchReturnsEmptyForShortQuery(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/search?q=a');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame('a', $data['query']);
self::assertSame([], $data['entries']);
}
public function testSearchReturns409WhenNotConfigured(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/search?q=rapport');
self::assertSame(409, $client->getResponse()->getStatusCode());
}
private function login(KernelBrowser $client): void
{
$em = self::getContainer()->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
class ShareSettingsTest extends WebTestCase
{
public function testGetSettingsReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->request('GET', '/api/settings/share');
self::assertResponseStatusCodeSame(401);
}
public function testGetSettingsReturns403ForRoleUser(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user);
$client->request('GET', '/api/settings/share');
self::assertResponseStatusCodeSame(403);
}
public function testAdminCanReadSettingsWithoutPasswordLeak(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$client->loginUser($admin);
$client->request('GET', '/api/settings/share');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('hasPassword', $data);
self::assertArrayNotHasKey('password', $data);
self::assertArrayNotHasKey('encryptedPassword', $data);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\SharePathResolver;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SharePathResolverTest extends TestCase
{
private SharePathResolver $resolver;
protected function setUp(): void
{
$this->resolver = new SharePathResolver();
}
public function testNormalizeRelativeKeepsSimplePath(): void
{
self::assertSame('a/b', $this->resolver->normalizeRelative('a/b'));
}
public function testNormalizeRelativeStripsDotsAndSlashes(): void
{
self::assertSame('a/b', $this->resolver->normalizeRelative('/a/./b/'));
}
public function testNormalizeRelativeConvertsBackslashes(): void
{
self::assertSame('a/b', $this->resolver->normalizeRelative('a\b'));
}
public function testNormalizeRelativeRejectsParentTraversal(): void
{
$this->expectException(InvalidPathException::class);
$this->resolver->normalizeRelative('a/../b');
}
public function testNormalizeRelativeRejectsLeadingParent(): void
{
$this->expectException(InvalidPathException::class);
$this->resolver->normalizeRelative('../etc/passwd');
}
public function testFullPathJoinsBaseAndRelative(): void
{
self::assertSame('/Projets/a/b', $this->resolver->fullPath('/Projets', 'a/b'));
}
public function testFullPathWithEmptyBaseAndEmptyRelativeIsRoot(): void
{
self::assertSame('/', $this->resolver->fullPath('', ''));
}
public function testFullPathTrimsBaseSlashes(): void
{
self::assertSame('/Projets/a', $this->resolver->fullPath('/Projets/', 'a'));
}
}