|
|
|
@@ -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
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|