Add share, webhook, and conflict management features

This commit is contained in:
2026-05-26 11:00:48 -04:00
parent ed2bb4f7fb
commit 0b611a8735
11 changed files with 1595 additions and 366 deletions
+278 -92
View File
@@ -34,6 +34,7 @@ from backend.indexer import (
get_vault_names,
find_file_in_index,
get_backlinks,
get_conflicts,
parse_markdown_file,
_extract_tags,
SUPPORTED_EXTENSIONS,
@@ -75,12 +76,12 @@ class VaultInfo(BaseModel):
class BrowseItem(BaseModel):
"""A single entry (file or directory) returned by the browse endpoint."""
name: str
path: str
name: str = Field(description="File or directory name")
path: str = Field(description="Relative path within vault")
type: str = Field(description="'file' or 'directory'")
children_count: Optional[int] = None
size: Optional[int] = None
extension: Optional[str] = None
children_count: Optional[int] = Field(default=None, description="Number of children (directories only)")
size: Optional[int] = Field(default=None, description="File size in bytes")
extension: Optional[str] = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
@@ -92,95 +93,95 @@ class BrowseResponse(BaseModel):
class FileContentResponse(BaseModel):
"""Rendered file content with metadata."""
vault: str
path: str
title: str
tags: List[str]
frontmatter: Dict[str, Any]
html: str
raw_length: int
extension: str
is_markdown: bool
unsupported: Optional[bool] = False
size_bytes: Optional[int] = None
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
title: str = Field(description="File title (from frontmatter or filename)")
tags: List[str] = Field(description="Extracted tags from frontmatter and inline #tags")
frontmatter: Dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
html: str = Field(description="Rendered HTML content")
raw_length: int = Field(description="Length of raw file content in characters")
extension: str = Field(description="File extension (e.g. .md, .txt)")
is_markdown: bool = Field(description="Whether the file is markdown")
unsupported: Optional[bool] = Field(default=False, description="True for binary/unsupported files")
size_bytes: Optional[int] = Field(default=None, description="File size in bytes (for unsupported files)")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
vault: str
path: str
raw: str
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
raw: str = Field(description="Raw file content as text")
class FileSaveResponse(BaseModel):
"""Confirmation after saving a file."""
status: str
vault: str
path: str
size: int
status: str = Field(description="Always 'ok'")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
size: int = Field(description="Size of saved content in characters")
class FileDeleteResponse(BaseModel):
"""Confirmation after deleting a file."""
status: str
vault: str
path: str
status: str = Field(description="Always 'ok'")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
class SearchResultItem(BaseModel):
"""A single search result."""
vault: str
path: str
title: str
tags: List[str]
score: int
snippet: str
modified: str
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
tags: List[str] = Field(description="File tags")
score: int = Field(description="Relevance score")
snippet: str = Field(description="Content excerpt with highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
class SearchResponse(BaseModel):
"""Full-text search response with optional pagination."""
query: str
vault_filter: str
tag_filter: Optional[str]
count: int
total: int = Field(0, description="Total results before pagination")
offset: int = Field(0, description="Current pagination offset")
limit: int = Field(200, description="Page size")
results: List[SearchResultItem]
query: str = Field(description="Original search query")
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
tag_filter: Optional[str] = Field(default=None, description="Tag filter applied")
count: int = Field(description="Number of results in this response")
total: int = Field(default=0, description="Total results before pagination")
offset: int = Field(default=0, description="Current pagination offset")
limit: int = Field(default=200, description="Page size")
results: List[SearchResultItem] = Field(description="Search result items")
class TagsResponse(BaseModel):
"""Tag aggregation response."""
vault_filter: Optional[str]
tags: Dict[str, int]
vault_filter: Optional[str] = Field(default=None, description="Vault filter applied")
tags: Dict[str, int] = Field(description="Tag name → count mapping")
class TreeSearchResult(BaseModel):
"""A single tree search result item."""
vault: str
path: str
name: str
vault: str = Field(description="Vault name")
path: str = Field(description="Full relative path")
name: str = Field(description="File or directory name")
type: str = Field(description="'file' or 'directory'")
matched_path: str
matched_path: str = Field(description="Path segment that matched the query")
class TreeSearchResponse(BaseModel):
"""Tree search response with matching paths."""
query: str
vault_filter: str
results: List[TreeSearchResult]
query: str = Field(description="Search query")
vault_filter: str = Field(description="Vault filter applied")
results: List[TreeSearchResult] = Field(description="Matching files and directories")
class AdvancedSearchResultItem(BaseModel):
"""A single advanced search result with highlighted snippet."""
vault: str
path: str
title: str
tags: List[str]
score: float
snippet: str
modified: str
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
tags: List[str] = Field(description="File tags")
score: float = Field(description="TF-IDF relevance score")
snippet: str = Field(description="Content excerpt with <mark> highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
class SearchFacets(BaseModel):
@@ -191,37 +192,37 @@ class SearchFacets(BaseModel):
class AdvancedSearchResponse(BaseModel):
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
results: List[AdvancedSearchResultItem]
total: int
offset: int
limit: int
facets: SearchFacets
query_time_ms: float = Field(0, description="Server-side query time in milliseconds")
results: List[AdvancedSearchResultItem] = Field(description="Search results")
total: int = Field(description="Total number of matching results")
offset: int = Field(description="Current pagination offset")
limit: int = Field(description="Page size")
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
class TitleSuggestion(BaseModel):
"""A file title suggestion for autocomplete."""
vault: str
path: str
title: str
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
class SuggestResponse(BaseModel):
"""Autocomplete suggestions for file titles."""
query: str
suggestions: List[TitleSuggestion]
query: str = Field(description="Original query string")
suggestions: List[TitleSuggestion] = Field(description="Matching file suggestions")
class TagSuggestion(BaseModel):
"""A tag suggestion for autocomplete."""
tag: str
count: int
tag: str = Field(description="Tag name")
count: int = Field(description="Number of files with this tag")
class TagSuggestResponse(BaseModel):
"""Autocomplete suggestions for tags."""
query: str
suggestions: List[TagSuggestion]
query: str = Field(description="Original query string")
suggestions: List[TagSuggestion] = Field(description="Matching tag suggestions")
class GraphNode(BaseModel):
@@ -242,24 +243,24 @@ class GraphEdge(BaseModel):
class GraphResponse(BaseModel):
"""Graph data for a vault or directory."""
vault: str
path: str
nodes: List[GraphNode]
edges: List[GraphEdge]
vault: str = Field(description="Vault name")
path: str = Field(description="Root path for the graph")
nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)")
edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
class ReloadResponse(BaseModel):
"""Index reload confirmation with per-vault stats."""
status: str
vaults: Dict[str, Any]
status: str = Field(description="Reload status ('ok' or 'error')")
vaults: Dict[str, Any] = Field(description="Per-vault file counts after reload")
class HealthResponse(BaseModel):
"""Application health status."""
status: str
version: str
vaults: int
total_files: int
status: str = Field(description="Health status ('ok' or 'error')")
version: str = Field(description="Application version")
vaults: int = Field(description="Number of configured vaults")
total_files: int = Field(description="Total indexed files across all vaults")
class DirectoryCreateRequest(BaseModel):
@@ -269,8 +270,8 @@ class DirectoryCreateRequest(BaseModel):
class DirectoryCreateResponse(BaseModel):
"""Response after creating a directory."""
success: bool
path: str
success: bool = Field(description="Whether creation succeeded")
path: str = Field(description="Path of the created directory")
class DirectoryRenameRequest(BaseModel):
@@ -281,15 +282,15 @@ class DirectoryRenameRequest(BaseModel):
class DirectoryRenameResponse(BaseModel):
"""Response after renaming a directory."""
success: bool
old_path: str
new_path: str
success: bool = Field(description="Whether rename succeeded")
old_path: str = Field(description="Original directory path")
new_path: str = Field(description="New directory path")
class DirectoryDeleteResponse(BaseModel):
"""Response after deleting a directory."""
success: bool
deleted_count: int
success: bool = Field(description="Whether deletion succeeded")
deleted_count: int = Field(description="Number of files recursively deleted")
class FileCreateRequest(BaseModel):
@@ -300,8 +301,8 @@ class FileCreateRequest(BaseModel):
class FileCreateResponse(BaseModel):
"""Response after creating a file."""
success: bool
path: str
success: bool = Field(description="Whether creation succeeded")
path: str = Field(description="Path of the created file")
class FileRenameRequest(BaseModel):
@@ -312,7 +313,7 @@ class FileRenameRequest(BaseModel):
class FileRenameResponse(BaseModel):
"""Response after renaming a file."""
success: bool
success: bool = Field(description="Whether rename succeeded")
old_path: str
new_path: str
@@ -555,6 +556,8 @@ from backend.auth.router import router as auth_router
from backend.auth.middleware import require_auth, require_admin, check_vault_access
from backend.secret_redactor import redact_file_content
from backend.audit import log_file_save, log_file_delete
from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares
from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks
app.include_router(auth_router)
@@ -1254,6 +1257,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
"path": path,
})
# Dispatch webhooks
await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path})
return {"status": "ok", "vault": vault_name, "path": path}
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
@@ -1331,6 +1337,7 @@ async def api_directory_create(
"vault": vault_name,
"path": body.path,
})
await dispatch_webhooks("directory_created", {"vault": vault_name, "path": body.path})
return {"success": True, "path": body.path}
except PermissionError:
@@ -1403,6 +1410,7 @@ async def api_directory_rename(
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
except PermissionError:
@@ -1464,6 +1472,7 @@ async def api_directory_delete(
"path": path,
"deleted_count": file_count,
})
await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": path})
return {"success": True, "deleted_count": file_count}
except PermissionError:
@@ -1533,6 +1542,7 @@ async def api_file_create(
"vault": vault_name,
"path": body.path,
})
await dispatch_webhooks("file_created", {"vault": vault_name, "path": body.path})
return {"success": True, "path": body.path}
except PermissionError:
@@ -1609,6 +1619,7 @@ async def api_file_rename(
"old_path": old_path_str,
"new_path": new_path_str,
})
await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str})
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
except PermissionError:
@@ -2547,6 +2558,181 @@ async def api_diagnostics(current_user=Depends(require_admin)):
}
# ---------------------------------------------------------------------------
# Dashboard endpoint (aggregated stats)
# ---------------------------------------------------------------------------
@app.get("/api/dashboard")
async def api_dashboard(current_user=Depends(require_auth)):
"""Aggregated dashboard statistics across all accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
vault_stats = []
total_files = 0
total_tags = set()
total_size = 0
for vname, vdata in index.items():
if "*" not in user_vaults and vname not in user_vaults:
continue
files = vdata.get("files", [])
fc = len(files)
total_files += fc
vtags = set()
vsize = 0
for f in files:
vtags.update(f.get("tags", []))
vsize += f.get("size", 0)
total_tags.update(vtags)
total_size += vsize
vault_stats.append({"name": vname, "file_count": fc, "tag_count": len(vtags), "total_size_bytes": vsize})
return {"vaults": vault_stats, "total_files": total_files, "total_tags": len(total_tags), "total_size_bytes": total_size}
# ---------------------------------------------------------------------------
# Webhook CRUD endpoints
# ---------------------------------------------------------------------------
@app.get("/api/webhooks")
async def api_webhooks_list(current_user=Depends(require_admin)):
return get_webhooks()
@app.post("/api/webhooks")
async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)):
name = body.get("name", "Unnamed")
url = body.get("url", "")
events = body.get("events", [])
secret = body.get("secret")
if not url:
raise HTTPException(400, "URL is required")
return create_webhook(name, url, events, secret)
@app.patch("/api/webhooks/{webhook_id}")
async def api_webhooks_update(webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)):
result = update_webhook(webhook_id, body)
if not result:
raise HTTPException(404, "Webhook not found")
return result
@app.delete("/api/webhooks/{webhook_id}")
async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)):
if not delete_webhook(webhook_id):
raise HTTPException(404, "Webhook not found")
return {"status": "deleted"}
# ---------------------------------------------------------------------------
# Share (public document) endpoints
# ---------------------------------------------------------------------------
@app.post("/api/share/{vault_name}")
async def api_share_create(
vault_name: str,
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Create a public share link for a document."""
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
path = body.get("path", "")
expires = body.get("expires_in_hours")
share = create_share(vault_name, path, current_user["username"], expires)
share["url"] = f"/s/{share['token']}"
return share
@app.get("/api/shares")
async def api_shares_list(vault: Optional[str] = Query(None), current_user=Depends(require_auth)):
"""List all shares (optionally filtered by vault)."""
shares = list_shares(vault)
for s in shares:
s["url"] = f"/s/{s['token']}"
return shares
@app.delete("/api/share/{share_id}")
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
if not revoke_share(share_id):
raise HTTPException(404, "Share not found")
return {"status": "revoked"}
@app.get("/s/{token}")
async def public_share_view(token: str):
"""Public share view — no authentication required."""
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_access(token)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
html = _render_markdown(post.content, share["vault"], file_path)
title = post.metadata.get("title", file_path.stem)
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — ObsiGate Share</title>
<style>body{{font-family:system-ui,sans-serif;max-width:800px;margin:0 auto;padding:20px;line-height:1.6;color:#1a1a2e;background:#f8f9fa}}
pre{{background:#eee;padding:12px;border-radius:6px;overflow-x:auto}}code{{font-size:0.9em}}a{{color:#6366f1}}.wikilink{{color:#6366f1;cursor:default}}
img{{max-width:100%}}.share-banner{{background:#6366f1;color:#fff;padding:8px 16px;border-radius:6px;margin-bottom:20px;font-size:0.85rem}}</style></head>
<body><div class="share-banner">📄 Document partagé via ObsiGate</div><h1>{title}</h1>{html}</body></html>""")
# ---------------------------------------------------------------------------
# Syncthing conflict endpoints
# ---------------------------------------------------------------------------
@app.get("/api/conflicts")
async def api_conflicts(current_user=Depends(require_auth)):
"""List sync-conflict files across accessible vaults."""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
all_conflicts = get_conflicts()
if "*" not in user_vaults:
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
@app.post("/api/conflicts/resolve")
async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)):
"""Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original)."""
vault_name = body.get("vault")
conflict_path = body.get("conflict_path")
original_path = body.get("original_path")
action = body.get("action") # "keep_local" or "keep_conflict"
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
conf_file = _resolve_safe_path(vault_root, conflict_path)
orig_file = _resolve_safe_path(vault_root, original_path)
if not conf_file.exists():
raise HTTPException(404, "Conflict file not found")
try:
if action == "keep_conflict":
_backup_file(orig_file, vault_name, original_path)
shutil.copy2(conf_file, orig_file)
logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}")
conf_file.unlink()
await remove_single_file(vault_name, conflict_path)
log_file_delete(current_user["username"], vault_name, conflict_path)
await sse_manager.broadcast("file_deleted", {"vault": vault_name, "path": conflict_path})
return {"status": "resolved", "action": action}
except Exception as e:
raise HTTPException(500, f"Error resolving conflict: {str(e)}")
# ---------------------------------------------------------------------------
# Static files & SPA fallback
# ---------------------------------------------------------------------------