Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8acdd9817 | |||
| 920539a050 | |||
| 5a3be7a170 | |||
| 5014dd063e | |||
| 0a6a88e2fa | |||
| 8475f9604c |
@@ -13,32 +13,25 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
|
||||
## 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/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
||||
src/Enum/ # PHP enums (RecurrenceType)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task + métadonnées Task*, TimeEntry, Notification, *Configuration…)
|
||||
src/ApiResource/ # Ressources API Platform découplées des entités
|
||||
src/State/ # Providers & Processors API Platform (Me, ActiveTimeEntry, TaskNumber, Notification, Gitea*, Zimbra*, RecurrenceHandler…)
|
||||
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
||||
src/Command/ # Commandes console (GenerateApiTokenCommand)
|
||||
src/Repository/ # Repositories Doctrine
|
||||
src/DataFixtures/ # Fixtures
|
||||
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
|
||||
config/jwt/ # Clés JWT (private.pem, public.pem)
|
||||
migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
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/)
|
||||
src/Controller/ # Controllers custom (notifications, avatar, download document)
|
||||
src/Mcp/Tool/ # MCP tools par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
src/Security/ # ApiTokenAuthenticator (MCP HTTP)
|
||||
src/Command/ src/Repository/ src/DataFixtures/
|
||||
config/ # security, api_platform, lexik_jwt, nelmio_cors, doctrine — config/jwt/ = clés
|
||||
migrations/ docs/plans/ docs/superpowers/
|
||||
frontend/pages/ # index, login, my-tasks, profile, projects/[id]/*, time-tracking, admin
|
||||
frontend/components/ # Sous-dossiers ui/ client/ project/ task/ user/ admin/ time-tracking/ notification/
|
||||
frontend/composables/# useApi, useAppVersion, useNotifications, useAvatarService
|
||||
frontend/stores/ # Pinia : auth, ui, timer
|
||||
frontend/services/ # 1 service par ressource API (+ services/dto/ pour les types)
|
||||
frontend/i18n/locales/ # Traductions (langDir résolu depuis i18n/)
|
||||
```
|
||||
|
||||
## Commandes
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"doctrine/doctrine-bundle": "^3.2",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.6",
|
||||
"icewind/smb": "^3.8",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
|
||||
Generated
+1
-73
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
|
||||
"content-hash": "dc72ee68996f3f738763eafd350bc0e0",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2508,78 +2508,6 @@
|
||||
},
|
||||
"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",
|
||||
"version": "v13.8.0",
|
||||
|
||||
@@ -64,5 +64,3 @@ services:
|
||||
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.23'
|
||||
app.version: '0.4.26'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,186 +0,0 @@
|
||||
# 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.
|
||||
```
|
||||
@@ -19,3 +19,20 @@
|
||||
padding-top: 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;
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<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>
|
||||
@@ -1,173 +0,0 @@
|
||||
<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>
|
||||
|
||||
<!-- 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 (hors bloc texte car il a déjà le nom dans l'en-tête) -->
|
||||
<p v-if="!isText" 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)
|
||||
|
||||
const { getDownloadUrl } = useShareService()
|
||||
|
||||
const TEXT_RE = /\.(md|markdown|txt|csv|json|xml|log)$/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
|
||||
)
|
||||
|
||||
watch(() => props.entry, async (entry) => {
|
||||
textContent.value = ''
|
||||
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
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -428,40 +428,6 @@
|
||||
"testFailed": "Connexion échouée"
|
||||
}
|
||||
},
|
||||
"sharedFiles": {
|
||||
"title": "Documents",
|
||||
"root": "Racine",
|
||||
"empty": "Ce dossier est vide.",
|
||||
"filterPlaceholder": "Filtrer ce dossier…",
|
||||
"download": "Télécharger",
|
||||
"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": {
|
||||
"created": "Récurrence créée",
|
||||
"updated": "Récurrence mise à jour",
|
||||
|
||||
@@ -100,14 +100,6 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@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">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
@@ -230,9 +222,6 @@ const isMailVisible = computed(() => {
|
||||
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)
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
@@ -278,17 +267,13 @@ onMounted(() => {
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
ensureShareStatus()
|
||||
})
|
||||
|
||||
watch(() => auth.user, (user) => {
|
||||
if (!user) {
|
||||
mailStore.stopPolling()
|
||||
} else {
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
ensureShareStatus()
|
||||
} else if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Generated
+63
-44
@@ -7,7 +7,7 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@malio/layer-ui": "^1.7.5",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -82,7 +82,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1044,6 +1043,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
@@ -1053,6 +1053,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz",
|
||||
"integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.3",
|
||||
"debug": "^4.3.1",
|
||||
@@ -1067,6 +1068,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz",
|
||||
"integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1"
|
||||
},
|
||||
@@ -1079,6 +1081,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz",
|
||||
"integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
@@ -1091,6 +1094,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz",
|
||||
"integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
@@ -1100,6 +1104,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz",
|
||||
"integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.1.1",
|
||||
"levn": "^0.4.1"
|
||||
@@ -1122,7 +1127,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
@@ -1176,6 +1180,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
@@ -1185,6 +1190,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
@@ -1198,6 +1204,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.22"
|
||||
},
|
||||
@@ -1211,6 +1218,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
@@ -2210,9 +2218,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.6.0/layer-ui-1.6.0.tgz",
|
||||
"integrity": "sha512-2sN4mL1Jf984oeE4N4yEv6XFgSz0Gc+uSG+HLGfRrdzjAsMcU9hbb7HSAo3Q6MBvQHZn3ZBr1cK+VUM0kXY4NA==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.5/layer-ui-1.7.5.tgz",
|
||||
"integrity": "sha512-xryrAYgVgX3eurEWXT/d0p4r/MBYNBB3mBnvV6xVcFhzxW+HfOra8hsVHLvrCtd+m5E1t7PDRzjw1FObkV6fdQ==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2236,7 +2244,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
|
||||
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2485,7 +2492,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
|
||||
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2591,7 +2597,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.5.tgz",
|
||||
"integrity": "sha512-jt63jy8YbhZJUGMxTUzeivLhowGtFp6YbCFrrmZJ7G6IHu8X8LJzO81ksz5nT5l8DKpldGwnINUfA6iE91JIAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2618,7 +2623,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
|
||||
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
@@ -2633,7 +2637,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
|
||||
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
@@ -3016,7 +3019,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.1.tgz",
|
||||
"integrity": "sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"c12": "^3.3.3",
|
||||
"consola": "^3.4.2",
|
||||
@@ -3089,7 +3091,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.3.1.tgz",
|
||||
"integrity": "sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "^3.5.27",
|
||||
"defu": "^6.1.4",
|
||||
@@ -3736,7 +3737,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.95.0.tgz",
|
||||
"integrity": "sha512-Te8fE/SmiiKWIrwBwxz5Dod87uYvsbcZ9JAL5ylPg1DevyKgTkxCXnPEaewk1Su2qpfNmry5RHoN+NywWFCG+A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.95.0"
|
||||
},
|
||||
@@ -5877,7 +5877,8 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -5889,7 +5890,8 @@
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
@@ -6259,7 +6261,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
|
||||
"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.29",
|
||||
@@ -6509,7 +6510,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6549,6 +6549,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6880,7 +6881,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -7074,7 +7074,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7203,7 +7202,6 @@
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -7345,7 +7343,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -7382,7 +7379,6 @@
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
@@ -7938,7 +7934,8 @@
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
@@ -8453,6 +8450,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -8483,6 +8481,7 @@
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -8495,6 +8494,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -8507,6 +8507,7 @@
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.3"
|
||||
},
|
||||
@@ -8519,6 +8520,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@@ -8528,6 +8530,7 @@
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
@@ -8545,6 +8548,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
@@ -8570,6 +8574,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
},
|
||||
@@ -8582,6 +8587,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
@@ -8682,7 +8688,8 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
@@ -8710,13 +8717,15 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-npm-meta": {
|
||||
"version": "1.4.0",
|
||||
@@ -8761,6 +8770,7 @@
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
@@ -8791,6 +8801,7 @@
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
@@ -8807,6 +8818,7 @@
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.4"
|
||||
@@ -8819,7 +8831,8 @@
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz",
|
||||
"integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
@@ -9352,6 +9365,7 @@
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
@@ -9728,19 +9742,22 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-stable-stringify-without-jsonify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
@@ -9818,6 +9835,7 @@
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
@@ -10078,6 +10096,7 @@
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1",
|
||||
"type-check": "~0.4.0"
|
||||
@@ -10177,6 +10196,7 @@
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
@@ -10621,7 +10641,8 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
@@ -10857,7 +10878,6 @@
|
||||
"resolved": "https://registry.npmjs.org/nuxt/-/nuxt-4.3.1.tgz",
|
||||
"integrity": "sha512-bl+0rFcT5Ax16aiWFBFPyWcsTob19NTZaDL5P6t0MQdK63AtgS6fN6fwvwdbXtnTk6/YdCzlmuLzXhSM22h0OA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dxup/nuxt": "^0.3.2",
|
||||
"@nuxt/cli": "^3.33.0",
|
||||
@@ -11128,6 +11148,7 @@
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
@@ -11185,7 +11206,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.112.0.tgz",
|
||||
"integrity": "sha512-7rQ3QdJwobMQLMZwQaPuPYMEF2fDRZwf51lZ//V+bA37nejjKW5ifMHbbCwvA889Y4RLhT+/wLJpPRhAoBaZYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.112.0"
|
||||
},
|
||||
@@ -11269,6 +11289,7 @@
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
@@ -11284,6 +11305,7 @@
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
@@ -11326,6 +11348,7 @@
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -11429,7 +11452,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.7"
|
||||
},
|
||||
@@ -11546,7 +11568,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -12090,7 +12111,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -12141,6 +12161,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
@@ -12317,6 +12338,7 @@
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -12687,7 +12709,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -13476,7 +13497,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -13817,6 +13837,7 @@
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prelude-ls": "^1.2.1"
|
||||
},
|
||||
@@ -13884,7 +13905,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14326,6 +14346,7 @@
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
@@ -14350,7 +14371,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14712,7 +14732,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
|
||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/compiler-sfc": "3.5.29",
|
||||
@@ -14777,7 +14796,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
|
||||
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.3.0",
|
||||
"@intlify/devtools-types": "11.3.0",
|
||||
@@ -14799,7 +14817,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.6.4"
|
||||
},
|
||||
@@ -14858,6 +14875,7 @@
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -15026,6 +15044,7 @@
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@malio/layer-ui": "^1.7.5",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
<AdminShareTab v-if="activeTab === 'share'" />
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
|
||||
</div>
|
||||
@@ -51,7 +50,6 @@ const tabs = [
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
{ key: 'share', label: 'Partage' },
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
{ key: 'absences', label: 'Absences' },
|
||||
] as const
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
<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 -->
|
||||
<div class="mt-4 max-w-sm">
|
||||
<MalioInputText
|
||||
v-model="filter"
|
||||
:placeholder="$t('sharedFiles.filterPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- États -->
|
||||
<div v-if="loading" 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" class="mt-10 text-sm text-red-600">{{ error }}</p>
|
||||
<p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">{{ $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 text-neutral-400" />
|
||||
<span class="truncate">{{ entry.name }}</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 } = useShareService()
|
||||
const { enabled, ensureLoaded } = useShareStatus()
|
||||
|
||||
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 previewEntry = ref<FileEntry | null>(null)
|
||||
|
||||
const visibleEntries = computed(() => {
|
||||
const f = filter.value.trim().toLowerCase()
|
||||
if (!f) return entries.value
|
||||
return entries.value.filter((e) => e.name.toLowerCase().includes(f))
|
||||
})
|
||||
|
||||
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 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.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,48 +0,0 @@
|
||||
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 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
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ShareBrowseResult, 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 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, getStatus, getDownloadUrl }
|
||||
}
|
||||
@@ -33,7 +33,6 @@ RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
git \
|
||||
unzip \
|
||||
smbclient \
|
||||
&& docker-php-ext-install -j$(nproc) \
|
||||
intl \
|
||||
zip \
|
||||
|
||||
@@ -40,7 +40,7 @@ FROM php:8.4-fpm AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
nginx supervisor smbclient \
|
||||
nginx supervisor \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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()]);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Share\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class InvalidPathException extends RuntimeException {}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Share\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ShareConnectionException extends RuntimeException {}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Share\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class ShareNotConfiguredException extends RuntimeException {}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?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,
|
||||
) {}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* @return resource flux binaire en lecture
|
||||
*/
|
||||
public function read(string $relativePath);
|
||||
|
||||
public function test(): ShareTestResult;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Share;
|
||||
|
||||
final readonly class ShareTestResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $success,
|
||||
public ?string $message = null,
|
||||
) {}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
<?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;
|
||||
|
||||
final class SmbFileSource implements FileSource
|
||||
{
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -184,18 +184,6 @@
|
||||
"symfony/mcp-bundle": {
|
||||
"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": {
|
||||
"version": "4.0",
|
||||
"recipe": {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user