Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de6bde1613 | ||
|
|
fdc9f47a0d | ||
|
|
49c715199d | ||
|
|
fd192df036 | ||
|
|
8662d23ec8 |
+89
-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).
|
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
|
> **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.0**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.67.1**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,76 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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é
|
||||||
|
|
||||||
|
- **BUG-111 — Admin : icône de fermeture + navigation du tableau de bord**
|
||||||
|
- La modale « Administration — Utilisateurs » ouverte depuis la page `/admin` n'affichait pas l'icône du bouton fermer (lucide absent de `admin.html`) — même version du script UMD que `index.html` ajoutée.
|
||||||
|
- Le tableau de bord admin affiche désormais **une seule section** à la fois (état initial : hash de l'URL ou « Statistiques temps réel ») au lieu des 4 sections empilées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.65.1] — 2026-10-10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.65.0] — 2026-10-10
|
## [2.65.0] — 2026-10-10
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
@@ -31,6 +101,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
- Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`,
|
- Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`,
|
||||||
gemme violette inchangée ailleurs).
|
gemme violette inchangée ailleurs).
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **#194 — Un dossier perso n'est plus visible par les autres comptes**
|
||||||
|
- `check_vault_access` (`backend/auth/middleware.py`) refuse désormais tout
|
||||||
|
vault `home-*` qui n'est pas **explicitement** octroyé au compte : le
|
||||||
|
`vaults: ["*"]` de l'admin ne couvre plus les dossiers persos (avant,
|
||||||
|
l'admin voyait `home-<user>` de chaque utilisateur dans sa barre latérale).
|
||||||
|
- Les filtres `« * »` écrits en dur dans les routes ont été remplacés par
|
||||||
|
cet appel unique — dashboard, conflits, liens retour, favoris,
|
||||||
|
abonnements push : partout la même règle.
|
||||||
|
- `/api/search` filtre les résultats par vault accessible **avant**
|
||||||
|
pagination (`search_vaults(is_allowed=…)`), sinon les notes personnelles
|
||||||
|
d'un autre pouvait apparaître dans les résultats.
|
||||||
|
- `_grant` octroie désormais le home aussi aux comptes `« * »` : l'admin
|
||||||
|
voit **son** `home-admin`, réparé au démarrage pour les comptes existants.
|
||||||
|
- Tests : `test_user_home.py` +2 (`« * »` sans homes, filtrage de la
|
||||||
|
recherche), assertion API inversée dans `test_auth_api.py`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [2.64.0] — 2026-10-10
|
## [2.64.0] — 2026-10-10
|
||||||
|
|||||||
+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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.65.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.65.0 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.67.1 | 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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.65.0).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.65.0 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.67.1 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ def require_admin(current_user=Depends(require_auth)):
|
|||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def is_home_vault(vault_name: str) -> bool:
|
||||||
|
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||||
|
return vault_name.startswith("home-")
|
||||||
|
|
||||||
|
|
||||||
def check_vault_access(vault_name: str, user: dict) -> bool:
|
def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||||
"""Check if a user has access to a specific vault.
|
"""Check if a user has access to a specific vault.
|
||||||
|
|
||||||
@@ -149,8 +154,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool:
|
|||||||
- vaults == ["*"] → full access (admin default)
|
- vaults == ["*"] → full access (admin default)
|
||||||
- vault_name in vaults → access granted
|
- vault_name in vaults → access granted
|
||||||
- otherwise → denied
|
- otherwise → denied
|
||||||
|
|
||||||
|
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
|
||||||
|
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
|
||||||
"""
|
"""
|
||||||
vaults = user.get("_token_vaults") or user.get("vaults", [])
|
vaults = user.get("_token_vaults") or user.get("vaults", [])
|
||||||
|
if is_home_vault(vault_name):
|
||||||
|
return vault_name in vaults
|
||||||
if "*" in vaults:
|
if "*" in vaults:
|
||||||
return True
|
return True
|
||||||
return vault_name in vaults
|
return vault_name in vaults
|
||||||
|
|||||||
+4
-4
@@ -13,7 +13,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from backend.auth.middleware import require_auth
|
from backend.auth.middleware import check_vault_access, require_auth
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.push")
|
logger = logging.getLogger("obsigate.push")
|
||||||
|
|
||||||
@@ -167,9 +167,9 @@ async def subscribe_push(
|
|||||||
"""Subscribe to push notifications for a vault."""
|
"""Subscribe to push notifications for a vault."""
|
||||||
username = current_user.get("username", "unknown")
|
username = current_user.get("username", "unknown")
|
||||||
|
|
||||||
# Check if user has access to this vault
|
# Check if user has access to this vault (#194 : "*" n'inclut pas les
|
||||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
# dossiers persos, check_vault_access est la source unique).
|
||||||
if "*" not in user_vaults and request.vault not in user_vaults:
|
if not check_vault_access(request.vault, current_user):
|
||||||
raise HTTPException(status_code=403, detail="No access to this vault")
|
raise HTTPException(status_code=403, detail="No access to this vault")
|
||||||
|
|
||||||
# Check if subscription already exists
|
# Check if subscription already exists
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
|
|
||||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||||
from backend.auth.middleware import require_admin, require_auth
|
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||||
from backend.indexer import index
|
from backend.indexer import index
|
||||||
from backend.media_types import IMAGE_EXTENSIONS
|
from backend.media_types import IMAGE_EXTENSIONS
|
||||||
from backend.schemas import (
|
from backend.schemas import (
|
||||||
@@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
|||||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||||
async def api_dashboard(current_user=Depends(require_auth)):
|
async def api_dashboard(current_user=Depends(require_auth)):
|
||||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
|
||||||
vault_stats = []
|
vault_stats = []
|
||||||
total_files = 0
|
total_files = 0
|
||||||
total_tags = set()
|
total_tags = set()
|
||||||
total_size = 0
|
total_size = 0
|
||||||
total_images = 0
|
total_images = 0
|
||||||
for vname, vdata in index.items():
|
for vname, vdata in index.items():
|
||||||
if "*" not in user_vaults and vname not in user_vaults:
|
if not check_vault_access(vname, current_user):
|
||||||
continue
|
continue
|
||||||
files = vdata.get("files", [])
|
files = vdata.get("files", [])
|
||||||
fc = len(files)
|
fc = len(files)
|
||||||
|
|||||||
@@ -31,10 +31,9 @@ router = APIRouter(tags=["conflicts"])
|
|||||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||||
async def api_conflicts(current_user=Depends(require_auth)):
|
async def api_conflicts(current_user=Depends(require_auth)):
|
||||||
"""List sync-conflict files across accessible vaults."""
|
"""List sync-conflict files across accessible vaults."""
|
||||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
|
||||||
all_conflicts = get_conflicts()
|
all_conflicts = get_conflicts()
|
||||||
if "*" not in user_vaults:
|
# #194 : filtrage via check_vault_access ("*" n'inclut pas les homes).
|
||||||
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
|
all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)]
|
||||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,32 @@ from backend.services.files import read_raw_file
|
|||||||
from backend.services.mutations import file_revision
|
from backend.services.mutations import file_revision
|
||||||
from backend.services.paths import resolve_safe_path
|
from backend.services.paths import resolve_safe_path
|
||||||
from backend.services.vaults import browse_directory, get_vault_root
|
from backend.services.vaults import browse_directory, get_vault_root
|
||||||
|
from backend.share import list_shares
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate")
|
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
|
# Map file extensions to highlight.js language hints
|
||||||
EXT_TO_LANG = {
|
EXT_TO_LANG = {
|
||||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||||
@@ -104,6 +127,9 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
|||||||
Returns:
|
Returns:
|
||||||
``FileRawResponse`` with vault, path, and raw text content.
|
``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):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
return read_raw_file(vault_name, path)
|
return read_raw_file(vault_name, path)
|
||||||
@@ -120,6 +146,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
|||||||
Returns:
|
Returns:
|
||||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
``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):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
vault_data = get_vault_data(vault_name)
|
vault_data = get_vault_data(vault_name)
|
||||||
@@ -166,12 +195,11 @@ async def api_file_backlinks(
|
|||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
|
||||||
backlinks = get_backlinks(vault_name, path)
|
backlinks = get_backlinks(vault_name, path)
|
||||||
|
|
||||||
# Filter by user-accessible vaults
|
# Filter by user-accessible vaults (#194 : check_vault_access, "*" sans
|
||||||
if "*" not in user_vaults:
|
# les dossiers persos).
|
||||||
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
|
backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vault": vault_name,
|
"vault": vault_name,
|
||||||
@@ -306,8 +334,17 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
Returns:
|
Returns:
|
||||||
``FileContentResponse`` with HTML, metadata, and tags.
|
``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):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
vault_data = get_vault_data(vault_name)
|
vault_data = get_vault_data(vault_name)
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
|
|||||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||||
username = current_user.get("username")
|
username = current_user.get("username")
|
||||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
|
||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
return {"files": []}
|
return {"files": []}
|
||||||
@@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re
|
|||||||
files_resp = []
|
files_resp = []
|
||||||
for item in history:
|
for item in history:
|
||||||
v_name = item["vault"]
|
v_name = item["vault"]
|
||||||
if "*" not in user_vaults and v_name not in user_vaults:
|
# #194 : check_vault_access — "*" n'inclut pas les dossiers persos.
|
||||||
|
if not check_vault_access(v_name, current_user):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Find in index to get metadata
|
# Find in index to get metadata
|
||||||
|
|||||||
@@ -82,7 +82,15 @@ async def api_search(
|
|||||||
# paginate in the shared service so routes and tools share the same logic.
|
# paginate in the shared service so routes and tools share the same logic.
|
||||||
return await loop.run_in_executor(
|
return await loop.run_in_executor(
|
||||||
get_search_executor(),
|
get_search_executor(),
|
||||||
partial(search_vaults, q, vault, tag, limit, offset),
|
# #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"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+54
-15
@@ -24,7 +24,8 @@ import frontmatter
|
|||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
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.indexer import get_vault_data, parse_markdown_file, update_single_file
|
||||||
from backend.render import _render_markdown
|
from backend.render import _render_markdown
|
||||||
from backend.schemas import ShareModel, StatusResponse
|
from backend.schemas import ShareModel, StatusResponse
|
||||||
@@ -53,6 +54,27 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
|||||||
router = APIRouter(tags=["sharing"])
|
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)
|
@router.post("/api/share/{vault_name}", response_model=ShareModel)
|
||||||
async def api_share_create(
|
async def api_share_create(
|
||||||
vault_name: str,
|
vault_name: str,
|
||||||
@@ -66,9 +88,18 @@ async def api_share_create(
|
|||||||
"""
|
"""
|
||||||
if not check_vault_access(vault_name, current_user):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
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")
|
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']}"
|
share["url"] = f"/s/{share['token']}"
|
||||||
|
|
||||||
# Set publish: true in the file's frontmatter
|
# Set publish: true in the file's frontmatter
|
||||||
@@ -94,8 +125,13 @@ async def api_share_create(
|
|||||||
|
|
||||||
@router.get("/api/shares", response_model=list[ShareModel])
|
@router.get("/api/shares", response_model=list[ShareModel])
|
||||||
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||||
"""List all shares (optionally filtered by vault)."""
|
"""List shares (optionally filtered by vault).
|
||||||
shares = list_shares(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:
|
for s in shares:
|
||||||
s["url"] = f"/s/{s['token']}"
|
s["url"] = f"/s/{s['token']}"
|
||||||
return shares
|
return shares
|
||||||
@@ -103,6 +139,12 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends(
|
|||||||
|
|
||||||
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
|
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
|
||||||
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
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):
|
if not revoke_share(share_id):
|
||||||
raise HTTPException(404, "Share not found")
|
raise HTTPException(404, "Share not found")
|
||||||
return {"status": "revoked"}
|
return {"status": "revoked"}
|
||||||
@@ -113,13 +155,12 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
|||||||
response_class=Response,
|
response_class=Response,
|
||||||
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
|
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."""
|
"""Download shared document as real PDF via WeasyPrint."""
|
||||||
if generate_pdf is None:
|
if generate_pdf is None:
|
||||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||||
share = get_share_by_token(token)
|
share = get_share_by_token(token)
|
||||||
if not share:
|
share = _gate_share(share, current_user)
|
||||||
raise HTTPException(404, "Share not found or expired")
|
|
||||||
vault_data = get_vault_data(share["vault"])
|
vault_data = get_vault_data(share["vault"])
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(404, "Vault not found")
|
raise HTTPException(404, "Vault not found")
|
||||||
@@ -147,11 +188,10 @@ async def public_share_pdf_download(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/s/{token}/raw", response_class=FileResponse)
|
@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."""
|
"""Download the raw (original) shared document."""
|
||||||
share = get_share_by_token(token)
|
share = get_share_by_token(token)
|
||||||
if not share:
|
share = _gate_share(share, current_user)
|
||||||
raise HTTPException(404, "Share not found or expired")
|
|
||||||
vault_data = get_vault_data(share["vault"])
|
vault_data = get_vault_data(share["vault"])
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(404, "Vault not found")
|
raise HTTPException(404, "Vault not found")
|
||||||
@@ -164,13 +204,12 @@ async def public_share_raw(token: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||||
async def public_share_view(request: Request, token: str):
|
async def public_share_view(request: Request, token: str, current_user=Depends(get_current_user)):
|
||||||
"""Public share view — no authentication required."""
|
"""Public share view — no authentication required (unless directed #196)."""
|
||||||
from backend.csp import inject_csp_nonce
|
from backend.csp import inject_csp_nonce
|
||||||
|
|
||||||
share = get_share_by_token(token)
|
share = get_share_by_token(token)
|
||||||
if not share:
|
share = _gate_share(share, current_user)
|
||||||
raise HTTPException(404, "Share not found or expired")
|
|
||||||
vault_data = get_vault_data(share["vault"])
|
vault_data = get_vault_data(share["vault"])
|
||||||
if not vault_data:
|
if not vault_data:
|
||||||
raise HTTPException(404, "Vault not found")
|
raise HTTPException(404, "Vault not found")
|
||||||
|
|||||||
+4
-1
@@ -611,13 +611,15 @@ class BookmarkToggleRequest(BaseModel):
|
|||||||
class SearchResultItem(BaseModel):
|
class SearchResultItem(BaseModel):
|
||||||
"""A single search result."""
|
"""A single search result."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
vault: str = Field(description="Vault name")
|
vault: str = Field(description="Vault name")
|
||||||
path: str = Field(description="Relative file path")
|
path: str = Field(description="Relative file path")
|
||||||
title: str = Field(description="File title")
|
title: str = Field(description="File title")
|
||||||
tags: list[str] = Field(description="File tags")
|
tags: list[str] = Field(description="File tags")
|
||||||
score: int = Field(description="Relevance score")
|
score: int = Field(description="Relevance score")
|
||||||
snippet: str = Field(description="Content excerpt with highlights")
|
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):
|
class SearchResponse(BaseModel):
|
||||||
@@ -1056,6 +1058,7 @@ class ShareModel(BaseModel):
|
|||||||
expires_at: str | None = None
|
expires_at: str | None = None
|
||||||
access_count: int = 0
|
access_count: int = 0
|
||||||
last_accessed: str | None = None
|
last_accessed: str | None = None
|
||||||
|
shared_with: list[str] = Field(default_factory=list, description="#196 destinataires (partage dirigé)")
|
||||||
|
|
||||||
|
|
||||||
class ConflictEntry(BaseModel):
|
class ConflictEntry(BaseModel):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.auth.middleware import is_home_vault
|
||||||
from backend.history import get_recent_opened, is_bookmarked
|
from backend.history import get_recent_opened, is_bookmarked
|
||||||
from backend.indexer import find_file_in_index, index
|
from backend.indexer import find_file_in_index, index
|
||||||
|
|
||||||
@@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
||||||
|
# #194 : un dossier perso ne bénéficie jamais de "*" (même règle que
|
||||||
|
# backend.auth.middleware.check_vault_access).
|
||||||
|
if is_home_vault(vault):
|
||||||
|
return vault in user_vaults
|
||||||
return "*" in user_vaults or vault in user_vaults
|
return "*" in user_vaults or vault in user_vaults
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@@ -11,15 +12,29 @@ def search_vaults(
|
|||||||
tag: str | None = None,
|
tag: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
|
is_allowed: Callable[[str], bool] | None = None,
|
||||||
|
username: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Full-text search with pagination, returned as the API response payload.
|
"""Full-text search with pagination, returned as the API response payload.
|
||||||
|
|
||||||
No permission filtering is applied here: callers that need it (the tool
|
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||||
layer) filter the ``results`` list themselves.
|
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
|
from backend.search import search
|
||||||
|
|
||||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
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)
|
total = len(all_results)
|
||||||
page = all_results[offset: offset + limit]
|
page = all_results[offset: offset + limit]
|
||||||
return {
|
return {
|
||||||
@@ -34,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]:
|
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||||
"""Return tag → count, optionally restricted to a single vault."""
|
"""Return tag → count, optionally restricted to a single vault."""
|
||||||
from backend.search import get_all_tags
|
from backend.search import get_all_tags
|
||||||
|
|||||||
+17
-3
@@ -44,8 +44,14 @@ def create_share(
|
|||||||
path: str,
|
path: str,
|
||||||
created_by: str,
|
created_by: str,
|
||||||
expires_in_hours: int | None = None,
|
expires_in_hours: int | None = None,
|
||||||
|
shared_with: list[str] | None = None,
|
||||||
) -> dict:
|
) -> 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:
|
with _lock:
|
||||||
data = _read()
|
data = _read()
|
||||||
token = secrets.token_hex(32) # 64-char hex token
|
token = secrets.token_hex(32) # 64-char hex token
|
||||||
@@ -64,6 +70,7 @@ def create_share(
|
|||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"access_count": 0,
|
"access_count": 0,
|
||||||
"last_accessed": None,
|
"last_accessed": None,
|
||||||
|
"shared_with": list(shared_with) if shared_with else [],
|
||||||
}
|
}
|
||||||
data["shares"][token] = share
|
data["shares"][token] = share
|
||||||
_write(data)
|
_write(data)
|
||||||
@@ -107,10 +114,17 @@ def revoke_share(share_id: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def list_shares(vault_filter: str | None = None) -> list:
|
def list_shares(vault_filter: str | None = None, user: str | None = None) -> list:
|
||||||
"""List all shares, optionally filtered by vault."""
|
"""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()
|
data = _read()
|
||||||
shares = list(data["shares"].values())
|
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:
|
if vault_filter:
|
||||||
shares = [s for s in shares if s["vault"] == vault_filter]
|
shares = [s for s in shares if s["vault"] == vault_filter]
|
||||||
# Most recent first
|
# Most recent first
|
||||||
|
|||||||
@@ -112,14 +112,18 @@ async def release_user_home(username: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _grant(username: str, vault_name: str) -> None:
|
def _grant(username: str, vault_name: str) -> None:
|
||||||
"""Ajoute le vault à ``user.vaults`` si l'utilisateur n'a pas déjà ``*``."""
|
"""Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194).
|
||||||
|
|
||||||
|
L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne
|
||||||
|
couvre jamais un dossier perso (voir ``check_vault_access``).
|
||||||
|
"""
|
||||||
from backend.auth.user_store import get_user, update_user
|
from backend.auth.user_store import get_user, update_user
|
||||||
|
|
||||||
user = get_user(username)
|
user = get_user(username)
|
||||||
if not user:
|
if not user:
|
||||||
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
|
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
|
||||||
vaults = user.get("vaults") or []
|
vaults = user.get("vaults") or []
|
||||||
if "*" in vaults or vault_name in vaults:
|
if vault_name in vaults:
|
||||||
return
|
return
|
||||||
update_user(username, {"vaults": [*vaults, vault_name]})
|
update_user(username, {"vaults": [*vaults, vault_name]})
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.65.0"
|
version = "2.67.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.65.0"
|
version = "2.67.1"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.65.0",
|
"version": "2.67.1",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
|
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
|
||||||
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
|
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
|
||||||
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
|
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
|
||||||
|
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
|
||||||
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
||||||
| | | | | | | | | | |
|
| | | | | | | | | | |
|
||||||
### TODOs techniques (améliorations / nouvelles tâches)
|
### TODOs techniques (améliorations / nouvelles tâches)
|
||||||
@@ -335,6 +336,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
|
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
|
||||||
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
|
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
|
||||||
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
|
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-10 | BUG-111 | Correction UI admin | `frontend/admin.html`, `frontend/js/admin.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-111** : (1) la modale « Administration — Utilisateurs » ouverte depuis `/admin` n'affichait pas l'icône du bouton fermer — `safeCreateIcons()` retourne immédiatement car le global `lucide` n'existe pas sur `admin.html` (script UMD absent — seul index.html le chargeait) ; ajout du même `<script defer>` lucide@0.344.0. (2) Le tableau de bord admin affichait les 4 sections d'un coup (scrollspy) — `_wireSectionNav` masque désormais toutes les sections sauf celle cliquée (état initial : hash de l'URL ou section Stats). Vérifié : 1705 passed, suites frontend vertes, instance docker locale 2020. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
|
||||||
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
|
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
|
||||||
@@ -343,6 +345,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
|
|
||||||
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
|
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
|
||||||
|
|
||||||
|
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres `« * »` écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes `« * »` → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📜 Historique des bugs résolus
|
## 📜 Historique des bugs résolus
|
||||||
|
|||||||
+39
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.65.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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -30,6 +30,41 @@
|
|||||||
|
|
||||||
## 🔵 En cours
|
## 🔵 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
|
||||||
|
- [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.
|
||||||
|
|
||||||
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
||||||
|
|
||||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
|
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
|
||||||
@@ -50,6 +85,9 @@
|
|||||||
`docker-compose.test-win.yml` → `test_home:/vaults/Home`)
|
`docker-compose.test-win.yml` → `test_home:/vaults/Home`)
|
||||||
- [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison
|
- [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison
|
||||||
lucide (`getVaultIcon`), vérifié live sur l'instance 2020
|
lucide (`getVaultIcon`), vérifié live sur l'instance 2020
|
||||||
|
- [x] Isolation : « * » (admin) ne couvre **plus** les dossiers persos —
|
||||||
|
`check_vault_access` + filtres des routes + `/api/search` (avant
|
||||||
|
pagination) ; octroi du home aussi aux comptes « * »
|
||||||
- [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs
|
- [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# #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).
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -81,9 +81,24 @@ suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home` montée `/vaults/Home`.
|
|||||||
- **Test local** : `docker-compose.test-win.yml` monte
|
- **Test local** : `docker-compose.test-win.yml` monte
|
||||||
`test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home`
|
`test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home`
|
||||||
(`test_home/` est dans `.gitignore`).
|
(`test_home/` est dans `.gitignore`).
|
||||||
- **Vérifié live** (instance 2020, Playwright) : ordre
|
- **Vérifié live** (instance 2020, Playwright) : ordre `home-admin, TestDir,
|
||||||
`home-admin, home-bruno, TestDir, TestVault`, `svg.lucide-home` rendu,
|
TestVault` (l'admin ne voit plus le home des autres), `svg.lucide-home`
|
||||||
placeholder `<i>` remplacé.
|
rendu, placeholder `<i>` remplacé.
|
||||||
|
|
||||||
|
## Isolation — « * » ne couvre pas les dossiers persos
|
||||||
|
|
||||||
|
- **Racine** : `check_vault_access` (`backend/auth/middleware.py`) renvoie
|
||||||
|
``vault_name in vaults`` pour tout vault ``home-*``, quel que soit le ``*``.
|
||||||
|
Toutes les routes (89 usages) passent par cette fonction.
|
||||||
|
- **Filtres « * » en dur remplacés** par cet appel : dashboard
|
||||||
|
(`routers/config.py`), conflits, liens retour, favoris, abonnements push.
|
||||||
|
- **Recherche** : `services/search.search_vaults(is_allowed=…)` filtre les
|
||||||
|
bruts **avant** pagination — ``total`` et page restent justes.
|
||||||
|
- **Octroi** : `_grant` n'écarte plus les comptes ``*`` → l'admin reçoit son
|
||||||
|
propre ``home-admin`` (réparé au démarrage pour les comptes existants).
|
||||||
|
- **Reste ouvert** (fuite pré-existante, hors #194) : `/api/search/advanced`,
|
||||||
|
`/api/tags`, `/api/tags/suggest`, `/api/tree-search` et `/api/suggest` ne
|
||||||
|
filtrent pas par vault accessible — à traiter dans une tâche dédiée.
|
||||||
|
|
||||||
## Impact utilisateur
|
## Impact utilisateur
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
||||||
|
|
||||||
|
<!-- Lucide : requis par safeCreateIcons() (icônes des modales, ex. bouton fermer) -->
|
||||||
|
<script defer src="https://unpkg.com/[email protected]/dist/umd/lucide.min.js"></script>
|
||||||
|
|
||||||
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
|
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
+19
-38
@@ -394,54 +394,35 @@ function _wireAuditFilters() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section nav (scrollspy) ───────────────────────────────────────────
|
// ── Section nav (une seule section visible à la fois) ──────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire the sticky section nav: smooth-scroll on click + highlight the section
|
* Wire the sticky section nav: clicking a link shows ONLY that section
|
||||||
* currently in view (IntersectionObserver). Falls back to default anchor jump
|
* (the others are hidden). The first section (Stats) is shown on load.
|
||||||
* if the observer is unavailable.
|
|
||||||
*/
|
*/
|
||||||
function _wireSectionNav() {
|
function _wireSectionNav() {
|
||||||
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
||||||
if (!links.length) return;
|
if (!links.length) return;
|
||||||
const targets = links
|
const sections = Array.from(document.querySelectorAll("#admin-main .admin-section"));
|
||||||
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
// Smooth scroll on click
|
const show = (id) => {
|
||||||
links.forEach((a) => {
|
sections.forEach((s) => { s.style.display = s.id === id ? "" : "none"; });
|
||||||
a.addEventListener("click", (e) => {
|
|
||||||
const href = a.getAttribute("href");
|
|
||||||
if (!href || !href.startsWith("#")) return;
|
|
||||||
const t = document.getElementById(href.slice(1));
|
|
||||||
if (!t) return;
|
|
||||||
e.preventDefault();
|
|
||||||
t.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scrollspy: mark the section currently at the top of the viewport
|
|
||||||
let active = null;
|
|
||||||
const setActive = (id) => {
|
|
||||||
if (active === id) return;
|
|
||||||
active = id;
|
|
||||||
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("IntersectionObserver" in window) {
|
links.forEach((a) => {
|
||||||
const io = new IntersectionObserver(
|
a.addEventListener("click", (e) => {
|
||||||
(entries) => {
|
e.preventDefault();
|
||||||
entries.forEach((en) => {
|
const href = a.getAttribute("href") || "";
|
||||||
if (en.isIntersecting) setActive(en.target.id);
|
if (!href.startsWith("#")) return;
|
||||||
});
|
show(href.slice(1));
|
||||||
},
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
|
});
|
||||||
);
|
});
|
||||||
targets.forEach((t) => io.observe(t));
|
|
||||||
} else {
|
// État initial : seule la section demandée par le hash (ou la première) est visible.
|
||||||
// Fallback: highlight the first section only (no scroll observation)
|
const initial = (location.hash && sections.find((s) => "#" + s.id === location.hash)) || sections[0];
|
||||||
links[0] && links[0].classList.add("active");
|
if (initial) show(initial.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth gate ────────────────────────────────────────────────────────────
|
// ── Auth gate ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+27
-3
@@ -2,7 +2,7 @@
|
|||||||
import { api, AuthManager, initMfaSettings } from './auth.js';
|
import { api, AuthManager, initMfaSettings } from './auth.js';
|
||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { el, icon, openFile, filterSavedSearches } from './viewer.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 { escapeHtml, safeCreateIcons } from './utils.js';
|
||||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||||
import { t, setLocale, getLocale } from './i18n.js';
|
import { t, setLocale, getLocale } from './i18n.js';
|
||||||
@@ -1656,10 +1656,14 @@ export async function openShareDialog(vault, path) {
|
|||||||
const expiresInfo = existingShare.expires_at
|
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)">Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}</p>`
|
||||||
: '<p style="font-size:0.75rem;color:var(--text-muted)">Sans expiration</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.innerHTML = `
|
||||||
<div class="share-dialog">
|
<div class="share-dialog">
|
||||||
<h3>📤 Document partagé</h3>
|
<h3>📤 Document partagé</h3>
|
||||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
||||||
|
${recipientsInfo}
|
||||||
${expiresInfo}
|
${expiresInfo}
|
||||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
|
<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>
|
<input type="text" class="share-url-input" value="${url}" readonly>
|
||||||
@@ -1687,6 +1691,7 @@ export async function openShareDialog(vault, path) {
|
|||||||
div.querySelector(".share-revoke-btn").addEventListener("click", async () => {
|
div.querySelector(".share-revoke-btn").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
|
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
|
||||||
|
invalidateSharedPaths();
|
||||||
showToast(t("config.share_revoked"), "success");
|
showToast(t("config.share_revoked"), "success");
|
||||||
existingShare = null;
|
existingShare = null;
|
||||||
renderContent();
|
renderContent();
|
||||||
@@ -1697,7 +1702,16 @@ export async function openShareDialog(vault, path) {
|
|||||||
<div class="share-dialog">
|
<div class="share-dialog">
|
||||||
<h3>📤 Partager ce document</h3>
|
<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.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">
|
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||||
Expiration :
|
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">
|
<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,12 +1730,22 @@ export async function openShareDialog(vault, path) {
|
|||||||
div.querySelector(".share-create-btn").addEventListener("click", async () => {
|
div.querySelector(".share-create-btn").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const expiry = document.getElementById("share-expiry")?.value;
|
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)}`, {
|
const share = await api(`/api/share/${encodeURIComponent(vault)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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;
|
existingShare = share;
|
||||||
|
invalidateSharedPaths();
|
||||||
renderContent();
|
renderContent();
|
||||||
showToast(t("config.link_created"), "success");
|
showToast(t("config.link_created"), "success");
|
||||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||||
|
|||||||
+19
-11
@@ -1,5 +1,5 @@
|
|||||||
// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
|
// 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 { state } from './state.js';
|
||||||
import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js';
|
import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js';
|
||||||
import { icon, openFile } from './viewer.js';
|
import { icon, openFile } from './viewer.js';
|
||||||
@@ -60,24 +60,31 @@ const DashboardSharedWidget = {
|
|||||||
const shares = await api("/api/shares");
|
const shares = await api("/api/shares");
|
||||||
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
|
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
|
||||||
if (empty) empty.style.display = "none";
|
if (empty) empty.style.display = "none";
|
||||||
grid.innerHTML = shares.map(s => `
|
const me = (AuthManager.getUser() || {}).username;
|
||||||
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}">
|
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>`;
|
||||||
|
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(cardVault)}" data-path="${escapeHtml(cardPath)}" data-token="${s.token}" data-received="${received ? "1" : ""}">
|
||||||
<div class="shared-card-header">
|
<div class="shared-card-header">
|
||||||
<i data-lucide="file-text" style="width:14px;height:14px"></i>
|
<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>
|
<span class="shared-card-vault">${escapeHtml(s.vault)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shared-card-meta">
|
<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>
|
<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>` : ""}
|
${s.expires_at ? `<span>${t('dashboard.expires', {date: new Date(s.expires_at).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US')})}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="shared-card-actions">
|
<div class="shared-card-actions">${actions}</div>
|
||||||
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
|
</div>`;
|
||||||
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
|
}).join("");
|
||||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).join("");
|
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
|
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -88,6 +95,7 @@ const DashboardSharedWidget = {
|
|||||||
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
|
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = b.closest(".shared-card");
|
const card = b.closest(".shared-card");
|
||||||
|
// #196 : onglet applicatif — /api/file résout Partage/ vers la source.
|
||||||
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||||
}));
|
}));
|
||||||
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
|
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||||
|
|||||||
@@ -911,8 +911,14 @@ export function renderSearchResults(data, query, tagFilter) {
|
|||||||
});
|
});
|
||||||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||||||
}
|
}
|
||||||
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
|
// #196 : document reçu par partage → onglet applicatif (/api/file résout
|
||||||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
|
// 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);
|
container.appendChild(item);
|
||||||
});
|
});
|
||||||
area.appendChild(container);
|
area.appendChild(container);
|
||||||
|
|||||||
+141
-8
@@ -1,6 +1,6 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { safeCreateIcons, getFileIcon, flushIcons } from './utils.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 { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
||||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
||||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||||
@@ -8,6 +8,47 @@ import { performAdvancedSearch } from './search.js';
|
|||||||
import { t } from './i18n.js';
|
import { t } from './i18n.js';
|
||||||
import { filterChatMessages as _filterChat } from './filechat.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");
|
||||||
|
return shares.filter(s => s.created_by && s.created_by !== 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
|
// Vault context switching
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -306,7 +347,52 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
|||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
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) => {
|
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 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)])]);
|
||||||
|
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).
|
||||||
|
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", () => {
|
||||||
|
// #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;
|
if (!shouldDisplayPath(item.path, vaultName)) return;
|
||||||
|
|
||||||
const existing = existingItems[item.path];
|
const existing = existingItems[item.path];
|
||||||
@@ -374,11 +460,11 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
|||||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : 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 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);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
scrollTreeItemIntoView(fileItem, false);
|
scrollTreeItemIntoView(fileItem, false);
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -629,9 +715,54 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
}
|
}
|
||||||
container.innerHTML = "";
|
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();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
data.items.forEach((item) => {
|
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 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)])]);
|
||||||
|
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).
|
||||||
|
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", () => {
|
||||||
|
// #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
|
// Apply client-side filtering for hidden files
|
||||||
if (!shouldDisplayPath(item.path, vaultName)) {
|
if (!shouldDisplayPath(item.path, vaultName)) {
|
||||||
return; // Skip this item
|
return; // Skip this item
|
||||||
@@ -673,7 +804,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
} else {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : 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);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
@@ -705,6 +837,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sidebar filter
|
// Sidebar filter
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1133,4 +1266,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 };
|
||||||
|
|||||||
@@ -604,6 +604,11 @@
|
|||||||
"config.server_config": "Server config",
|
"config.server_config": "Server config",
|
||||||
"config.settings_saved": "✓ Settings saved",
|
"config.settings_saved": "✓ Settings saved",
|
||||||
"config.share_revoked": "Share revoked",
|
"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": "Public shares",
|
||||||
"config.shares_desc": "Manage public share links for your documents.",
|
"config.shares_desc": "Manage public share links for your documents.",
|
||||||
"config.shares_empty": "No active shares",
|
"config.shares_empty": "No active shares",
|
||||||
@@ -701,6 +706,7 @@
|
|||||||
"dashboard.recent": "Recent",
|
"dashboard.recent": "Recent",
|
||||||
"dashboard.recent_hint": "Open a file to see it appear here",
|
"dashboard.recent_hint": "Open a file to see it appear here",
|
||||||
"dashboard.shared_hint": "Share a document 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.shares": "Shares",
|
||||||
"dashboard.show": "Show",
|
"dashboard.show": "Show",
|
||||||
"dashboard.size_bytes": " B",
|
"dashboard.size_bytes": " B",
|
||||||
@@ -1715,6 +1721,7 @@
|
|||||||
"shortcut.search_in_doc": "Search in document",
|
"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.",
|
"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.all_vaults": "All vaults",
|
||||||
|
"sidebar.shared_folder": "Shared",
|
||||||
"sidebar.case_sensitive": "Case sensitive",
|
"sidebar.case_sensitive": "Case sensitive",
|
||||||
"sidebar.clear_filter": "Clear",
|
"sidebar.clear_filter": "Clear",
|
||||||
"sidebar.expand_collapse": "Expand/Collapse",
|
"sidebar.expand_collapse": "Expand/Collapse",
|
||||||
|
|||||||
@@ -604,6 +604,11 @@
|
|||||||
"config.server_config": "Configuration serveur",
|
"config.server_config": "Configuration serveur",
|
||||||
"config.settings_saved": "✓ Paramètres sauvegardés",
|
"config.settings_saved": "✓ Paramètres sauvegardés",
|
||||||
"config.share_revoked": "Partage révoqué",
|
"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": "Partages publics",
|
||||||
"config.shares_desc": "Gérez les liens de partage public de vos documents.",
|
"config.shares_desc": "Gérez les liens de partage public de vos documents.",
|
||||||
"config.shares_empty": "Aucun partage actif",
|
"config.shares_empty": "Aucun partage actif",
|
||||||
@@ -701,6 +706,7 @@
|
|||||||
"dashboard.recent": "Récents",
|
"dashboard.recent": "Récents",
|
||||||
"dashboard.recent_hint": "Ouvrez un fichier pour le voir apparaître ici",
|
"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_hint": "Partagez un document pour le voir apparaître ici",
|
||||||
|
"dashboard.shared_by": "Partagé par {user}",
|
||||||
"dashboard.shares": "Partages",
|
"dashboard.shares": "Partages",
|
||||||
"dashboard.show": "Afficher",
|
"dashboard.show": "Afficher",
|
||||||
"dashboard.size_bytes": " o",
|
"dashboard.size_bytes": " o",
|
||||||
@@ -1715,6 +1721,7 @@
|
|||||||
"shortcut.search_in_doc": "Rechercher dans le document",
|
"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é.",
|
"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.all_vaults": "Tous les vaults",
|
||||||
|
"sidebar.shared_folder": "Partage",
|
||||||
"sidebar.case_sensitive": "Respecter la casse",
|
"sidebar.case_sensitive": "Respecter la casse",
|
||||||
"sidebar.clear_filter": "Effacer",
|
"sidebar.clear_filter": "Effacer",
|
||||||
"sidebar.expand_collapse": "Développer/Réduire",
|
"sidebar.expand_collapse": "Développer/Réduire",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.65.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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -514,8 +514,11 @@ class TestUserHome:
|
|||||||
assert (home / "alice").is_dir()
|
assert (home / "alice").is_dir()
|
||||||
|
|
||||||
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
||||||
assert "home-alice" in names
|
# #194 : « * » n'inclut pas les dossiers persos — l'admin ne voit pas
|
||||||
# L'octroi atterrit bien dans le user → visible dans son JWT/liste de vaults
|
# le home d'alice (il ne verrait sinon celui de chaque utilisateur).
|
||||||
|
assert "home-alice" not in names
|
||||||
|
assert "TestVault" in names
|
||||||
|
# L'octroi atterrit bien dans le user
|
||||||
assert "home-alice" in resp.json()["vaults"]
|
assert "home-alice" in resp.json()["vaults"]
|
||||||
|
|
||||||
# Isolation : alice ne voit QUE son propre dossier
|
# Isolation : alice ne voit QUE son propre dossier
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# #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
|
||||||
|
|
||||||
|
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/<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
|
||||||
@@ -191,6 +191,48 @@ class TestIsolation:
|
|||||||
assert check_vault_access("home-alice", get_user("bob")) is False
|
assert check_vault_access("home-alice", get_user("bob")) is False
|
||||||
assert check_vault_access("home-bob", get_user("alice")) is False
|
assert check_vault_access("home-bob", get_user("alice")) is False
|
||||||
|
|
||||||
|
def test_wildcard_admin_does_not_cover_homes(self, home_env):
|
||||||
|
"""#194 : « * » n'inclut JAMAIS un dossier perso, même pour l'admin."""
|
||||||
|
from backend.auth.middleware import check_vault_access
|
||||||
|
from backend.auth.user_store import create_user, get_user
|
||||||
|
from backend.user_home import ensure_user_home
|
||||||
|
|
||||||
|
create_user("root", "Motdepasse1!", role="admin", vaults=["*"])
|
||||||
|
_create_user("alice")
|
||||||
|
_run(ensure_user_home("alice"))
|
||||||
|
|
||||||
|
admin = get_user("root")
|
||||||
|
assert check_vault_access("home-alice", admin) is False # pas le sien
|
||||||
|
assert check_vault_access("TestVault", admin) is True # « * » intact
|
||||||
|
|
||||||
|
# L'octroi explicite suffit (y compris pour l'admin, cf. _grant)
|
||||||
|
_run(ensure_user_home("root"))
|
||||||
|
assert check_vault_access("home-root", get_user("root")) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchFiltering:
|
||||||
|
def test_search_service_filters_before_pagination(self, monkeypatch):
|
||||||
|
"""#194 : /api/search ne doit pas renvoyer les notes d'un autre user."""
|
||||||
|
import backend.search as search_mod
|
||||||
|
from backend.services import search as search_service
|
||||||
|
|
||||||
|
hits = [
|
||||||
|
{"vault": "home-bruno", "path": "secret.md"},
|
||||||
|
{"vault": "TestVault", "path": "note.md"},
|
||||||
|
{"vault": "home-bruno", "path": "autre.md"},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
search_mod, "search",
|
||||||
|
lambda q, vault_filter="all", tag_filter=None: list(hits),
|
||||||
|
)
|
||||||
|
|
||||||
|
page = search_service.search_vaults("x", limit=50, is_allowed=lambda v: v == "TestVault")
|
||||||
|
assert page["total"] == 1, "total = résultats filtrés"
|
||||||
|
assert [r["vault"] for r in page["results"]] == ["TestVault"]
|
||||||
|
|
||||||
|
# Sans filtre (tool layer, qui filtre lui-même) : inchangé.
|
||||||
|
assert search_service.search_vaults("x", limit=50)["total"] == 3
|
||||||
|
|
||||||
|
|
||||||
class TestEnsureAll:
|
class TestEnsureAll:
|
||||||
def test_repairs_every_existing_user(self, home_env):
|
def test_repairs_every_existing_user(self, home_env):
|
||||||
|
|||||||
Reference in New Issue
Block a user