Compare commits

..

17 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
41 changed files with 4156 additions and 5 deletions
+1
View File
@@ -12,6 +12,7 @@
"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
+73 -1
View File
@@ -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": "dc72ee68996f3f738763eafd350bc0e0",
"content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2508,6 +2508,78 @@
},
"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",
+2
View File
@@ -64,3 +64,5 @@ services:
App\Controller\Absence\AbsenceJustificationDownloadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.24'
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>
+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"
}
},
"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",
+17 -2
View File
@@ -100,6 +100,14 @@
: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"
@@ -222,6 +230,9 @@ 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
@@ -267,13 +278,17 @@ onMounted(() => {
if (isMailVisible.value) {
mailStore.startPolling()
}
ensureShareStatus()
})
watch(() => auth.user, (user) => {
if (!user) {
mailStore.stopPolling()
} else if (isMailVisible.value) {
mailStore.startPolling()
} else {
if (isMailVisible.value) {
mailStore.startPolling()
}
ensureShareStatus()
}
})
+2
View File
@@ -30,6 +30,7 @@
<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>
@@ -50,6 +51,7 @@ 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
+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>
+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 }
}
+1
View File
@@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y \
wget \
git \
unzip \
smbclient \
&& docker-php-ext-install -j$(nproc) \
intl \
zip \
+1 -1
View File
@@ -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 \
nginx supervisor smbclient \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& 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,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": {
"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": {
@@ -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'));
}
}