From 49c715199d265ebf417925ae638222fee02b9515 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 10 Oct 2026 20:14:27 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20partage=20dirig=C3=83=C2=A9=20entre=20u?= =?UTF-8?q?tilisateurs=20#196?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_share/shared_with + validation des destinataires - gate auth sur /s/{token} (+pdf, raw) : 404 opaque hors créateur/admin/destinataires - GET /api/shares scopé (non-admin = créés + reçus), révocation créateur/admin - UI : dialogue dirigé/public + destinataires, dashboard « partagé par X » - i18n FR/EN, tests test_directed_shares.py (9), fiche feature --- CHANGELOG.md | 30 +++++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/routers/sharing.py | 65 +++++++++--- backend/schemas.py | 1 + backend/share.py | 20 +++- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 21 +++- docs/features/directed-shares-196.md | 79 +++++++++++++++ frontend/js/config.js | 26 ++++- frontend/js/dashboard.js | 35 ++++--- frontend/locales/en.json | 6 ++ frontend/locales/fr.json | 6 ++ package.json | 2 +- tests/test_directed_shares.py | 142 +++++++++++++++++++++++++++ 18 files changed, 410 insertions(+), 43 deletions(-) create mode 100644 docs/features/directed-shares-196.md create mode 100644 tests/test_directed_shares.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 63da182..f458642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.65.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.66.0**. --- @@ -14,6 +14,34 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.66.0] — 2026-10-10 + +### Ajouté + +- **#196 — Partage dirigé entre utilisateurs** + - Un utilisateur peut partager un document à un ou plusieurs comptes + précis (champ destinataires dans le dialogue de partage, noms séparés + par des virgules) : les pages `/s/{token}` (+ PDF, téléchargement brut) + exigent alors une session et n'acceptent que le créateur, les admins et + les destinataires — 404 opaque sinon (l'existence d'un token dirigé + n'est pas confirmable de l'extérieur). + - Dashboard : les partages reçus s'affichent avec « Partagé par X » et + s'ouvrent en lecture seule via la page de partage ; pas de bouton + révoquer pour un destinataire. + - Rétrocompatible : les partages publics existants (sans destinataires) + restent accessibles anonymement. + +### Sécurité + +- **#196 — `GET /api/shares` scopé** : un utilisateur non-admin ne voit plus + TOUS les partages de l'instance, mais uniquement ceux qu'il a créés ou + reçus (fuite préexistante, intenable avec le partage user-to-user). La + révocation d'un partage est réservée à son créateur ou à un admin. + - i18n FR/EN (7 nouvelles clés), tests `tests/test_directed_shares.py` (9), + fiche `docs/features/directed-shares-196.md`. + +--- + ## [2.65.2] — 2026-10-10 ### Corrigé diff --git a/README.fr.md b/README.fr.md index 64910c2..3ae12d5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.65.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.66.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.65.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.66.0). --- -*Projet : ObsiGate | Version : 2.65.2 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.66.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index bad51e0..4c9ddf9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.65.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.66.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.65.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.66.0). --- -*Project: ObsiGate | Version: 2.65.2 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.66.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 3d8ad30..3d6ac35 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.65.2 +2.66.0 diff --git a/backend/routers/sharing.py b/backend/routers/sharing.py index 0da2956..513d775 100644 --- a/backend/routers/sharing.py +++ b/backend/routers/sharing.py @@ -24,7 +24,8 @@ import frontmatter from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse, HTMLResponse, Response -from backend.auth.middleware import check_vault_access, require_auth +from backend.auth.middleware import check_vault_access, get_current_user, require_auth +from backend.auth.user_store import get_user from backend.indexer import get_vault_data, parse_markdown_file, update_single_file from backend.render import _render_markdown from backend.schemas import ShareModel, StatusResponse @@ -53,6 +54,27 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing router = APIRouter(tags=["sharing"]) +def _gate_share(share: dict | None, user: dict | None) -> dict: + """#196 — directed shares: auth + membership check for ``/s/*`` pages. + + Public share (``shared_with`` empty) → pass-through, no auth required. + Directed share → requires an authenticated user who is the creator, an + admin, or listed in ``shared_with``. Anything else answers **404** (never + 403) so an outsider cannot learn that a token exists. + """ + if share is None: + raise HTTPException(404, "Share not found or expired") + recipients = share.get("shared_with") or [] + if not recipients: + return share + if user is None: + raise HTTPException(404, "Share not found or expired") + if user["username"] == share.get("created_by") or user.get("role") == "admin" \ + or user["username"] in recipients: + return share + raise HTTPException(404, "Share not found or expired") + + @router.post("/api/share/{vault_name}", response_model=ShareModel) async def api_share_create( vault_name: str, @@ -68,7 +90,14 @@ async def api_share_create( raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") path = body.get("path", "") expires = body.get("expires_in_hours") - share = create_share(vault_name, path, current_user["username"], expires) + # #196 — directed share: validate recipients before creating anything. + recipients = body.get("shared_with") or [] + if not isinstance(recipients, list): + raise HTTPException(400, "shared_with doit être une liste d'utilisateurs") + for name in recipients: + if not isinstance(name, str) or not get_user(name): + raise HTTPException(400, f"Utilisateur inconnu : {name}") + share = create_share(vault_name, path, current_user["username"], expires, recipients) share["url"] = f"/s/{share['token']}" # Set publish: true in the file's frontmatter @@ -94,8 +123,13 @@ async def api_share_create( @router.get("/api/shares", response_model=list[ShareModel]) async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)): - """List all shares (optionally filtered by vault).""" - shares = list_shares(vault) + """List shares (optionally filtered by vault). + + #196 : un non-admin ne voit que SES partages (créés ou reçus) ; un admin + voit tout (comportement historique). + """ + username = None if current_user.get("role") == "admin" else current_user["username"] + shares = list_shares(vault, username) for s in shares: s["url"] = f"/s/{s['token']}" return shares @@ -103,6 +137,12 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends( @router.delete("/api/share/{share_id}", response_model=StatusResponse) async def api_share_revoke(share_id: str, current_user=Depends(require_auth)): + # #196 — only the creator or an admin may revoke; a recipient cannot. + from backend.share import _read + share = _read()["shares"].get(share_id) + if share and current_user.get("role") != "admin" \ + and share.get("created_by") != current_user["username"]: + raise HTTPException(403, "Seul le créateur du partage peut le révoquer") if not revoke_share(share_id): raise HTTPException(404, "Share not found") return {"status": "revoked"} @@ -113,13 +153,12 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)): response_class=Response, responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}}, ) -async def public_share_pdf_download(token: str): +async def public_share_pdf_download(token: str, current_user=Depends(get_current_user)): """Download shared document as real PDF via WeasyPrint.""" if generate_pdf is None: raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") share = get_share_by_token(token) - if not share: - raise HTTPException(404, "Share not found or expired") + share = _gate_share(share, current_user) vault_data = get_vault_data(share["vault"]) if not vault_data: raise HTTPException(404, "Vault not found") @@ -147,11 +186,10 @@ async def public_share_pdf_download(token: str): @router.get("/s/{token}/raw", response_class=FileResponse) -async def public_share_raw(token: str): +async def public_share_raw(token: str, current_user=Depends(get_current_user)): """Download the raw (original) shared document.""" share = get_share_by_token(token) - if not share: - raise HTTPException(404, "Share not found or expired") + share = _gate_share(share, current_user) vault_data = get_vault_data(share["vault"]) if not vault_data: raise HTTPException(404, "Vault not found") @@ -164,13 +202,12 @@ async def public_share_raw(token: str): @router.get("/s/{token}", response_class=HTMLResponse) -async def public_share_view(request: Request, token: str): - """Public share view — no authentication required.""" +async def public_share_view(request: Request, token: str, current_user=Depends(get_current_user)): + """Public share view — no authentication required (unless directed #196).""" from backend.csp import inject_csp_nonce share = get_share_by_token(token) - if not share: - raise HTTPException(404, "Share not found or expired") + share = _gate_share(share, current_user) vault_data = get_vault_data(share["vault"]) if not vault_data: raise HTTPException(404, "Vault not found") diff --git a/backend/schemas.py b/backend/schemas.py index 1233857..7dc25c1 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1056,6 +1056,7 @@ class ShareModel(BaseModel): expires_at: str | None = None access_count: int = 0 last_accessed: str | None = None + shared_with: list[str] = Field(default_factory=list, description="#196 destinataires (partage dirigé)") class ConflictEntry(BaseModel): diff --git a/backend/share.py b/backend/share.py index f003eeb..c008d96 100644 --- a/backend/share.py +++ b/backend/share.py @@ -44,8 +44,14 @@ def create_share( path: str, created_by: str, expires_in_hours: int | None = None, + shared_with: list[str] | None = None, ) -> dict: - """Create a new share token for a document.""" + """Create a new share token for a document. + + ``shared_with`` non vide (#196) : partage **dirigé** — la page ``/s/…`` + exige alors une session et n'accepte que les destinataires listés (plus + le créateur et les admins). Vide/absent : comportement public inchangé. + """ with _lock: data = _read() token = secrets.token_hex(32) # 64-char hex token @@ -64,6 +70,7 @@ def create_share( "expires_at": expires_at, "access_count": 0, "last_accessed": None, + "shared_with": list(shared_with) if shared_with else [], } data["shares"][token] = share _write(data) @@ -107,10 +114,17 @@ def revoke_share(share_id: str) -> bool: return False -def list_shares(vault_filter: str | None = None) -> list: - """List all shares, optionally filtered by vault.""" +def list_shares(vault_filter: str | None = None, user: str | None = None) -> list: + """List shares, optionally filtered by vault and/or requesting user. + + #196 : un non-admin ne voit que les partages qu'il a créés **ou** qui lui + sont dirigés. Un admin voit tout. ``user=None`` (anciens appels, tests + unitaires) conserve l'ancien comportement : tout lister. + """ data = _read() shares = list(data["shares"].values()) + if user is not None: + shares = [s for s in shares if user in (s.get("created_by"), *(s.get("shared_with") or []))] if vault_filter: shares = [s for s in shares if s["vault"] == vault_filter] # Most recent first diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index ec844e2..8362965 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.65.2" +version = "2.66.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index dc6d0d2..b6bd557 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.65.2" +version = "2.66.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 91449aa..91a396c 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.65.2", + "version": "2.66.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index de2b7d5..a578cfa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.65.2 | **Dernière mise à jour :** 2026-10-10 +> **Version :** 2.66.0 | **Dernière mise à jour :** 2026-10-10 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -30,6 +30,25 @@ ## 🔵 En cours +### 196. Partage dirigé entre utilisateurs (document → utilisateur) + +- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10 +- **Fiche :** [features/directed-shares-196.md](./features/directed-shares-196.md) +- **Description :** un utilisateur peut partager un document à un ou plusieurs + autres comptes (pas un lien public) : les pages `/s/{token}` exigent alors + une session et n'acceptent que créateur + admin + destinataires (404 sinon, + pas de fuite d'existence). Vue « partagé avec moi » dans le dashboard, + destinataires listés dans le dialogue de partage, révocabilité réservée au + créateur/admin, `GET /api/shares` scopé (un non-admin ne voit que ses + partages créés/reçus — corrige une fuite préexistante). Rétrocompatible : + les partages publics existants restent anonymes. +- **Sous-tâches :** + - [x] Store `shared_with` + validation des destinataires à la création + - [x] Gate auth+appartenance sur `/s/{token}` (+`/pdf`, `/raw`) — 404 opaque + - [x] Scope de `GET /api/shares` ; révocation créateur/admin uniquement + - [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X » + - [x] i18n FR/EN, tests `test_directed_shares.py` (9), fiche feature + ### 194. Dossier personnel par utilisateur (`/Home/`) - **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours diff --git a/docs/features/directed-shares-196.md b/docs/features/directed-shares-196.md new file mode 100644 index 0000000..aae1c23 --- /dev/null +++ b/docs/features/directed-shares-196.md @@ -0,0 +1,79 @@ +# #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 : + +1. **Réutiliser le partage par token existant, dirigé** (retenu) — le record + `data/shares.json` gagne `shared_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). +2. 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/shares` scopé** (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_with` est 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). diff --git a/frontend/js/config.js b/frontend/js/config.js index 6ec4803..e5714ef 100644 --- a/frontend/js/config.js +++ b/frontend/js/config.js @@ -1656,10 +1656,14 @@ export async function openShareDialog(vault, path) { const expiresInfo = existingShare.expires_at ? `

Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}

` : '

Sans expiration

'; + const recipientsInfo = (existingShare.shared_with && existingShare.shared_with.length) + ? `

${t('config.share_shared_with')} : ${escapeHtml(existingShare.shared_with.join(", "))}

` + : ''; div.innerHTML = `