feat: partage dirigé entre utilisateurs #196
- 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
This commit is contained in:
+29
-1
@@ -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é
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
+51
-14
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.65.2"
|
||||
version = "2.66.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+20
-1
@@ -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 (`<racine>/Home/<user>`)
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
|
||||
|
||||
@@ -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).
|
||||
+24
-2
@@ -1656,10 +1656,14 @@ export async function openShareDialog(vault, path) {
|
||||
const expiresInfo = existingShare.expires_at
|
||||
? `<p style="font-size:0.75rem;color:var(--text-muted)">Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}</p>`
|
||||
: '<p style="font-size:0.75rem;color:var(--text-muted)">Sans expiration</p>';
|
||||
const recipientsInfo = (existingShare.shared_with && existingShare.shared_with.length)
|
||||
? `<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px">${t('config.share_shared_with')} : ${escapeHtml(existingShare.shared_with.join(", "))}</p>`
|
||||
: '';
|
||||
div.innerHTML = `
|
||||
<div class="share-dialog">
|
||||
<h3>📤 Document partagé</h3>
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
||||
${recipientsInfo}
|
||||
${expiresInfo}
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
|
||||
<input type="text" class="share-url-input" value="${url}" readonly>
|
||||
@@ -1697,7 +1701,16 @@ export async function openShareDialog(vault, path) {
|
||||
<div class="share-dialog">
|
||||
<h3>📤 Partager ce document</h3>
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:12px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
||||
<p style="font-size:0.8rem;margin-bottom:8px">Ce lien sera accessible <strong>publiquement, sans authentification</strong>.</p>
|
||||
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<input type="radio" name="share-visibility" value="directed" checked>
|
||||
${t('config.share_directed')}
|
||||
</label>
|
||||
<input type="text" id="share-recipients" placeholder="${t('config.share_recipients_ph')}"
|
||||
style="width:100%;padding:6px 8px;margin-bottom:8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem">
|
||||
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<input type="radio" name="share-visibility" value="public">
|
||||
${t('config.share_public')}
|
||||
</label>
|
||||
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
Expiration :
|
||||
<select id="share-expiry" style="padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem">
|
||||
@@ -1716,10 +1729,19 @@ export async function openShareDialog(vault, path) {
|
||||
div.querySelector(".share-create-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
const expiry = document.getElementById("share-expiry")?.value;
|
||||
const visibility = div.querySelector('input[name="share-visibility"]:checked')?.value || "directed";
|
||||
const recipientsRaw = document.getElementById("share-recipients")?.value || "";
|
||||
const recipients = visibility === "directed"
|
||||
? recipientsRaw.split(",").map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
if (visibility === "directed" && !recipients.length) {
|
||||
showToast(t('config.share_need_recipient'), "error");
|
||||
return;
|
||||
}
|
||||
const share = await api(`/api/share/${encodeURIComponent(vault)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }),
|
||||
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null, shared_with: recipients }),
|
||||
});
|
||||
existingShare = share;
|
||||
renderContent();
|
||||
|
||||
+24
-11
@@ -1,5 +1,5 @@
|
||||
// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
|
||||
import { api } from './auth.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js';
|
||||
import { icon, openFile } from './viewer.js';
|
||||
@@ -60,24 +60,29 @@ const DashboardSharedWidget = {
|
||||
const shares = await api("/api/shares");
|
||||
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
|
||||
if (empty) empty.style.display = "none";
|
||||
grid.innerHTML = shares.map(s => `
|
||||
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}">
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
grid.innerHTML = shares.map(s => {
|
||||
const received = me && s.created_by && s.created_by !== me;
|
||||
const actions = received
|
||||
? `<button class="shared-open-btn">${t('dashboard.open_btn')}</button>`
|
||||
: `<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
|
||||
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
|
||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>`;
|
||||
return `
|
||||
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}" data-token="${s.token}" data-received="${received ? "1" : ""}">
|
||||
<div class="shared-card-header">
|
||||
<i data-lucide="file-text" style="width:14px;height:14px"></i>
|
||||
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))}</span>
|
||||
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\\.md$/i, ""))}</span>
|
||||
<span class="shared-card-vault">${escapeHtml(s.vault)}</span>
|
||||
</div>
|
||||
<div class="shared-card-meta">
|
||||
${received ? `<span>${t('dashboard.shared_by', { user: s.created_by })}</span>` : ""}
|
||||
<span>${s.access_count || 0} ${t('dashboard.views', {count: s.access_count || 0})}</span>
|
||||
${s.expires_at ? `<span>${t('dashboard.expires', {date: new Date(s.expires_at).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US')})}</span>` : ""}
|
||||
</div>
|
||||
<div class="shared-card-actions">
|
||||
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
|
||||
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
|
||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
<div class="shared-card-actions">${actions}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
lucide.createIcons();
|
||||
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -88,6 +93,10 @@ const DashboardSharedWidget = {
|
||||
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const card = b.closest(".shared-card");
|
||||
if (card && card.dataset.received === "1" && card.dataset.token) {
|
||||
window.open(`/s/${card.dataset.token}`, "_blank");
|
||||
return;
|
||||
}
|
||||
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||
}));
|
||||
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
@@ -97,6 +106,10 @@ const DashboardSharedWidget = {
|
||||
this.load();
|
||||
}));
|
||||
grid.querySelectorAll(".shared-card").forEach(card => card.addEventListener("click", () => {
|
||||
if (card.dataset.received === "1" && card.dataset.token) {
|
||||
window.open(`/s/${card.dataset.token}`, "_blank");
|
||||
return;
|
||||
}
|
||||
TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||
}));
|
||||
} catch (err) { if (empty) empty.style.display = ""; }
|
||||
|
||||
@@ -604,6 +604,11 @@
|
||||
"config.server_config": "Server config",
|
||||
"config.settings_saved": "✓ Settings saved",
|
||||
"config.share_revoked": "Share revoked",
|
||||
"config.share_directed": "Share with users (authentication required)",
|
||||
"config.share_public": "Public link (no authentication)",
|
||||
"config.share_recipients_ph": "Recipients (usernames, comma-separated)",
|
||||
"config.share_need_recipient": "Provide at least one recipient",
|
||||
"config.share_shared_with": "Shared with",
|
||||
"config.shares": "Public shares",
|
||||
"config.shares_desc": "Manage public share links for your documents.",
|
||||
"config.shares_empty": "No active shares",
|
||||
@@ -701,6 +706,7 @@
|
||||
"dashboard.recent": "Recent",
|
||||
"dashboard.recent_hint": "Open a file to see it appear here",
|
||||
"dashboard.shared_hint": "Share a document to see it appear here",
|
||||
"dashboard.shared_by": "Shared by {user}",
|
||||
"dashboard.shares": "Shares",
|
||||
"dashboard.show": "Show",
|
||||
"dashboard.size_bytes": " B",
|
||||
|
||||
@@ -604,6 +604,11 @@
|
||||
"config.server_config": "Configuration serveur",
|
||||
"config.settings_saved": "✓ Paramètres sauvegardés",
|
||||
"config.share_revoked": "Partage révoqué",
|
||||
"config.share_directed": "Partager avec des utilisateurs (authentification requise)",
|
||||
"config.share_public": "Lien public (sans authentification)",
|
||||
"config.share_recipients_ph": "Destinataires (noms d'utilisateur, séparés par des virgules)",
|
||||
"config.share_need_recipient": "Indiquez au moins un destinataire",
|
||||
"config.share_shared_with": "Partagé avec",
|
||||
"config.shares": "Partages publics",
|
||||
"config.shares_desc": "Gérez les liens de partage public de vos documents.",
|
||||
"config.shares_empty": "Aucun partage actif",
|
||||
@@ -701,6 +706,7 @@
|
||||
"dashboard.recent": "Récents",
|
||||
"dashboard.recent_hint": "Ouvrez un fichier pour le voir apparaître ici",
|
||||
"dashboard.shared_hint": "Partagez un document pour le voir apparaître ici",
|
||||
"dashboard.shared_by": "Partagé par {user}",
|
||||
"dashboard.shares": "Partages",
|
||||
"dashboard.show": "Afficher",
|
||||
"dashboard.size_bytes": " o",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.65.2",
|
||||
"version": "2.66.0",
|
||||
"description": "**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.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# #196 — Partage dirigé entre utilisateurs : gate des pages /s/*, scope de
|
||||
# /api/shares, révocabilité, validation des destinataires.
|
||||
# Fixture admin_client (conftest.py) : users admin (role admin, vaults ["*"])
|
||||
# et normaluser (role user, vaults ["TestVault"]), auth activée.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _logged_client(username, password):
|
||||
"""Fresh TestClient with a session (cookie) — /s/* reads the access_token cookie."""
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
|
||||
c = TestClient(app)
|
||||
resp = c.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return c
|
||||
|
||||
|
||||
def _ensure_shared_file():
|
||||
# admin_client chdir(tmp_path) mais le vault indexé est VAULT_1_PATH
|
||||
# (absolu, résolu avant le chdir) → écrire là, pas dans cwd.
|
||||
vault = Path(os.environ["VAULT_1_PATH"])
|
||||
vault.mkdir(parents=True, exist_ok=True)
|
||||
target = vault / "share_directed.md"
|
||||
target.write_text("# Partagé\n\ncontenu dirigé\n", encoding="utf-8")
|
||||
return "share_directed.md"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(admin_client):
|
||||
"""admin (creator) and normaluser (recipient) clients, logged in via cookie."""
|
||||
return _logged_client("admin", "chab30"), _logged_client("normaluser", "normal123")
|
||||
|
||||
|
||||
class TestDirectedShareGate:
|
||||
def test_create_directed_share(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["shared_with"] == ["normaluser"]
|
||||
|
||||
def test_create_directed_unknown_recipient_rejected(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["ghost"],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_recipient_can_view_but_anon_cannot(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
token = resp.json()["token"]
|
||||
|
||||
# Anonymous → 404 (pas 401/403 : on ne fuite pas l'existence du token)
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
anon = TestClient(app)
|
||||
assert anon.get(f"/s/{token}").status_code == 404
|
||||
|
||||
# Recipient (cookie de session via Bearer) → 200
|
||||
assert user_client.get(f"/s/{token}").status_code == 200
|
||||
# Creator → 200
|
||||
assert admin_client.get(f"/s/{token}").status_code == 200
|
||||
|
||||
def test_other_user_cannot_view(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["admin"], # dirigé, mais pas normaluser
|
||||
})
|
||||
token = resp.json()["token"]
|
||||
other = _logged_client("normaluser", "normal123")
|
||||
assert other.get(f"/s/{token}").status_code == 404
|
||||
|
||||
def test_public_share_still_anonymous(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={"path": path})
|
||||
token = resp.json()["token"]
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
anon = TestClient(app)
|
||||
assert anon.get(f"/s/{token}").status_code == 200
|
||||
|
||||
def test_shares_list_scoped_for_user(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
# normaluser (non-admin) voit le partage reçu
|
||||
resp = user_client.get("/api/shares")
|
||||
assert resp.status_code == 200
|
||||
tokens = [s["token"] for s in resp.json()]
|
||||
assert tokens, "destinataire doit voir le partage reçu"
|
||||
|
||||
# Un admin voit tout
|
||||
resp = admin_client.get("/api/shares")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
def test_recipient_cannot_revoke(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
sid = resp.json()["id"]
|
||||
resp = user_client.delete(f"/api/share/{sid}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_creator_can_revoke(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
sid = resp.json()["id"]
|
||||
assert admin_client.delete(f"/api/share/{sid}").status_code == 200
|
||||
|
||||
def test_revoke_cuts_recipient_access(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
body = resp.json()
|
||||
assert user_client.get(f"/s/{body['token']}").status_code == 200
|
||||
admin_client.delete(f"/api/share/{body['id']}")
|
||||
assert user_client.get(f"/s/{body['token']}").status_code == 404
|
||||
Reference in New Issue
Block a user