fix: partage dirigé — dossier Partage virtuel + ouverture en onglet #196
CI / lint (push) Successful in 3m1s
CI / security (push) Failing after 1m58s
CI / test (push) Successful in 4m43s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 20m28s

- _resolve_shared_file: home-<user>/Partage/<f> -> fichier source (/api/file, raw, download)
- clic dossier Partage: dépliage sans navigation (fix Directory not found)
- fichiers reçus ouverts en onglet applicatif (arbre, recherche, dashboard)
- POST /api/share: path vide/null rejeté (400) — un share path:null cassait /api/shares
- +2 tests (13 total)
This commit is contained in:
2026-10-10 21:48:46 -04:00
parent fdc9f47a0d
commit de6bde1613
16 changed files with 142 additions and 29 deletions
+11 -1
View File
@@ -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.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.67.1**.
---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.67.1] — 2026-10-10
---
## [2.67.0] — 2026-10-10
### Ajouté
@@ -29,6 +33,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
couvre les partages dirigés reçus (résultats `home-<user>/Partage/…`,
clic → page de partage, jamais l'éditeur du vault d'autrui).
- Cache d'icônes partagé invalidé à la création/révocation.
- **Itération 3 :** correction « Directory not found: Partage » (le dossier
virtuel ne déclenche plus la navigation) ; un fichier reçu s'ouvre dans
l'application en onglet — arbre, recherche et dashboard — via résolution
serveur `home-<user>/Partage/<fichier>` vers le fichier source
(`/api/file`, raw, download) ; un `path` vide/null est rejeté (400) à la
création d'un partage.
---
+3 -3
View File
@@ -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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.67.1-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.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.67.1).
---
*Projet : ObsiGate | Version : 2.67.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.67.1 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.67.1-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.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.1).
---
*Project: ObsiGate | Version: 2.67.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.67.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.67.0
2.67.1
+38
View File
@@ -45,9 +45,32 @@ from backend.services.files import read_raw_file
from backend.services.mutations import file_revision
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory, get_vault_root
from backend.share import list_shares
logger = logging.getLogger("obsigate")
def _resolve_shared_file(vault_name: str, path: str, username: str | None):
"""#196 — resolve ``home-<user>/Partage/<file>`` to the real source file.
Returns ``(real_vault, real_path)`` when *path* is a received share
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.
"""
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]
for s in list_shares(user=username):
if s.get("created_by") == username:
continue
if (s.get("path") or "").split("/")[-1] == name:
return s["vault"], s["path"]
return None
# Map file extensions to highlight.js language hints
EXT_TO_LANG = {
".py": "python", ".js": "javascript", ".ts": "typescript",
@@ -104,6 +127,9 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
if shared:
vault_name, path = shared
if 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)
@@ -120,6 +146,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
if shared:
vault_name, path = shared
if 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)
@@ -305,8 +334,17 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
# #196 — fichier reçu par partage dirigé : home-<user>/Partage/<fichier>
# est résolu vers le fichier source (lecture seule, viewer standard).
# Résolu AVANT l'ACL vault : le dossier est virtuel, l'autorisation réelle
# est l'appartenance au partage (vérifiée dans le resolver).
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
if shared:
vault_name, path = shared
if 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:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
+3 -1
View File
@@ -88,7 +88,9 @@ async def api_share_create(
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
path = body.get("path", "")
path = body.get("path") or ""
if not path:
raise HTTPException(400, "Chemin de fichier requis")
expires = body.get("expires_in_hours")
# #196 — directed share: validate recipients before creating anything.
recipients = body.get("shared_with") or []
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.67.0"
version = "2.67.1"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.67.0"
version = "2.67.1"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.67.0",
"version": "2.67.1",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+8 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.67.0 | **Dernière mise à jour :** 2026-10-10
> **Version :** 2.67.1 | **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)**
@@ -57,6 +57,13 @@
jamais l'éditeur du vault d'autrui) ; cache front invalidé à la
création/révocation. +2 tests (recherche trouvée par le destinataire,
aucune fuite vers un non-destinataire).
- [x] **Itération 3 (2026-10-11)** : le dossier Partage ne déclenche plus la
navigation (dossier virtuel — corrige « Directory not found: Partage ») ;
un fichier reçu s'ouvre **dans l'application en onglet** (arbre,
recherche, dashboard) via la résolution serveur
`home-<user>/Partage/<fichier>` → fichier source dans `/api/file`
(+ raw, download) ; validation `path` non-nulle à la création d'un
partage. +2 tests.
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
+16
View File
@@ -105,3 +105,19 @@ Décisions :
+2 tests : `test_search_finds_received_share_for_recipient`,
`test_search_does_not_leak_share_to_other_user`.
## Itération 3 — ouverture en onglet (2026-10-11)
- **Résolution serveur** : `_resolve_shared_file()` (`backend/routers/files_read.py`)
mappe `home-<user>/Partage/<fichier>` vers le fichier source pour les routes
`/api/file`, `/api/file/raw`, `/api/file/download`. Résolu AVANT l'ACL vault
(le home virtuel peut ne pas être dans `user.vaults`) — l'autorisation réelle
est l'appartenance au partage, vérifiée dans le resolver (destinataire seul).
- **Front** : clic dossier Partage → simple dépliage (plus de `openNav`, fix
« Directory not found ») ; clic fichier reçu → `TabManager.openPreview/openPersistent`
(arbre, recherche, dashboard) — même parcours qu'un fichier ordinaire.
- `POST /api/share` : `path` vide/null → 400 (un share `path: None` cassait
`/api/shares` en 500).
+2 tests : ouverture applicative du fichier reçu (file + raw), résolution
scopée par utilisateur (home d'autrui → 403).
+4 -9
View File
@@ -68,8 +68,10 @@ const DashboardSharedWidget = {
: `<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>`;
const cardVault = received ? `home-${me}` : s.vault;
const cardPath = received ? `Partage/${(s.path || "").split("/").pop()}` : s.path;
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" data-vault="${escapeHtml(cardVault)}" data-path="${escapeHtml(cardPath)}" 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>
@@ -93,10 +95,7 @@ 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;
}
// #196 : onglet applicatif — /api/file résout Partage/ vers la source.
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
}));
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
@@ -106,10 +105,6 @@ 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 = ""; }
+3 -2
View File
@@ -911,9 +911,10 @@ export function renderSearchResults(data, query, tagFilter) {
});
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
}
// #196 : document reçu par partage → ouvre la page de partage (lecture seule).
// #196 : document reçu par partage → onglet applicatif (/api/file résout
// Partage/ vers la source, lecture seule). Fallback /s/ si résolution KO.
if (r.share_token) {
item.addEventListener("click", () => window.open(`/s/${r.share_token}`, "_blank"));
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
} else {
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
+18 -4
View File
@@ -370,12 +370,19 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
fItem.title = t("dashboard.shared_by", { user: s.created_by || "" });
fItem.addEventListener("click", () => {
scrollTreeItemIntoView(fItem, false);
window.open(`/s/${s.token}`, "_blank");
// #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()}`);
} else {
TabManager.openPreview(vaultName, `Partage/${(s.path || "").split("/").pop()}`);
}
closeMobileSidebar();
});
shareSub.appendChild(fItem);
});
shareDir.addEventListener("click", () => {
TabManager.openNav(vaultName, "Partage");
// #196 : dossier virtuel — pas de navigation (pas de répertoire physique).
const expanded = !shareSub.classList.contains("collapsed");
shareSub.classList.toggle("collapsed", expanded);
const chev = shareDir.querySelector("[data-lucide]");
@@ -733,12 +740,19 @@ async function loadDirectory(vaultName, dirPath, container) {
fItem.title = t("dashboard.shared_by", { user: s.created_by || "" });
fItem.addEventListener("click", () => {
scrollTreeItemIntoView(fItem, false);
window.open(`/s/${s.token}`, "_blank");
// #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()}`);
} else {
TabManager.openPreview(vaultName, `Partage/${(s.path || "").split("/").pop()}`);
}
closeMobileSidebar();
});
shareSub.appendChild(fItem);
});
shareDir.addEventListener("click", () => {
TabManager.openNav(vaultName, "Partage");
// #196 : dossier virtuel — pas de navigation (pas de répertoire physique).
const expanded = !shareSub.classList.contains("collapsed");
shareSub.classList.toggle("collapsed", expanded);
const chev = shareDir.querySelector("[data-lucide]");
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.67.0",
"version": "2.67.1",
"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": {
+30
View File
@@ -167,3 +167,33 @@ class TestDirectedShareGate:
resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"})
assert resp.status_code == 200
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-<user>/Partage/<fichier> vers la source.
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"})
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"})
assert r.status_code == 200
assert "dirigé" in r.json()["raw"]
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).
admin_client, user_client = sessions
path = _ensure_shared_file()
admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["normaluser"],
})
r = user_client.get("/api/file/home-admin", params={"path": "Partage/share_directed.md"})
assert r.status_code == 403