diff --git a/CHANGELOG.md b/CHANGELOG.md index d09bd4d..06d7b69 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.67.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.67.2**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.67.2] — 2026-10-10 + +--- + ## [2.67.1] — 2026-10-10 --- @@ -39,6 +43,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). serveur `home-/Partage/` vers le fichier source (`/api/file`, raw, download) ; un `path` vide/null est rejeté (400) à la création d'un partage. + - **Itération 4 :** correctifs d'ouverture — « Accès refusé à la vault … » + (le partage dirigé est l'autorisation, l'ACL du vault source est + court-circuitée après résolution `Partage/…`), « File not found: + Partage/… » (les partages non adressés à l'utilisateur ne s'affichent + plus comme reçus ; chemin canonique `Partage//` pour + désambiguïser les fichiers homonymes). --- diff --git a/README.fr.md b/README.fr.md index 24308e5..6bc592c 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.67.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.67.2-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.67.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.67.2). --- -*Projet : ObsiGate | Version : 2.67.1 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.67.2 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 8b14049..a83d875 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.67.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.67.2-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.67.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.2). --- -*Project: ObsiGate | Version: 2.67.1 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.67.2 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 9db2e99..456312b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.67.1 +2.67.2 diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index b08ad3b..4192f5a 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -57,17 +57,31 @@ def _resolve_shared_file(vault_name: str, path: str, username: str | None): mounted in the user's personal folder, else ``None``. Read-only: only a recipient (or the share creator, whose own file is already accessible) gets a mapping — a share directed to someone else never resolves here. + + Chemin canonique : ``Partage//`` (token = lève l'ambiguïté + de deux partages au même nom). Fallback par nom seul pour les liens + ne transportant pas le token. """ if not username or not vault_name.startswith("home-") or not path.startswith("Partage/"): return None owner = vault_name[len("home-"):] if owner != username: return None - name = path.split("/", 1)[1] + # Chemin virtuel canonique : Partage// — le token lève + # l'ambiguïté (deux partages, même nom de base). Fallback : match par + # nom pour les liens ne portant pas le token. + rest = path.split("/", 1)[1] + parts = rest.split("/", 1) + if len(parts) == 2 and len(parts[0]) >= 20: # token (64 hex) vs nom de fichier + token, _name = parts + for s in list_shares(user=username): + if s.get("token") == token and s.get("created_by") != username: + return s["vault"], s["path"] + return None for s in list_shares(user=username): if s.get("created_by") == username: continue - if (s.get("path") or "").split("/")[-1] == name: + if (s.get("path") or "").split("/")[-1] == rest: return s["vault"], s["path"] return None @@ -129,8 +143,9 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela """ shared = _resolve_shared_file(vault_name, path, current_user.get("username")) if shared: + # Partage dirigé = autorisation (cf. api_file). vault_name, path = shared - if not check_vault_access(vault_name, current_user): + elif not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") return read_raw_file(vault_name, path) @@ -148,8 +163,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description= """ shared = _resolve_shared_file(vault_name, path, current_user.get("username")) if shared: + # Partage dirigé = autorisation (cf. api_file). vault_name, path = shared - if not check_vault_access(vault_name, current_user): + elif not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: @@ -340,9 +356,10 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative # est l'appartenance au partage (vérifiée dans le resolver). shared = _resolve_shared_file(vault_name, path, current_user.get("username")) if shared: + # Le partage dirigé EST l'autorisation : le destinataire n'a par + # définition pas accès au vault source — on ne passe PAS par l'ACL. vault_name, path = shared - - if not check_vault_access(vault_name, current_user): + elif not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index c691c8b..03f5107 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.67.1" +version = "2.67.2" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index d42eb46..4bb910d 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.67.1" +version = "2.67.2" 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 ade8686..5511485 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.67.1", + "version": "2.67.2", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 72ed8c2..25aa466 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.67.1 | **Dernière mise à jour :** 2026-10-10 +> **Version :** 2.67.2 | **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)** @@ -64,6 +64,14 @@ `home-/Partage/` → fichier source dans `/api/file` (+ raw, download) ; validation `path` non-nulle à la création d'un partage. +2 tests. + - [x] **Itération 4 (2026-10-11)** : le partage dirigé EST l'autorisation — + la résolution `Partage/…` court-circuite l'ACL du vault source (fix + « Accès refusé à la vault … » : le destinataire n'a par définition + pas accès au vault de l'émetteur) ; chemin virtuel canonique + `Partage//` (lève l'ambiguïté de fichiers homonymes, + fallback par nom conservé) ; « reçu » = adressé à moi (un admin ne + voit plus les partages d'autres comptes comme reçus — fix 404 + « File not found: Partage/… »). +3 tests. ### 194. Dossier personnel par utilisateur (`/Home/`) diff --git a/docs/features/directed-shares-196.md b/docs/features/directed-shares-196.md index b2d732d..9a90698 100644 --- a/docs/features/directed-shares-196.md +++ b/docs/features/directed-shares-196.md @@ -121,3 +121,23 @@ Décisions : `/api/shares` en 500). +2 tests : ouverture applicative du fichier reçu (file + raw), résolution scopée par utilisateur (home d'autrui → 403). + + +## Itération 4 — correctifs d'ouverture (2026-10-11) + +- **Le partage EST l'autorisation** : après résolution `Partage/…`, l'ACL du + vault source n'est plus appliquée (`elif` dans `/api/file`, raw, download) — + un destinataire sans accès au vault émetteur ouvrait « Accès refusé à la + vault '…' », précisément le cas d'usage du partage. L'appartenance au + partage (vérifiée dans `_resolve_shared_file` via `list_shares(user=…)`) + reste la seule autorisation. +- **Chemin canonique `Partage//`** : lève l'ambiguïté de deux + partages homonymes (le token identifie le partage exact). Fallback par nom + conservé pour les liens historiques. Transporté par l'arbre (`data-path`), + la recherche et le dashboard. +- **« Reçu » = adressé à moi** : sidebar `_loadReceivedShares` et dashboard + filtrent désormais `shared_with.includes(me)` — un admin voyant TOUS les + partages (`/api/shares` non scopé pour lui) n'a plus les partages d'autres + comptes dans son dossier Partage (source des « File not found »). ++3 tests : destinataire sans accès au vault source (200), disambiguïsation +par token, home d'autrui (403). 14 tests au total. diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js index 1df377c..f60659a 100644 --- a/frontend/js/dashboard.js +++ b/frontend/js/dashboard.js @@ -62,7 +62,11 @@ const DashboardSharedWidget = { if (empty) empty.style.display = "none"; const me = (AuthManager.getUser() || {}).username; grid.innerHTML = shares.map(s => { - const received = me && s.created_by && s.created_by !== me; + // Reçu = adressé à moi (et pas créé par moi). Un admin voit TOUS les + // partages via /api/shares — ceux des autres comptes ne sont pas + // « reçus » pour lui : ils ne se résolvent pas dans son Partage/. + const received = me && s.created_by && s.created_by !== me + && Array.isArray(s.shared_with) && s.shared_with.includes(me); const actions = received ? `` : ` diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index f239e28..0eaec0a 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -32,7 +32,10 @@ async function _loadReceivedShares() { if (!me) return []; try { const shares = await api("/api/shares"); - return shares.filter(s => s.created_by && s.created_by !== me); + // Adressés à moi uniquement : un admin voit tous les partages via + // /api/shares, ceux des autres ne sont pas dans SON Partage/. + return shares.filter(s => s.created_by && s.created_by !== me + && Array.isArray(s.shared_with) && s.shared_with.includes(me)); } catch { return []; } } @@ -366,16 +369,17 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { const shareSub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Partage` }); item.shares.forEach((s) => { const name = (s.path || "").split("/").pop().replace(/\.md$/i, ""); - const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Partage/${s.path.split("/").pop()}`, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]); + const virtualPath = `Partage/${s.token}/${s.path.split("/").pop()}`; + const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": virtualPath, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]); fItem.title = t("dashboard.shared_by", { user: s.created_by || "" }); fItem.addEventListener("click", () => { scrollTreeItemIntoView(fItem, false); - // #196 : ouverture en onglet applicatif — /api/file résout Partage/ - // vers le fichier source (lecture seule). + // #196 : ouverture en onglet applicatif — /api/file résout + // Partage// vers le fichier source (lecture seule). if (window.innerWidth <= 768) { - TabManager.openPersistent(vaultName, `Partage/${(s.path || "").split("/").pop()}`); + TabManager.openPersistent(vaultName, virtualPath); } else { - TabManager.openPreview(vaultName, `Partage/${(s.path || "").split("/").pop()}`); + TabManager.openPreview(vaultName, virtualPath); } closeMobileSidebar(); }); @@ -736,16 +740,17 @@ async function loadDirectory(vaultName, dirPath, container) { const shareSub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Partage` }); item.shares.forEach((s) => { const name = (s.path || "").split("/").pop().replace(/\.md$/i, ""); - const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Partage/${s.path.split("/").pop()}`, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]); + const virtualPath = `Partage/${s.token}/${s.path.split("/").pop()}`; + const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": virtualPath, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]); fItem.title = t("dashboard.shared_by", { user: s.created_by || "" }); fItem.addEventListener("click", () => { scrollTreeItemIntoView(fItem, false); - // #196 : ouverture en onglet applicatif — /api/file résout Partage/ - // vers le fichier source (lecture seule). + // #196 : ouverture en onglet applicatif — /api/file résout + // Partage// vers le fichier source (lecture seule). if (window.innerWidth <= 768) { - TabManager.openPersistent(vaultName, `Partage/${(s.path || "").split("/").pop()}`); + TabManager.openPersistent(vaultName, virtualPath); } else { - TabManager.openPreview(vaultName, `Partage/${(s.path || "").split("/").pop()}`); + TabManager.openPreview(vaultName, virtualPath); } closeMobileSidebar(); }); diff --git a/package.json b/package.json index dbc2aa7..7f2c44d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.67.1", + "version": "2.67.2", "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": { diff --git a/tests/test_directed_shares.py b/tests/test_directed_shares.py index 11a0989..03e1335 100644 --- a/tests/test_directed_shares.py +++ b/tests/test_directed_shares.py @@ -169,24 +169,40 @@ class TestDirectedShareGate: assert not [r for r in resp.json()["results"] if r.get("share_token")] def test_recipient_opens_shared_file_in_app(self, sessions): - # #196 : /api/file résout home-/Partage/ vers la source. + # #196 : /api/file résout home-/Partage// vers la + # source — même quand le destinataire N'A PAS accès au vault source + # (le partage dirigé EST l'autorisation). admin_client, user_client = sessions path = _ensure_shared_file() resp = admin_client.post("/api/share/TestVault", json={ "path": path, "shared_with": ["normaluser"], }) assert resp.status_code == 200 - r = user_client.get("/api/file/home-normaluser", params={"path": "Partage/share_directed.md"}) + token = resp.json()["token"] + + # Retire l'accès direct au vault source : le partage doit suffire. + from backend.auth.user_store import update_user + update_user("normaluser", {"vaults": ["home-normaluser"]}) + + # Chemin canonique avec token + r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{token}/share_directed.md"}) assert r.status_code == 200, r.text body = r.json() assert body["is_markdown"] is True assert "dirigé" in body["html"] - # raw endpoint too - r = user_client.get("/api/file/home-normaluser/raw", params={"path": "Partage/share_directed.md"}) + # Fallback par nom (liens sans token) + r = user_client.get("/api/file/home-normaluser", params={"path": "Partage/share_directed.md"}) + assert r.status_code == 200, r.text + + # raw + download too + r = user_client.get("/api/file/home-normaluser/raw", params={"path": f"Partage/{token}/share_directed.md"}) assert r.status_code == 200 assert "dirigé" in r.json()["raw"] + # Restore fixture state for other tests + update_user("normaluser", {"vaults": ["TestVault"]}) + def test_shared_file_resolution_is_user_scoped(self, sessions): # home-admin/Partage/x.md demandé par normaluser → pas de mapping # (le home d'un autre user lui est interdit, 403 avant tout). @@ -197,3 +213,27 @@ class TestDirectedShareGate: }) r = user_client.get("/api/file/home-admin", params={"path": "Partage/share_directed.md"}) assert r.status_code == 403 + + def test_same_name_two_shares_token_disambiguates(self, sessions): + # Deux partages de fichiers homonymes reçus : le token sélectionne + # le bon, le fallback par nom prend le premier sans crash. + admin_client, user_client = sessions + import os as _os + vault = Path(_os.environ["VAULT_1_PATH"]) + (vault / "a.md").write_text("contenu alpha unique", encoding="utf-8") + (vault / "b.md").write_text("contenu beta unique", encoding="utf-8") + # subdirectory with same basename to force ambiguity + (vault / "d1").mkdir(exist_ok=True) + (vault / "d2").mkdir(exist_ok=True) + (vault / "d1" / "same.md").write_text("contenu GAMMA unique", encoding="utf-8") + (vault / "d2" / "same.md").write_text("contenu DELTA unique", encoding="utf-8") + r1 = admin_client.post("/api/share/TestVault", json={"path": "d1/same.md", "shared_with": ["normaluser"]}) + r2 = admin_client.post("/api/share/TestVault", json={"path": "d2/same.md", "shared_with": ["normaluser"]}) + t1, t2 = r1.json()["token"], r2.json()["token"] + + r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{t2}/same.md"}) + assert r.status_code == 200 + assert "DELTA" in r.json()["html"] + r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{t1}/same.md"}) + assert r.status_code == 200 + assert "GAMMA" in r.json()["html"]