Compare commits

...
3 Commits
Author SHA1 Message Date
bruno 2be0b15bcf fix: partage dirigé — ACL court-circuitée après résolution Partage/ + chemin canonique au token #196
CI / lint (push) Successful in 3m0s
CI / security (push) Failing after 1m38s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 19m55s
- le partage EST l'autorisation: plus de 403 'Accès refusé à la vault' pour un
  destinataire sans accès au vault émetteur (api_file, raw, download)
- chemin virtuel canonique Partage/<token>/<nom> — désambiguïse les homonymes,
  fallback par nom conservé
- 'reçu' = partagé_avec contient l'utilisateur: un admin ne voit plus les
  partages des autres comme reçus (fix File not found: Partage/…)
- +3 tests (14 total)
2026-10-10 23:11:12 -04:00
bruno de6bde1613 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)
2026-10-10 21:48:46 -04:00
bruno fdc9f47a0d feat: visibilité & recherche des partages dirigés #196
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
- icône share-2 bleue sur les fichiers partagés dans l'arborescence
- dossier virtuel Partage/ à la racine du home du destinataire (lecture seule /s/{token})
- recherche plein texte couvre les documents reçus (merge search_vaults, share_token)
- cache front invalidé à la création/révocation ; +2 tests non-fuite
2026-10-10 21:23:00 -04:00
22 changed files with 542 additions and 39 deletions
+39 -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.66.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.67.2**.
---
@@ -14,6 +14,44 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.67.2] — 2026-10-10
---
## [2.67.1] — 2026-10-10
---
## [2.67.0] — 2026-10-10
### Ajouté
- **#196 — Partage dirigé (itération 2 : visibilité & recherche)**
- Icône « partagé » (share-2 bleu) dans l'arborescence sur tout fichier
porteur d'un partage actif (créé ou reçu).
- Dossier virtuel **Partage** à la racine du dossier personnel du
destinataire : documents reçus listés avec l'icône de partage et
« Partagé par X » en infobulle ; ouverture en lecture seule via la page
de partage.
- Les documents reçus sont **cherchables** : la recherche plein texte
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.
- **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/<token>/<nom>` pour
désambiguïser les fichiers homonymes).
---
## [2.66.0] — 2026-10-10
### Ajouté
+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.66.0-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.66.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.67.2).
---
*Projet : ObsiGate | Version : 2.66.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.67.2 | 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.66.0-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.66.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.2).
---
*Project: ObsiGate | Version: 2.66.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.67.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.66.0
2.67.2
+58 -3
View File
@@ -45,9 +45,46 @@ 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.
Chemin canonique : ``Partage/<token>/<nom>`` (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
# Chemin virtuel canonique : Partage/<token>/<nom> — 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] == rest:
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,7 +141,11 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if not check_vault_access(vault_name, current_user):
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
if shared:
# Partage dirigé = autorisation (cf. api_file).
vault_name, path = shared
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)
@@ -120,7 +161,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
if not check_vault_access(vault_name, current_user):
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
if shared:
# Partage dirigé = autorisation (cf. api_file).
vault_name, path = shared
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:
@@ -305,8 +350,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
if not check_vault_access(vault_name, current_user):
# #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:
# 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
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:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
+3
View File
@@ -84,9 +84,12 @@ async def api_search(
get_search_executor(),
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
# dossiers persos) — sinon un user voyait les notes des autres.
# #196 : les documents reçus par partage dirigé sont ajoutés aux
# résultats (vault virtuel "home-<user>/Partage").
partial(
search_vaults, q, vault, tag, limit, offset,
is_allowed=lambda v: check_vault_access(v, current_user),
username=current_user["username"],
),
)
+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 []
+3 -1
View File
@@ -611,13 +611,15 @@ class BookmarkToggleRequest(BaseModel):
class SearchResultItem(BaseModel):
"""A single search result."""
model_config = ConfigDict(extra="allow")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
tags: list[str] = Field(description="File tags")
score: int = Field(description="Relevance score")
snippet: str = Field(description="Content excerpt with highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
modified: str | None = Field(default=None, description="ISO 8601 modification timestamp")
share_token: str | None = Field(default=None, description="#196 token de partage dirigé (document reçu)")
class SearchResponse(BaseModel):
+71
View File
@@ -13,18 +13,28 @@ def search_vaults(
limit: int = 50,
offset: int = 0,
is_allowed: Callable[[str], bool] | None = None,
username: str | None = None,
) -> dict[str, Any]:
"""Full-text search with pagination, returned as the API response payload.
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
restricted vault set neither distorts ``total`` nor the returned page.
The tool layer filters on its own and leaves it as ``None``.
``username`` (#196) : les documents **reçus** par cet utilisateur via un
partage dirigé sont ajoutés aux résultats (lecture seule, sans indexer
les fichiers d'autrui — le contenu est lu à la volée et mis en cache
TF-IDF côté index, pas sur disque).
"""
from backend.search import search
all_results = search(q, vault_filter=vault, tag_filter=tag)
if is_allowed is not None:
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
if username is not None:
all_results = all_results + _shared_results(username, q, vault)
total = len(all_results)
page = all_results[offset: offset + limit]
return {
@@ -39,6 +49,67 @@ def search_vaults(
}
def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
"""Search results for documents shared TO *username* (#196).
Reads the shared file content on the fly (read-only, source vault on
disk) and returns hits shaped exactly like ordinary search results so
the frontend can open them via the normal share page.
"""
from pathlib import Path
from backend.indexer import get_vault_data
from backend.services.paths import resolve_safe_path
from backend.share import list_shares
out: list[dict[str, Any]] = []
if not q:
return out
q_lower = q.lower()
for s in list_shares(user=username):
if s.get("created_by") == username:
continue # own share — already indexed in its own vault
if vault not in ("all", f"home-{username}"):
continue
data = get_vault_data(s["vault"])
if not data:
continue
try:
fp = resolve_safe_path(Path(data["path"]), s["path"])
if not fp.exists() or fp.suffix.lower() != ".md":
continue
raw = fp.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
title = fp.stem
occurrences = raw.lower().count(q_lower)
if q_lower in title.lower():
occurrences += 1
if occurrences == 0:
continue
out.append({
"vault": f"home-{username}",
"path": f"Partage/{fp.name}",
"title": title,
"tags": [],
"score": min(occurrences, 10),
"snippet": _snippet(raw, q_lower),
"modified": None,
"share_token": s["token"],
})
return out
def _snippet(text: str, q_lower: str, width: int = 160) -> str:
"""Small excerpt around the first match (#196, shared-file search)."""
idx = text.lower().find(q_lower)
if idx < 0:
return text[:width]
start = max(0, idx - width // 2)
return "…" + text[start:start + width].replace("\n", " ") + "…"
def list_tags(vault: str | None = None) -> dict[str, int]:
"""Return tag → count, optionally restricted to a single vault."""
from backend.search import get_all_tags
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.66.0"
version = "2.67.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.66.0"
version = "2.67.2"
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.66.0",
"version": "2.67.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+25 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.66.0 | **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)**
@@ -48,6 +48,30 @@
- [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
- [x] **Itération 2 (2026-10-11)** : icône « partagé » (share-2 bleu) sur les
fichiers porteurs d'un partage actif dans l'arborescence ; dossier
virtuel **Partage** à la racine du home du destinataire (fichiers reçus,
ouverture lecture seule `/s/{token}`) ; les documents reçus sont
**cherchables** (merge dans `search_vaults`, résultats
`home-<user>/Partage/…` avec `share_token`, clic → page de partage —
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.
- [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/<token>/<nom>` (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 (`<racine>/Home/<user>`)
+64
View File
@@ -77,3 +77,67 @@ peut pas révoquer (403), créateur révoque, la révocation coupe l'accès.
rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe
si un vrai push est demandé).
- La date d'« expiration » reste globale au partage (pas par destinataire).
## Itération 2 — visibilité & recherche (2026-10-11)
| Élément | Où |
|---|---|
| Icône « partagé » (share-2 bleu) sur les fichiers de l'arborescence | `frontend/js/sidebar.js` (`loadSharedPaths` + `sharedFileIconSync`, appliqué dans `loadDirectory` et `incrementalLoadDirectory`) |
| Dossier virtuel **Partage** à la racine du home du destinataire | `frontend/js/sidebar.js` (injection `virtual-share` + rendu, clic fichier → `/s/{token}`) |
| Recherche des documents reçus | `backend/services/search.py::_shared_results` (merge dans `search_vaults` avant pagination), `backend/routers/search.py` (passe `username`), `SearchResultItem.share_token` |
| Clic sur un résultat reçu → page de partage | `frontend/js/search.js` |
| Invalidation du cache front | `invalidateSharedPaths()` exporté, appelé par `openShareDialog` après création/révocation |
Décisions :
- **Dossier virtuel, pas physique** : l'indexeur exclut les symlinks
(sécurité path traversal) et un dossier réel ferait double indexation.
La source de vérité reste `data/shares.json`.
- **Recherche à la volée** (`_shared_results`) : le contenu des documents
reçus est lu au moment de la requête, uniquement pour les destinataires —
jamais indexé dans le TF-IDF global, donc aucune fuite vers un
non-destinataire (testé). ponytail: lecture disque par requête, plafond
~quelques centaines de partages reçus par user ; indexer dans le TF-IDF
avec un champ "destinataires" si le volume devient réel.
- Icône déterminée côté client via `/api/shares` (déjà scopé #196) : la
liste des partages actifs "créés OU reçus" suffit — le créateur voit
l'icône sur son fichier source, le destinataire voit le dossier Partage.
+2 tests : `test_search_finds_received_share_for_recipient`,
`test_search_does_not_leak_share_to_other_user`.
## 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).
## 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/<token>/<nom>`** : 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.
+3 -1
View File
@@ -2,7 +2,7 @@
import { api, AuthManager, initMfaSettings } from './auth.js';
import { state } from './state.js';
import { el, icon, openFile, filterSavedSearches } from './viewer.js';
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState, invalidateSharedPaths } from './sidebar.js';
import { escapeHtml, safeCreateIcons } from './utils.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, setLocale, getLocale } from './i18n.js';
@@ -1691,6 +1691,7 @@ export async function openShareDialog(vault, path) {
div.querySelector(".share-revoke-btn").addEventListener("click", async () => {
try {
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
invalidateSharedPaths();
showToast(t("config.share_revoked"), "success");
existingShare = null;
renderContent();
@@ -1744,6 +1745,7 @@ export async function openShareDialog(vault, path) {
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null, shared_with: recipients }),
});
existingShare = share;
invalidateSharedPaths();
renderContent();
showToast(t("config.link_created"), "success");
} catch (err) { showToast("Erreur: " + err.message, "error"); }
+9 -10
View File
@@ -62,14 +62,20 @@ 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
? `<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>`;
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 +99,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 +109,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 = ""; }
+8 -2
View File
@@ -911,8 +911,14 @@ export function renderSearchResults(data, query, tagFilter) {
});
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
}
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
// #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", () => 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); });
}
container.appendChild(item);
});
area.appendChild(container);
+146 -8
View File
@@ -1,6 +1,6 @@
import { state } from './state.js';
import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js';
import { api } from './auth.js';
import { api, AuthManager } from './auth.js';
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
@@ -8,6 +8,50 @@ import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
import { filterChatMessages as _filterChat } from './filechat.js';
// ---------------------------------------------------------------------------
// Shared-file decoration (#196) : fichier porteur d'un partage actif →
// icône « share » dans l'arborescence ; dossier virtuel « Partage » dans le
// home du destinataire.
// ---------------------------------------------------------------------------
let _sharedPaths = null; // Promise<Set<"vault::path">> — parts actives (créées ou reçues)
function loadSharedPaths() {
if (!_sharedPaths) {
_sharedPaths = api("/api/shares")
.then(shares => new Set(shares.map(s => `${s.vault}::${s.path}`)))
.catch(() => new Set());
}
return _sharedPaths;
}
function invalidateSharedPaths() { _sharedPaths = null; }
/** Partages dirigés reçus par l'utilisateur courant (#196). */
async function _loadReceivedShares() {
const me = (AuthManager.getUser() || {}).username;
if (!me) return [];
try {
const shares = await api("/api/shares");
// 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 []; }
}
/** Icône « partagé » (#196) — share-2 bleu. */
function _sharedIcon(size = 16) {
const i = icon("share-2", size);
i.style.color = "var(--accent-blue, #3b82f6)";
return i;
}
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
return icon(getFileIcon(itemName), 16);
}
// ---------------------------------------------------------------------------
// Vault context switching
// ---------------------------------------------------------------------------
@@ -306,7 +350,53 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
const fragment = document.createDocumentFragment();
// #196 : icône « partagé » — résout le cache avant le rendu.
const sharedPaths = await loadSharedPaths();
// #196 : dossier virtuel « Partage » à la racine du home du destinataire.
const items = Array.isArray(data.items) ? data.items.slice() : [];
const me = (AuthManager.getUser() || {}).username;
if (me && vaultName === `home-${me}` && !dirPath) {
const myShares = await _loadReceivedShares();
if (myShares.length) items.unshift({ type: "virtual-share", shares: myShares });
}
data.items = items;
data.items.forEach((item) => {
// #196 : dossier virtuel « Partage » (fichiers reçus via partage dirigé).
if (item.type === "virtual-share") {
const shareDir = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Partage", "data-type": "directory" }, [icon("chevron-right", 14), _sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("sidebar.shared_folder"))]), smallBadge(item.shares.length)]);
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 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/<token>/<nom> vers le fichier source (lecture seule).
if (window.innerWidth <= 768) {
TabManager.openPersistent(vaultName, virtualPath);
} else {
TabManager.openPreview(vaultName, virtualPath);
}
closeMobileSidebar();
});
shareSub.appendChild(fItem);
});
shareDir.addEventListener("click", () => {
// #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]");
if (chev) chev.setAttribute("data-lucide", expanded ? "chevron-right" : "chevron-down");
safeCreateIcons();
});
fragment.appendChild(shareDir);
fragment.appendChild(shareSub);
return;
}
if (!shouldDisplayPath(item.path, vaultName)) return;
const existing = existingItems[item.path];
@@ -374,11 +464,11 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
});
} else {
const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [sharedFileIconSync(sharedPaths, item.name, vaultName, item.path), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
if (window.innerWidth <= 768) {
@@ -629,9 +719,55 @@ async function loadDirectory(vaultName, dirPath, container) {
}
container.innerHTML = "";
// #196 : icône « partagé » — résout le cache avant le rendu synchrone.
const sharedPaths = await loadSharedPaths();
// #196 : dossier virtuel « Partage » à la racine du home du destinataire.
const items = Array.isArray(data.items) ? data.items.slice() : [];
const me = (AuthManager.getUser() || {}).username;
if (me && vaultName === `home-${me}` && !dirPath) {
const myShares = await _loadReceivedShares();
if (myShares.length) items.unshift({ type: "virtual-share", shares: myShares });
}
data.items = items;
const fragment = document.createDocumentFragment();
data.items.forEach((item) => {
// #196 : dossier virtuel « Partage » (fichiers reçus via partage dirigé).
if (item.type === "virtual-share") {
const shareDir = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Partage", "data-type": "directory" }, [icon("chevron-right", 14), _sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("sidebar.shared_folder"))]), smallBadge(item.shares.length)]);
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 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/<token>/<nom> vers le fichier source (lecture seule).
if (window.innerWidth <= 768) {
TabManager.openPersistent(vaultName, virtualPath);
} else {
TabManager.openPreview(vaultName, virtualPath);
}
closeMobileSidebar();
});
shareSub.appendChild(fItem);
});
shareDir.addEventListener("click", () => {
// #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]");
if (chev) chev.setAttribute("data-lucide", expanded ? "chevron-right" : "chevron-down");
safeCreateIcons();
});
fragment.appendChild(shareDir);
fragment.appendChild(shareSub);
return;
}
// Apply client-side filtering for hidden files
if (!shouldDisplayPath(item.path, vaultName)) {
return; // Skip this item
@@ -673,7 +809,8 @@ async function loadDirectory(vaultName, dirPath, container) {
} else {
const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
const isShared = sharedPaths.has(`${vaultName}::${item.path}`);
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [isShared ? _sharedIcon() : icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
fileItem.addEventListener("click", () => {
@@ -705,6 +842,7 @@ async function loadDirectory(vaultName, dirPath, container) {
safeCreateIcons();
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Sidebar filter
// ---------------------------------------------------------------------------
@@ -1133,4 +1271,4 @@ function searchByTag(tag) {
}
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, focusPathInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter };
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, focusPathInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter, invalidateSharedPaths };
+1
View File
@@ -1721,6 +1721,7 @@
"shortcut.search_in_doc": "Search in document",
"shortcut.tip_preview": "Hover a document with **Ctrl** for a quick preview. Click **Forge** in the toolbar for the advanced editor.",
"sidebar.all_vaults": "All vaults",
"sidebar.shared_folder": "Shared",
"sidebar.case_sensitive": "Case sensitive",
"sidebar.clear_filter": "Clear",
"sidebar.expand_collapse": "Expand/Collapse",
+1
View File
@@ -1721,6 +1721,7 @@
"shortcut.search_in_doc": "Rechercher dans le document",
"shortcut.tip_preview": "Survolez un document avec **Ctrl** pour un aperçu rapide. Cliquez sur **Forge** dans la barre d'outils pour l'éditeur avancé.",
"sidebar.all_vaults": "Tous les vaults",
"sidebar.shared_folder": "Partage",
"sidebar.case_sensitive": "Respecter la casse",
"sidebar.clear_filter": "Effacer",
"sidebar.expand_collapse": "Développer/Réduire",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.66.0",
"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": {
+97
View File
@@ -140,3 +140,100 @@ class TestDirectedShareGate:
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
def test_search_finds_received_share_for_recipient(self, sessions):
# #196 : le contenu du document reçu est cherchable par le destinataire.
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"]
resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"})
assert resp.status_code == 200
hits = [r for r in resp.json()["results"] if r.get("share_token") == token]
assert hits, "le destinataire doit trouver le document reçu via la recherche"
assert hits[0]["vault"] == "home-normaluser"
assert hits[0]["path"].startswith("Partage/")
def test_search_does_not_leak_share_to_other_user(self, sessions):
# Un utilisateur non destinataire ne voit JAMAIS le contenu partagé
# dans ses résultats (le vault source lui est interdit).
admin_client, user_client = sessions
path = _ensure_shared_file()
admin_client.post("/api/share/TestVault", json={
"path": path, "shared_with": ["admin"], # dirigé à admin seul
})
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/<token>/<f> 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
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"]
# 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).
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
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"]