- icône share-2 bleue sur les fichiers partagés dans l'arborescence
- dossier virtuel Partage/ Ã la racine du home du destinataire (lecture seule /s/{token})
- recherche plein texte couvre les documents reçus (merge search_vaults, share_token)
- cache front invalidé à la création/révocation ; +2 tests non-fuite
6.0 KiB
#196 — Partage dirigé entre utilisateurs
Statut : livré | Ouvert : 2026-10-10 | Type : feature (sharing + auth)
Problème
Le seul partage existant (#85) était un lien public : quiconque possède l'URL lit le document, sans compte ni session. Il n'existait aucun moyen de partager un document à un utilisateur précis de la plateforme — un utilisateur ne pouvait ni adresser un document à un autre, ni consulter les documents qu'on lui adresse, ni (côté créateur) retirer ce partage.
Décision d'architecture
Deux formes étaient possibles :
- Réutiliser le partage par token existant, dirigé (retenu) — le record
data/shares.jsongagneshared_with: [usernames]. Si la liste est non vide, les pages/s/{token}(et/pdf,/raw) exigent une session et n'acceptent que créateur + admin + destinataires. Vide/absent : le comportement public historique est inchangé (rétrocompatibilité totale, zéro migration — les shares existants n'ont pas le champ). - Octroyer le vault entier au destinataire (
update_user {"vaults": …}) (écartée) — accès lecture-écriture au vault complet au lieu d'un document en lecture seule ; c'est le modèle « collaborateur de vault », pas « partager un document ». Reste la bonne réponse pour ce besoin-là.
Points de sécurité délibérés :
- 404, jamais 403/401, sur la gate — un visiteur sans session (ou un utilisateur non destinataire) apprend le même « Share not found or expired » qu'un token inventé : l'existence d'un token dirigé n'est pas confirmable de l'extérieur.
GET /api/sharesscopé (correctif au passage) — un non-admin ne voit que les partages qu'il a créés ou reçus` (avant : tout le monde voyait tous les partages, fuite préexistante devenue intenable avec du user-to-user). Un admin voit tout.- Révocation réservée au créateur/admin — un destinataire ne peut pas casser l'accès des autres.
- Les destinataires sont validés à la création (
get_user) : pas de lien mort vers un compte inexistant.
Mise en place
| Élément | Où |
|---|---|
Store : champ shared_with, param de create_share |
backend/share.py |
Gate _gate_share() + branchement /s/{token}, /s/{token}/pdf, /s/{token}/raw |
backend/routers/sharing.py |
Scope de GET /api/shares (non-admin = créés + reçus) |
backend/routers/sharing.py, backend/share.py::list_shares(user=…) |
| Révocabilité créateur/admin uniquement | backend/routers/sharing.py::api_share_revoke |
| Validation des destinataires à la création | backend/routers/sharing.py::api_share_create |
ShareModel.shared_with |
backend/schemas.py |
| Dialogue : radio public/dirigé + champ destinataires (virgules) | frontend/js/config.js::openShareDialog |
| Destinataires affichés dans la vue « déjà partagé » | frontend/js/config.js |
Dashboard : cartes reçues (« Partagé par X », ouverture /s/, pas de bouton révoquer) |
frontend/js/dashboard.js::DashboardSharedWidget |
| i18n FR/EN (6 clés config.* + dashboard.shared_by) | frontend/locales/{fr,en}.json |
Ce qui est gratuit par construction : lecture seule (le destinataire ne
passe jamais par l'éditeur), expiration, compteur d'accès, suivi des
renommages (update_shares_after_rename), masquage des secrets
(redact_file_content déjà dans les 3 routes /s/*), isolation des
dossiers perso #194 (le destinataire ne voit jamais l'arbre du vault de
l'auteur, seulement le document rendu).
Tests
tests/test_directed_shares.py (9) : création dirigée, destinataire inconnu
→ 400, anonyme → 404 / destinataire + créateur → 200, non-destinataire →
404, lien public toujours anonyme, scope de /api/shares, destinataire ne
peut pas révoquer (403), créateur révoque, la révocation coupe l'accès.
Limites connues
shared_withest une liste plate d'usernames — pas de groupes. Ajouter des groupes quand le besoin sera réel.- Pas de notification push : le destinataire découvre les partages reçus au rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe si un vrai push est demandé).
- La date d'« expiration » reste globale au partage (pas par destinataire).
Itération 2 — visibilité & recherche (2026-10-11)
| Élément | Où |
|---|---|
| Icône « partagé » (share-2 bleu) sur les fichiers de l'arborescence | frontend/js/sidebar.js (loadSharedPaths + sharedFileIconSync, appliqué dans loadDirectory et incrementalLoadDirectory) |
| Dossier virtuel Partage à la racine du home du destinataire | frontend/js/sidebar.js (injection virtual-share + rendu, clic fichier → /s/{token}) |
| Recherche des documents reçus | backend/services/search.py::_shared_results (merge dans search_vaults avant pagination), backend/routers/search.py (passe username), SearchResultItem.share_token |
| Clic sur un résultat reçu → page de partage | frontend/js/search.js |
| Invalidation du cache front | invalidateSharedPaths() exporté, appelé par openShareDialog après création/révocation |
Décisions :
- Dossier virtuel, pas physique : l'indexeur exclut les symlinks
(sécurité path traversal) et un dossier réel ferait double indexation.
La source de vérité reste
data/shares.json. - Recherche à la volée (
_shared_results) : le contenu des documents reçus est lu au moment de la requête, uniquement pour les destinataires — jamais indexé dans le TF-IDF global, donc aucune fuite vers un non-destinataire (testé). ponytail: lecture disque par requête, plafond ~quelques centaines de partages reçus par user ; indexer dans le TF-IDF avec un champ "destinataires" si le volume devient réel. - Icône déterminée côté client via
/api/shares(déjà scopé #196) : la liste des partages actifs "créés OU reçus" suffit — le créateur voit l'icône sur son fichier source, le destinataire voit le dossier Partage.
+2 tests : test_search_finds_received_share_for_recipient,
test_search_does_not_leak_share_to_other_user.