Compare commits

...

22 Commits

Author SHA1 Message Date
Matthieu 4ffa19e53f fix(share) : durcissement download (allowlist inline anti-XSS + nosniff) et masquage des erreurs SMB 2026-06-03 17:42:36 +02:00
Matthieu 74b6d298fb chore(share) : retrait de vue-pdf-embed (viewer PDF via iframe natif) 2026-06-03 17:37:48 +02:00
Matthieu c1415d20f4 feat(share) : traductions explorateur et config partage 2026-06-03 17:35:57 +02:00
Matthieu 1d4dbaa766 feat(share) : page explorateur de fichiers du partage 2026-06-03 17:32:26 +02:00
Matthieu ef7b6c13da feat(share) : viewer de documents du partage (image/pdf/texte) 2026-06-03 17:26:48 +02:00
Matthieu c125566efc feat(share) : lien Documents conditionné à l'activation du partage 2026-06-03 17:23:44 +02:00
Matthieu 947d95b1f7 feat(share) : onglet admin de configuration du partage 2026-06-03 17:21:38 +02:00
Matthieu 027c1305fd feat(share) : services et DTO front (browse, settings, status) + dépendance pdf
- Ajout vue-pdf-embed@2.1.4
- DTO share.ts (FileEntry, Breadcrumb, ShareBrowseResult, ShareStatus, ShareSettings, ShareSettingsWrite, ShareTestResult)
- Service share.ts (browse, getStatus, getDownloadUrl)
- Service share-settings.ts (getSettings, saveSettings, testConnection)
2026-06-03 17:19:42 +02:00
Matthieu f25f3fa634 feat(share) : controllers status/browse/download du partage 2026-06-03 17:13:46 +02:00
Matthieu 224176d9d7 feat(share) : endpoint test de connexion (POST settings/share/test) 2026-06-03 17:10:36 +02:00
Matthieu 8c66e73e8d feat(share) : endpoints de configuration admin (GET/PUT settings/share) 2026-06-03 17:09:05 +02:00
Matthieu f9428f5c5d feat(share) : source de fichiers SMB (FileSource + SmbFileSource) 2026-06-03 17:05:08 +02:00
Matthieu f12ff87b87 feat(share) : résolution de chemin SMB anti path-traversal 2026-06-03 17:02:21 +02:00
Matthieu d0aff0fa51 feat(share) : entité ShareConfiguration + migration 2026-06-03 17:00:53 +02:00
Matthieu 879f961d88 build(share) : ajout icewind/smb et paquet smbclient (dev + prod) 2026-06-03 16:57:21 +02:00
Matthieu 6de7dfde4e docs(share) : plan d'implémentation explorateur de partage Windows 2026-06-03 16:37:12 +02:00
Matthieu 83d938fd91 docs(share) : design explorateur de partage Windows + viewer (SMB) 2026-06-03 16:30:36 +02:00
Matthieu 226ab8ea84 feat(mcp) : tools update et delete des documents de tâche
Auto Tag Develop / tag (push) Successful in 7s
Ajoute deux tools MCP sur le modèle de add-task-document :
- update-task-document : remplace le contenu et/ou renomme un document (MIME ré-inféré, taille rafraîchie, garde-fous vide/5 Mo)
- delete-task-document : supprime le document en base, le fichier disque étant retiré par le PreRemove listener

Met aussi à jour le compteur de tools MCP dans le CLAUDE.md (60).
2026-06-02 09:50:03 +02:00
gitea-actions d48ee8eae5 chore: bump version to v0.4.23
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-01 21:26:44 +00:00
Matthieu 1dadc31884 style(kanban) : largeur fixe des colonnes de statut + scroll horizontal conditionnel
Auto Tag Develop / tag (push) Successful in 7s
Remplace flex-1/min-w par une largeur fixe (w-72) avec shrink-0 sur les
colonnes du board projet et de Mes Taches. Les colonnes ne sont plus
ecrasees quand un workflow compte beaucoup de statuts ; le scroll
horizontal n'apparait que si elles depassent la largeur du conteneur.
2026-06-01 23:26:35 +02:00
gitea-actions cdd7ca7626 chore: bump version to v0.4.22
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-01 20:52:47 +00:00
Matthieu e1bf9ecb22 fix(frontend) : copie presse-papiers fonctionnelle en HTTP via fallback execCommand
Auto Tag Develop / tag (push) Successful in 7s
navigator.clipboard n'est disponible qu'en secure context (HTTPS/localhost),
ce qui cassait la copie en prod HTTP. Ajout d'un utilitaire copyToClipboard
avec fallback textarea + execCommand, appliqué au viewer Markdown, au token
API du profil et au nom de branche Git.
2026-06-01 22:52:32 +02:00
50 changed files with 4370 additions and 16 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
### MCP Server ### MCP Server
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences - 60 tools MCP exposant projets, tâches, métadonnées, time tracking, récurrences, documents et absences
- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server` - Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server`
- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>` - Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer <token>`
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User` - Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
+1
View File
@@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"icewind/smb": "^3.8",
"lexik/jwt-authentication-bundle": "^3.2", "lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8", "nyholm/psr7": "^1.8",
Generated
+73 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "dc72ee68996f3f738763eafd350bc0e0", "content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -2508,6 +2508,78 @@
}, },
"time": "2026-02-08T16:21:46+00:00" "time": "2026-02-08T16:21:46+00:00"
}, },
{
"name": "icewind/smb",
"version": "3.8.1",
"source": {
"type": "git",
"url": "https://codeberg.org/icewind/SMB",
"reference": "97063a63b44edc6554966f6121679506b8d85103"
},
"require": {
"icewind/streams": ">=0.7.3",
"php": ">=8.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "v3.89.0",
"phpstan/phpstan": "^0.12.57",
"phpunit/phpunit": "10.5.58",
"psalm/phar": "6.*"
},
"type": "library",
"autoload": {
"psr-4": {
"Icewind\\SMB\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Robin Appelman",
"email": "robin@icewind.nl"
}
],
"description": "php wrapper for smbclient and libsmbclient-php",
"time": "2025-11-13T16:17:19+00:00"
},
{
"name": "icewind/streams",
"version": "v0.7.8",
"source": {
"type": "git",
"url": "https://codeberg.org/icewind/streams",
"reference": "cb2bd3ed41b516efb97e06e8da35a12ef58ba48b"
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^9"
},
"type": "library",
"autoload": {
"psr-4": {
"Icewind\\Streams\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Robin Appelman",
"email": "icewind@owncloud.com"
}
],
"description": "A set of generic stream wrappers",
"time": "2024-12-05T14:36:22+00:00"
},
{ {
"name": "illuminate/collections", "name": "illuminate/collections",
"version": "v13.8.0", "version": "v13.8.0",
+6
View File
@@ -49,6 +49,10 @@ services:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
@@ -60,3 +64,5 @@ services:
App\Controller\Absence\AbsenceJustificationDownloadController: App\Controller\Absence\AbsenceJustificationDownloadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.21' app.version: '0.4.23'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
# Explorateur de partage réseau Windows + viewer — Design
Date : 2026-06-03
Statut : design validé (brainstorming), à transformer en plan d'implémentation.
## 1. Objectif
Donner accès, **depuis Lesstime**, à un partage de fichiers Windows (SMB), avec :
- un **explorateur de fichiers façon Google Drive / SharePoint** qui parcourt le partage **en direct** (live, pas d'index) ;
- un **viewer propre** pour ouvrir les documents (image, PDF, texte) sans quitter l'app ;
- une **configuration en admin** (serveur, partage, identifiants) avec un **bouton « Tester la connexion »** et un **interrupteur d'activation**, sur le même modèle que les intégrations existantes (Zimbra, Gitea, BookStack) ;
- une **visibilité conditionnelle** : si l'option SMB est **désactivée** dans l'admin, l'entrée « Documents » et la page **n'apparaissent pas** pour les utilisateurs.
### Hors périmètre (POC)
- Pas d'index en base, pas de recherche plein texte, pas d'extraction de contenu (pas de Tika).
- Pas d'OCR.
- Pas d'écriture sur le partage (lecture seule).
- Pas de cron / synchronisation. Tout est lu **à la volée** à chaque navigation.
## 2. Décisions d'architecture
| Sujet | Décision |
|-------|----------|
| Accès au partage | **`icewind/smb`** (protocole SMB en PHP), **pas de montage CIFS**. La connexion est configurée dans l'app. |
| Configuration | Entité `ShareConfiguration` (1 ligne) saisie en admin, mot de passe chiffré au repos — calquée sur `ZimbraConfiguration`. |
| Abstraction | Interface `FileSource` (lister / lire), implémentation `SmbFileSource`. Permet de remplacer la source plus tard sans toucher au front ni aux endpoints. |
| API navigation | 2 endpoints live : `browse` (lister un dossier) et `download` (streamer un fichier). |
| Front | Explorateur **maison léger** (fil d'Ariane + tableau), cohérent avec `@malio/layer-ui`. Aucune lib de file-manager externe (elFinder/vue-finder écartés : vieux ou hors design system). |
| Rendu PDF | **PDF.js via `vue-pdf-embed`** dans le viewer (meilleur rendu qu'un `<iframe>`). Images et texte : rendu natif. |
| Sécurité chemin | Validation stricte anti path-traversal : tout chemin demandé doit rester sous la racine configurée. |
### Schéma
```
//WIN-SRV/Partage
│ SMB (icewind/smb, identifiants chiffrés en base)
Lesstime (Symfony) ──FileSource → SmbFileSource──┐
│ │
├─ GET /api/share/browse?path=/Compta/2024 → listing live (dossiers + fichiers)
├─ GET /api/share/download?path=…/x.pdf → stream du fichier (viewer / download)
├─ GET/PUT /api/settings/share → lire / enregistrer la config (admin)
└─ POST /api/settings/share/test → tester la connexion (admin)
```
## 3. Backend (Symfony)
### 3.1 Entité `ShareConfiguration`
Une seule ligne de config (singleton, comme `ZimbraConfiguration`). Champs :
- `id`
- `host` (string, ex. `WIN-SRV` ou IP)
- `shareName` (string, nom du partage SMB, ex. `Documents`)
- `basePath` (string nullable, sous-dossier racine optionnel, ex. `/Projets`) — la navigation est confinée à cette racine
- `domain` (string nullable, workgroup/domaine, défaut `WORKGROUP`)
- `username` (string nullable)
- `encryptedPassword` (text nullable) — chiffré, réutilise le mécanisme de chiffrement déjà employé par Zimbra
- `enabled` (bool, défaut `false`)
- `hasPassword()` helper
Migration Doctrine dédiée. Repository singleton (`findConfiguration()` renvoie la ligne unique ou en crée une vide), calqué sur `ZimbraConfigurationRepository`.
### 3.2 Ressources API de configuration (admin)
Calquées **à l'identique** sur Zimbra :
- `ShareSettings` (ApiResource) — `Get` + `Put` sur `/api/settings/share`, `security: ROLE_ADMIN`.
- Champs lus/écrits : `host`, `shareName`, `basePath`, `domain`, `username`, `enabled`.
- `password` : **write-only** (groupe write uniquement).
- `hasPassword` : **read-only** (indique si un mot de passe est déjà enregistré).
- Provider `ShareSettingsProvider` (lit l'entité → DTO), Processor `ShareSettingsProcessor` (DTO → entité, chiffre le mot de passe si fourni, ne l'écrase pas s'il est vide).
- `ShareTestConnection` (ApiResource) — `Post` sur `/api/settings/share/test`, `input: false`, `security: ROLE_ADMIN`.
- Renvoie `{ success: bool, message: string|null }`.
- Provider `ShareTestConnectionProvider` : tente une connexion SMB + un `dir()` sur la racine ; `success=false` + message d'erreur lisible en cas d'échec.
### 3.3 Source de fichiers
```
interface FileSource {
list(string $relativeDir): FileEntry[] // dossiers d'abord, puis fichiers
read(string $relativePath): resource // flux binaire du fichier
test(): TestResult // connexion + accès racine
}
```
`FileEntry` = `{ name, path, isDir, size, modifiedAt, mimeType }`.
`SmbFileSource` :
- construit la connexion à partir de `ShareConfiguration` (déchiffre le mot de passe) via `icewind/smb` ;
- préfixe tous les chemins par `basePath` ;
- **valide chaque chemin** (`normalize` + rejet de tout chemin qui s'échappe de la racine : pas de `..`, pas de chemin absolu hors racine) → `InvalidPathException` sinon ;
- déduit le `mimeType` à partir de l'extension (suffisant pour piloter le viewer ; pas de lecture du contenu pour le listing).
> **Dépendance infra** : `icewind/smb` requiert le binaire `smbclient` (ou l'extension `libsmbclient`) dans le conteneur PHP. Les deux images sont Debian (`apt-get`), donc une seule ligne suffit, **à appliquer dans les deux Dockerfiles** :
> - `infra/dev/Dockerfile` — ajouter `smbclient` à la liste `apt-get install` existante (~ligne 9).
> - `infra/prod/Dockerfile` — ajouter `smbclient` à l'`apt-get install` du **stage `production`** (le runtime FPM, ~ligne 41), **pas** au stage de build.
>
> Conséquence déploiement : l'image prod (`lesstime-app`) doit être **rebuildée et redéployée** pour embarquer `smbclient` ; sans ça, la fonctionnalité marcherait en dev et échouerait en prod. À inscrire comme étape du plan (avec la migration Doctrine de `ShareConfiguration`).
### 3.4 Endpoints de navigation
Controllers custom sous `/api/` (pas d'entité Doctrine derrière → controllers, avec `priority: 1` sur la route pour éviter le conflit avec API Platform `{id}`), `security: IS_AUTHENTICATED_FULLY` :
- `GET /api/share/browse?path=<rel>``ShareBrowseController`
- renvoie `{ path, breadcrumb[], entries: FileEntry[] }` ;
- si config désactivée/incomplète → `409` avec message clair ;
- chemin invalide → `400`.
- `GET /api/share/download?path=<rel>&disposition=inline|attachment``ShareDownloadController`
- streame le fichier (`StreamedResponse`) avec le bon `Content-Type` ;
- `inline` par défaut (pour le viewer), `attachment` pour le téléchargement ;
- fichier absent → `404`.
- `GET /api/share/status``ShareStatusController`, `security: IS_AUTHENTICATED_FULLY`
- renvoie `{ enabled: bool }`**uniquement le booléen**, aucune donnée de connexion ;
- utilisé par le front pour afficher/masquer l'entrée « Documents » et garder la page.
## 4. Frontend (Nuxt)
### 4.1 Explorateur — `pages/documents.vue`
- **Fil d'Ariane** du chemin courant (cliquable pour remonter).
- **Tableau** des entrées : dossiers d'abord, puis fichiers ; colonnes nom (icône par type), taille, date de modification.
- clic dossier → on descend (met à jour `path`, recharge `browse`) ;
- clic fichier → ouvre le viewer.
- **Filtre par nom** du dossier courant, **côté client** (live, non-indexé) — filtre simplement la liste déjà chargée.
- États : chargement, dossier vide, erreur (config désactivée / connexion KO) avec message.
### 4.2 Viewer — `components/share/SharedFilePreview.vue`
Adapté de `TaskDocumentPreview.vue` existant :
- **Image** : `<img>` sur l'URL `download?disposition=inline`.
- **PDF** : **`vue-pdf-embed`** (PDF.js) — rendu, pagination, zoom.
- **Texte/markdown/csv/json** : chargement du contenu + `<pre>` (comme l'existant).
- **Autre** : carte « fichier » + bouton de téléchargement (`attachment`).
- Navigation précédent/suivant dans la liste du dossier courant, fermeture clavier — repris de l'existant.
### 4.3 Service & config admin
- `services/share.ts` : `browse(path)`, `getDownloadUrl(path, disposition)` + DTO `FileEntry`.
- `services/share-settings.ts` (+ DTO) : `get()`, `update(payload)`, `test()` — calqué sur `services/zimbra.ts`.
- `components/admin/AdminShareTab.vue` : calqué sur `Admin ZimbraTab.vue` — champs host / shareName / basePath / domain / username / password + toggle `enabled`, bouton **« Tester la connexion »** (toast succès/échec) et **« Enregistrer »**. Onglet ajouté à la page admin.
- **i18n** : nouvelles clés (`sharedFiles.*`, `adminShare.*`) dans `frontend/i18n/locales/`.
- **Navigation conditionnelle** : le lien « Documents » du layout n'est affiché **que si** `GET /api/share/status` renvoie `enabled=true` (récupéré via un composable, ex. `useShareStatus`, mis en cache). Le middleware/garde de `pages/documents.vue` redirige vers l'accueil si la fonctionnalité est désactivée (défense en profondeur, en plus du `409` backend).
### 4.4 Dépendance frontend
`vue-pdf-embed` (+ `pdfjs-dist`) ajouté au `package.json` du frontend.
## 5. Flux
- **Configuration** (admin) : saisie host/partage/identifiants → « Tester » (`POST /settings/share/test`) → « Enregistrer » (`PUT /settings/share`).
- **Navigation** (utilisateur) : ouverture `/documents``GET /share/browse?path=/` → tableau ; clic dossier → re-`browse` ; clic fichier → viewer → `GET /share/download?...inline`.
- **Téléchargement** : bouton → `GET /share/download?...attachment`.
## 6. Gestion des erreurs
- **SMB injoignable / identifiants faux** → `browse`/`download` renvoient une erreur ; l'UI affiche un message clair. Le test de connexion renvoie `success=false` + message.
- **Config désactivée ou incomplète** → `browse` `409`, UI invite à configurer (admin).
- **Path-traversal** (`..`, chemin hors racine) → `400`, jamais d'accès hors `basePath`.
- **Fichier supprimé/déplacé entre listing et ouverture** → `download` `404`, message dans le viewer.
## 7. Sécurité
- **Lecture seule** : aucune écriture sur le partage.
- **Rôles** : navigation/lecture = utilisateur authentifié (`IS_AUTHENTICATED_FULLY`) ; configuration = `ROLE_ADMIN`.
- **Mot de passe chiffré au repos** (réutilise le mécanisme Zimbra), jamais renvoyé au front (`hasPassword` seulement).
- **Confinement** strict à `basePath` (anti path-traversal).
## 8. Tests
- **Unitaire**
- `SmbFileSource` : validation/normalisation de chemin, rejet `..` et chemins hors racine (connexion SMB mockée).
- Déduction du `mimeType` par extension.
- **Fonctionnel**
- `GET/PUT /api/settings/share` et `POST /api/settings/share/test` exigent `ROLE_ADMIN` ; le mot de passe n'est jamais exposé en lecture.
- `GET /api/share/browse` et `/download` exigent l'authentification ; un chemin `..` est rejeté (`400`).
## 9. Notes & suites possibles
- Perf : chaque `browse` = un aller-retour SMB live ; acceptable pour un POC. Gros dossiers = listing potentiellement lent (pas de pagination au POC).
- Évolutions naturelles (non incluses) : index + recherche plein texte (Tika), miniatures, multi-partages, restriction par dossier/rôle, mise en cache des listings.
```
+144
View File
@@ -0,0 +1,144 @@
<template>
<div>
<h2 class="text-lg font-bold text-neutral-900">{{ $t('adminShare.title') }}</h2>
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.host"
:label="$t('adminShare.host')"
:placeholder="$t('adminShare.hostPlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.shareName"
:label="$t('adminShare.shareName')"
:placeholder="$t('adminShare.shareNamePlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.basePath"
:label="$t('adminShare.basePath')"
:placeholder="$t('adminShare.basePathPlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.domain"
:label="$t('adminShare.domain')"
:placeholder="$t('adminShare.domainPlaceholder')"
input-class="w-full"
/>
<MalioInputText
v-model="form.username"
:label="$t('adminShare.username')"
:placeholder="$t('adminShare.usernamePlaceholder')"
input-class="w-full"
/>
<div>
<MalioInputPassword
v-model="form.password"
:label="$t('adminShare.password')"
input-class="w-full"
/>
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
{{ $t('adminShare.passwordConfigured') }}
</p>
</div>
<label class="flex cursor-pointer items-center gap-2">
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
<span class="text-sm">{{ $t('adminShare.enabled') }}</span>
</label>
<div class="flex gap-3">
<MalioButton
:label="$t('adminShare.save')"
button-class="w-auto px-4"
:disabled="isSaving"
@click="handleSave"
/>
<MalioButton
variant="tertiary"
:label="$t('adminShare.testConnection')"
button-class="w-auto px-4"
:disabled="isTesting"
@click="handleTest"
/>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
{{ testResult ? $t('adminShare.testSuccess') : (testMessage ?? $t('adminShare.testFailed')) }}
</p>
</form>
</div>
</template>
<script setup lang="ts">
import { useShareSettingsService } from '~/services/share-settings'
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
const form = reactive({
host: '',
shareName: '',
basePath: '',
domain: '',
username: '',
password: '',
enabled: false,
})
const hasPassword = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testResult = ref<boolean | null>(null)
const testMessage = ref<string | null>(null)
async function loadSettings() {
const settings = await getSettings()
form.host = settings.host ?? ''
form.shareName = settings.shareName ?? ''
form.basePath = settings.basePath ?? ''
form.domain = settings.domain ?? ''
form.username = settings.username ?? ''
form.enabled = settings.enabled
hasPassword.value = settings.hasPassword
}
async function handleSave() {
isSaving.value = true
try {
const result = await saveSettings({
host: form.host.trim() || null,
shareName: form.shareName.trim() || null,
basePath: form.basePath.trim() || null,
domain: form.domain.trim() || null,
username: form.username.trim() || null,
password: form.password || null,
enabled: form.enabled,
})
hasPassword.value = result.hasPassword
form.password = ''
testResult.value = null
testMessage.value = null
} finally {
isSaving.value = false
}
}
async function handleTest() {
isTesting.value = true
testResult.value = null
testMessage.value = null
try {
const result = await testConnection()
testResult.value = result.success
testMessage.value = result.message
} catch {
testResult.value = false
testMessage.value = null
} finally {
isTesting.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
@@ -0,0 +1,173 @@
<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>
@@ -124,6 +124,7 @@
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard'
const props = defineProps<{ const props = defineProps<{
document: TaskDocument | null document: TaskDocument | null
@@ -159,13 +160,10 @@ const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
const isText = computed(() => isTextDocument(props.document)) const isText = computed(() => isTextDocument(props.document))
async function copyContent() { async function copyContent() {
try { if (await copyToClipboard(textContent.value)) {
await navigator.clipboard.writeText(textContent.value)
copied.value = true copied.value = true
useToast().success(t('taskDocuments.copied')) useToast().success(t('taskDocuments.copied'))
setTimeout(() => { copied.value = false }, 2000) setTimeout(() => { copied.value = false }, 2000)
} catch {
// Clipboard unavailable
} }
} }
+2 -1
View File
@@ -229,6 +229,7 @@
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea' import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
@@ -374,7 +375,7 @@ async function handleCreate() {
async function handleCopy() { async function handleCopy() {
try { try {
const result = await getBranchName(props.task.id, branchForm.type) const result = await getBranchName(props.task.id, branchForm.type)
await navigator.clipboard.writeText(result.name) await copyToClipboard(result.name)
const { success } = useToast() const { success } = useToast()
success(t('gitea.branch.copied')) success(t('gitea.branch.copied'))
} catch { } catch {
+23
View File
@@ -0,0 +1,23 @@
import { useShareService } from '~/services/share'
export function useShareStatus() {
const enabled = useState<boolean | null>('share-enabled', () => null)
const { getStatus } = useShareService()
async function refresh() {
try {
const status = await getStatus()
enabled.value = status.enabled
} catch {
enabled.value = false
}
}
async function ensureLoaded() {
if (enabled.value === null) {
await refresh()
}
}
return { enabled, refresh, ensureLoaded }
}
+34
View File
@@ -428,6 +428,40 @@
"testFailed": "Connexion échouée" "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": { "taskRecurrence": {
"created": "Récurrence créée", "created": "Récurrence créée",
"updated": "Récurrence mise à jour", "updated": "Récurrence mise à jour",
+16 -1
View File
@@ -100,6 +100,14 @@
:collapsed="sidebarIsCollapsed" :collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()" @click="ui.closeMobileSidebar()"
/> />
<SidebarLink
v-if="isDocumentsVisible"
to="/documents"
icon="mdi:folder-network-outline"
:label="$t('sharedFiles.sidebar.title')"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<div v-if="isMailVisible" class="relative"> <div v-if="isMailVisible" class="relative">
<SidebarLink <SidebarLink
to="/mail" to="/mail"
@@ -222,6 +230,9 @@ const isMailVisible = computed(() => {
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN') return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
}) })
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
const isDocumentsVisible = computed(() => shareEnabled.value === true)
// On mobile, sidebar is always expanded (not collapsed icon mode) // On mobile, sidebar is always expanded (not collapsed icon mode)
const sidebarIsCollapsed = computed(() => { const sidebarIsCollapsed = computed(() => {
if (ui.sidebarOpen) return false if (ui.sidebarOpen) return false
@@ -267,14 +278,18 @@ onMounted(() => {
if (isMailVisible.value) { if (isMailVisible.value) {
mailStore.startPolling() mailStore.startPolling()
} }
ensureShareStatus()
}) })
watch(() => auth.user, (user) => { watch(() => auth.user, (user) => {
if (!user) { if (!user) {
mailStore.stopPolling() mailStore.stopPolling()
} else if (isMailVisible.value) { } else {
if (isMailVisible.value) {
mailStore.startPolling() mailStore.startPolling()
} }
ensureShareStatus()
}
}) })
const completeDrawerOpen = ref(false) const completeDrawerOpen = ref(false)
+2
View File
@@ -30,6 +30,7 @@
<AdminGiteaTab v-if="activeTab === 'gitea'" /> <AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" /> <AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" /> <AdminZimbraTab v-if="activeTab === 'zimbra'" />
<AdminShareTab v-if="activeTab === 'share'" />
<AdminMailTab v-if="activeTab === 'mail'" /> <AdminMailTab v-if="activeTab === 'mail'" />
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" /> <AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
</div> </div>
@@ -50,6 +51,7 @@ const tabs = [
{ key: 'gitea', label: 'Gitea' }, { key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' }, { key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' }, { key: 'zimbra', label: 'Zimbra' },
{ key: 'share', label: 'Partage' },
{ key: 'mail', label: 'Mail' }, { key: 'mail', label: 'Mail' },
{ key: 'absences', label: 'Absences' }, { key: 'absences', label: 'Absences' },
] as const ] as const
+151
View File
@@ -0,0 +1,151 @@
<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 -1
View File
@@ -439,7 +439,7 @@ onMounted(async () => {
<div <div
v-for="cat in CATEGORIES" v-for="cat in CATEGORIES"
:key="cat" :key="cat"
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition" class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50 transition"
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''" :class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
@dragover.prevent="dragOverCategory = cat" @dragover.prevent="dragOverCategory = cat"
@dragleave="dragOverCategory = null" @dragleave="dragOverCategory = null"
+3 -3
View File
@@ -129,6 +129,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService' import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token' import { useApiTokenService } from '~/services/api-token'
import { copyToClipboard } from '~/utils/clipboard'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
@@ -181,10 +182,9 @@ async function onRemove() {
async function onCopy() { async function onCopy() {
if (!auth.user?.apiToken) return if (!auth.user?.apiToken) return
try { if (await copyToClipboard(auth.user.apiToken)) {
await navigator.clipboard.writeText(auth.user.apiToken)
toast.success({ message: t('profile.apiToken.copied') }) toast.success({ message: t('profile.apiToken.copied') })
} catch { } else {
toast.error({ message: t('profile.apiToken.copyFailed') }) toast.error({ message: t('profile.apiToken.copyFailed') })
} }
} }
+1 -1
View File
@@ -96,7 +96,7 @@
<div <div
v-for="status in statuses" v-for="status in statuses"
:key="status.id" :key="status.id"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors" class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'" :class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent @dragover.prevent
@dragenter.prevent="onDragEnter(status.id)" @dragenter.prevent="onDragEnter(status.id)"
+48
View File
@@ -0,0 +1,48 @@
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
}
+21
View File
@@ -0,0 +1,21 @@
import type { ShareSettings, ShareSettingsWrite, ShareTestResult } from './dto/share'
export function useShareSettingsService() {
const api = useApi()
async function getSettings(): Promise<ShareSettings> {
return api.get<ShareSettings>('/settings/share')
}
async function saveSettings(payload: ShareSettingsWrite): Promise<ShareSettings> {
return api.put<ShareSettings>('/settings/share', payload as Record<string, unknown>, {
toastSuccessKey: 'adminShare.saved',
})
}
async function testConnection(): Promise<ShareTestResult> {
return api.post<ShareTestResult>('/settings/share/test', {})
}
return { getSettings, saveSettings, testConnection }
}
+22
View File
@@ -0,0 +1,22 @@
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 }
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Copy text to the clipboard with a fallback for non-secure contexts.
*
* `navigator.clipboard` is only available in secure contexts (HTTPS or
* localhost). On a plain HTTP origin (e.g. an internal/prod server without
* TLS) the API is missing, so we fall back to the legacy
* `document.execCommand('copy')` using a temporary off-screen textarea.
*
* @returns `true` if the copy succeeded, `false` otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Preferred path: available in secure contexts (HTTPS / localhost).
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the legacy fallback below.
}
}
// Legacy fallback: works on plain HTTP origins.
try {
const textarea = document.createElement('textarea')
textarea.value = text
// Keep it out of view and prevent layout shift / scrolling.
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.left = '-9999px'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
+1
View File
@@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y \
wget \ wget \
git \ git \
unzip \ unzip \
smbclient \
&& docker-php-ext-install -j$(nproc) \ && docker-php-ext-install -j$(nproc) \
intl \ intl \
zip \ zip \
+1 -1
View File
@@ -40,7 +40,7 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \ libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \ nginx supervisor smbclient \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \ && docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add share_configuration table for SMB/Windows share explorer feature.
*/
final class Version20260603165850 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create share_configuration table (SMB/Windows share explorer)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE share_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, host VARCHAR(255) DEFAULT NULL, share_name VARCHAR(255) DEFAULT NULL, base_path VARCHAR(255) DEFAULT NULL, domain VARCHAR(255) DEFAULT NULL, username VARCHAR(255) DEFAULT NULL, encrypted_password TEXT DEFAULT NULL, enabled BOOLEAN NOT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE share_configuration');
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\ShareSettingsProcessor;
use App\State\ShareSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/share',
normalizationContext: ['groups' => ['share_settings:read']],
provider: ShareSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/share',
denormalizationContext: ['groups' => ['share_settings:write']],
normalizationContext: ['groups' => ['share_settings:read']],
provider: ShareSettingsProvider::class,
processor: ShareSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ShareSettings
{
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $host = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $shareName = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $basePath = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $domain = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public ?string $username = null;
#[Groups(['share_settings:write'])]
public ?string $password = null;
#[Groups(['share_settings:read', 'share_settings:write'])]
public bool $enabled = false;
#[Groups(['share_settings:read'])]
public bool $hasPassword = false;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\ShareTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/share/test',
input: false,
normalizationContext: ['groups' => ['share_test:read']],
provider: ShareTestConnectionProvider::class,
processor: ShareTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class ShareTestConnection
{
#[Groups(['share_test:read'])]
public bool $success = false;
#[Groups(['share_test:read'])]
public ?string $message = null;
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileEntry;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareBrowseController extends AbstractController
{
public function __construct(
private readonly FileSource $fileSource,
private readonly SharePathResolver $pathResolver,
) {}
#[Route('/api/share/browse', name: 'share_browse', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): JsonResponse
{
$rawPath = (string) $request->query->get('path', '');
try {
$path = $this->pathResolver->normalizeRelative($rawPath);
} catch (InvalidPathException) {
return new JsonResponse(['error' => 'Invalid path.'], 400);
}
try {
$entries = $this->fileSource->dir($path);
} catch (ShareNotConfiguredException) {
return new JsonResponse(['error' => 'Share not configured.'], 409);
} catch (ShareConnectionException) {
return new JsonResponse(['error' => 'Unable to reach the file share.'], 502);
}
return new JsonResponse([
'path' => $path,
'breadcrumb' => $this->breadcrumb($path),
'entries' => array_map(static fn (FileEntry $e): array => [
'name' => $e->name,
'path' => $e->path,
'isDir' => $e->isDir,
'size' => $e->size,
'modifiedAt' => $e->modifiedAt,
'mimeType' => $e->mimeType,
], $entries),
]);
}
/**
* @return array<int, array{name: string, path: string}>
*/
private function breadcrumb(string $path): array
{
if ('' === $path) {
return [];
}
$crumbs = [];
$acc = '';
foreach (explode('/', $path) as $segment) {
$acc = '' === $acc ? $segment : $acc.'/'.$segment;
$crumbs[] = ['name' => $segment, 'path' => $acc];
}
return $crumbs;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\Exception\ShareConnectionException;
use App\Service\Share\Exception\ShareNotConfiguredException;
use App\Service\Share\FileSource;
use App\Service\Share\SharePathResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function is_resource;
class ShareDownloadController extends AbstractController
{
public function __construct(
private readonly FileSource $fileSource,
private readonly SharePathResolver $pathResolver,
) {}
#[Route('/api/share/download', name: 'share_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(Request $request): Response
{
$rawPath = (string) $request->query->get('path', '');
try {
$path = $this->pathResolver->normalizeRelative($rawPath);
} catch (InvalidPathException) {
return new Response('Invalid path.', 400);
}
if ('' === $path) {
throw new NotFoundHttpException('No file requested.');
}
try {
$stream = $this->fileSource->read($path);
} catch (ShareNotConfiguredException) {
return new Response('Share not configured.', 409);
} catch (ShareConnectionException) {
throw new NotFoundHttpException('File not found.');
}
$name = basename($path);
$extension = pathinfo($name, PATHINFO_EXTENSION);
$mime = MimeTypes::getDefault()->getMimeTypes($extension)[0] ?? 'application/octet-stream';
// Anti-XSS : seuls des types non exécutables sont servis inline (images hors SVG, PDF).
// Tout le reste (HTML, SVG, octet-stream, etc.) est forcé en attachment, même si inline est demandé.
$inlineSafe = ('image/svg+xml' !== $mime && str_starts_with($mime, 'image/')) || 'application/pdf' === $mime;
$wantInline = 'attachment' !== $request->query->get('disposition');
$disposition = ($inlineSafe && $wantInline) ? HeaderUtils::DISPOSITION_INLINE : HeaderUtils::DISPOSITION_ATTACHMENT;
$response = new StreamedResponse(function () use ($stream): void {
if (is_resource($stream)) {
fpassthru($stream);
fclose($stream);
}
});
$response->headers->set('Content-Type', $mime);
$response->headers->set('Content-Disposition', HeaderUtils::makeDisposition($disposition, $name));
// Empêche le navigateur de "deviner" un type exécutable à partir du contenu.
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Controller\Share;
use App\Repository\ShareConfigurationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ShareStatusController extends AbstractController
{
public function __construct(
private readonly ShareConfigurationRepository $configRepository,
) {}
#[Route('/api/share/status', name: 'share_status', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
$config = $this->configRepository->findSingleton();
return new JsonResponse(['enabled' => null !== $config && $config->isUsable()]);
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ShareConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ShareConfigurationRepository::class)]
class ShareConfiguration
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $host = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shareName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $basePath = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $domain = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $username = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedPassword = null;
#[ORM\Column(type: 'boolean')]
private bool $enabled = false;
public function getId(): ?int
{
return $this->id;
}
public function getHost(): ?string
{
return $this->host;
}
public function setHost(?string $host): static
{
$this->host = $host;
return $this;
}
public function getShareName(): ?string
{
return $this->shareName;
}
public function setShareName(?string $shareName): static
{
$this->shareName = $shareName;
return $this;
}
public function getBasePath(): ?string
{
return $this->basePath;
}
public function setBasePath(?string $basePath): static
{
$this->basePath = $basePath;
return $this;
}
public function getDomain(): ?string
{
return $this->domain;
}
public function setDomain(?string $domain): static
{
$this->domain = $domain;
return $this;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
public function getEncryptedPassword(): ?string
{
return $this->encryptedPassword;
}
public function setEncryptedPassword(?string $encryptedPassword): static
{
$this->encryptedPassword = $encryptedPassword;
return $this;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
public function hasPassword(): bool
{
return null !== $this->encryptedPassword;
}
public function isUsable(): bool
{
return $this->enabled
&& null !== $this->host && '' !== $this->host
&& null !== $this->shareName && '' !== $this->shareName;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'delete-task-document', description: 'Delete a document attached to a task, permanently. The underlying file is also removed from disk.')]
class DeleteTaskDocumentTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param int $id ID of the task document to delete
*/
public function __invoke(int $id): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
$taskId = $document->getTask()?->getId();
$originalName = $document->getOriginalName();
$this->entityManager->remove($document);
$this->entityManager->flush();
return json_encode([
'success' => true,
'message' => sprintf('Document "%s" (ID %d) deleted.', $originalName, $id),
'id' => $id,
'taskId' => $taskId,
'originalName' => $originalName,
]);
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
use function strlen;
#[McpTool(name: 'update-task-document', description: 'Update a document attached to a task: replace its text content and/or rename it. Pass the new raw content (verbatim UTF-8) and/or a new fileName. The MIME type is re-inferred from the fileName extension. At least one of content or fileName must be provided.')]
class UpdateTaskDocumentTool
{
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
private const EXTENSION_TO_MIME = [
'md' => 'text/markdown',
'markdown' => 'text/markdown',
'txt' => 'text/plain',
'csv' => 'text/csv',
'json' => 'application/json',
'xml' => 'text/xml',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly string $uploadDir,
) {}
/**
* @param int $id ID of the task document to update
* @param null|string $content New raw text content of the document (e.g. Markdown). Omit to keep the current content.
* @param null|string $fileName New display name of the document, including extension. Omit to keep the current name.
*/
public function __invoke(
int $id,
?string $content = null,
?string $fileName = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
if (null === $content && null === $fileName) {
throw new InvalidArgumentException('At least one of content or fileName must be provided.');
}
$document = $this->entityManager->find(TaskDocument::class, $id);
if (null === $document) {
throw new InvalidArgumentException(sprintf('Task document with ID %d not found.', $id));
}
// Rename: update the display name and re-infer the MIME type from its extension.
if (null !== $fileName) {
$originalName = trim($fileName);
if ('' === $originalName) {
throw new InvalidArgumentException('fileName cannot be empty.');
}
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if ('' === $extension) {
$originalName .= '.md';
$extension = 'md';
}
$document->setOriginalName($originalName);
$document->setMimeType(self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown');
}
// Replace content: overwrite the stored file in place and refresh its size.
if (null !== $content) {
if ('' === $content) {
throw new InvalidArgumentException('Document content cannot be empty.');
}
$size = strlen($content);
if ($size > self::MAX_CONTENT_SIZE) {
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (false === file_put_contents($filePath, $content)) {
throw new InvalidArgumentException('Failed to write document to disk.');
}
$document->setSize($size);
}
$this->entityManager->flush();
return json_encode([
'id' => $document->getId(),
'taskId' => $document->getTask()?->getId(),
'originalName' => $document->getOriginalName(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'createdAt' => $document->getCreatedAt()?->format('c'),
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
]);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ShareConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ShareConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ShareConfiguration::class);
}
public function findSingleton(): ?ShareConfiguration
{
return $this->createQueryBuilder('s')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Share\Exception;
use RuntimeException;
final class InvalidPathException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Share\Exception;
use RuntimeException;
final class ShareConnectionException extends RuntimeException {}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Share\Exception;
use RuntimeException;
final class ShareNotConfiguredException extends RuntimeException {}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
final readonly class FileEntry
{
public function __construct(
public string $name,
public string $path,
public bool $isDir,
public int $size,
public ?int $modifiedAt,
public string $mimeType,
) {}
}
+20
View File
@@ -0,0 +1,20 @@
<?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;
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
use App\Service\Share\Exception\InvalidPathException;
final class SharePathResolver
{
/**
* Normalise un chemin relatif et rejette toute tentative de sortie de racine.
*/
public function normalizeRelative(string $path): string
{
$path = str_replace('\\', '/', $path);
$segments = [];
foreach (explode('/', $path) as $segment) {
if ('' === $segment || '.' === $segment) {
continue;
}
if ('..' === $segment) {
throw new InvalidPathException('Path traversal is not allowed.');
}
$segments[] = $segment;
}
return implode('/', $segments);
}
/**
* Construit le chemin SMB absolu (toujours sous basePath).
*/
public function fullPath(string $basePath, string $relativePath): string
{
$base = trim(str_replace('\\', '/', $basePath), '/');
$relative = $this->normalizeRelative($relativePath);
$parts = array_values(array_filter([$base, $relative], static fn (string $p): bool => '' !== $p));
return '/'.implode('/', $parts);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Service\Share;
final readonly class ShareTestResult
{
public function __construct(
public bool $success,
public ?string $message = null,
) {}
}
+132
View File
@@ -0,0 +1,132 @@
<?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,
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ShareSettings;
use App\Entity\ShareConfiguration;
use App\Repository\ShareConfigurationRepository;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class ShareSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private ShareConfigurationRepository $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
{
assert($data instanceof ShareSettings);
$config = $this->configRepository->findSingleton() ?? new ShareConfiguration();
$config->setHost($data->host);
$config->setShareName($data->shareName);
$config->setBasePath($data->basePath);
$config->setDomain($data->domain);
$config->setUsername($data->username);
$config->setEnabled($data->enabled);
if (null !== $data->password && '' !== $data->password) {
$config->setEncryptedPassword($this->tokenEncryptor->encrypt($data->password));
}
$this->em->persist($config);
$this->em->flush();
$result = new ShareSettings();
$result->host = $config->getHost();
$result->shareName = $config->getShareName();
$result->basePath = $config->getBasePath();
$result->domain = $config->getDomain();
$result->username = $config->getUsername();
$result->enabled = $config->isEnabled();
$result->hasPassword = $config->hasPassword();
return $result;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ShareSettings;
use App\Repository\ShareConfigurationRepository;
final readonly class ShareSettingsProvider implements ProviderInterface
{
public function __construct(
private ShareConfigurationRepository $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareSettings
{
$config = $this->configRepository->findSingleton();
$dto = new ShareSettings();
if (null !== $config) {
$dto->host = $config->getHost();
$dto->shareName = $config->getShareName();
$dto->basePath = $config->getBasePath();
$dto->domain = $config->getDomain();
$dto->username = $config->getUsername();
$dto->enabled = $config->isEnabled();
$dto->hasPassword = $config->hasPassword();
}
return $dto;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ShareTestConnection;
use App\Service\Share\FileSource;
final readonly class ShareTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private FileSource $fileSource,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
{
return new ShareTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ShareTestConnection
{
$result = $this->fileSource->test();
$dto = new ShareTestConnection();
$dto->success = $result->success;
$dto->message = $result->message;
return $dto;
}
}
+12
View File
@@ -184,6 +184,18 @@
"symfony/mcp-bundle": { "symfony/mcp-bundle": {
"version": "v0.6.0" "version": "v0.6.0"
}, },
"symfony/messenger": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/monolog-bundle": { "symfony/monolog-bundle": {
"version": "4.0", "version": "4.0",
"recipe": { "recipe": {
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
final class ShareBrowseTest extends WebTestCase
{
public function testBrowseRequiresAuthentication(): void
{
$client = self::createClient();
$client->request('GET', '/api/share/browse?path=/');
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testBrowseRejectsPathTraversal(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/browse?path='.urlencode('../etc'));
self::assertSame(400, $client->getResponse()->getStatusCode());
}
public function testBrowseReturns409WhenNotConfigured(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/browse?path=');
self::assertSame(409, $client->getResponse()->getStatusCode());
}
public function testStatusReturnsDisabledByDefault(): void
{
$client = self::createClient();
$this->login($client);
$client->request('GET', '/api/share/status');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertFalse($data['enabled']);
}
private function login(KernelBrowser $client): void
{
$em = self::getContainer()->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
*/
class ShareSettingsTest extends WebTestCase
{
public function testGetSettingsReturns401WhenNotAuthenticated(): void
{
$client = static::createClient();
$client->request('GET', '/api/settings/share');
self::assertResponseStatusCodeSame(401);
}
public function testGetSettingsReturns403ForRoleUser(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$client->loginUser($user);
$client->request('GET', '/api/settings/share');
self::assertResponseStatusCodeSame(403);
}
public function testAdminCanReadSettingsWithoutPasswordLeak(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$client->loginUser($admin);
$client->request('GET', '/api/settings/share');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('hasPassword', $data);
self::assertArrayNotHasKey('password', $data);
self::assertArrayNotHasKey('encryptedPassword', $data);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
use App\Service\Share\Exception\InvalidPathException;
use App\Service\Share\SharePathResolver;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class SharePathResolverTest extends TestCase
{
private SharePathResolver $resolver;
protected function setUp(): void
{
$this->resolver = new SharePathResolver();
}
public function testNormalizeRelativeKeepsSimplePath(): void
{
self::assertSame('a/b', $this->resolver->normalizeRelative('a/b'));
}
public function testNormalizeRelativeStripsDotsAndSlashes(): void
{
self::assertSame('a/b', $this->resolver->normalizeRelative('/a/./b/'));
}
public function testNormalizeRelativeConvertsBackslashes(): void
{
self::assertSame('a/b', $this->resolver->normalizeRelative('a\b'));
}
public function testNormalizeRelativeRejectsParentTraversal(): void
{
$this->expectException(InvalidPathException::class);
$this->resolver->normalizeRelative('a/../b');
}
public function testNormalizeRelativeRejectsLeadingParent(): void
{
$this->expectException(InvalidPathException::class);
$this->resolver->normalizeRelative('../etc/passwd');
}
public function testFullPathJoinsBaseAndRelative(): void
{
self::assertSame('/Projets/a/b', $this->resolver->fullPath('/Projets', 'a/b'));
}
public function testFullPathWithEmptyBaseAndEmptyRelativeIsRoot(): void
{
self::assertSame('/', $this->resolver->fullPath('', ''));
}
public function testFullPathTrimsBaseSlashes(): void
{
self::assertSame('/Projets/a', $this->resolver->fullPath('/Projets/', 'a'));
}
}