feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:06:40 -04:00
parent c55e3e0cbc
commit 4c4e415975
27 changed files with 1465 additions and 386 deletions
+37 -136
View File
@@ -20,7 +20,7 @@ from typing import Any
import frontmatter
import mistune
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware
@@ -99,12 +99,15 @@ from backend.schemas import (
)
from backend.search import (
advanced_search,
get_all_tags,
init_inverted_index,
search,
suggest_tags,
suggest_titles,
)
from backend.services.errors import ServiceError
from backend.services.files import read_raw_file
from backend.services.search import list_tags as service_list_tags
from backend.services.search import search_vaults
from backend.services.vaults import browse_directory, list_accessible_vaults
from backend.vault_settings import (
get_vault_setting,
update_vault_setting,
@@ -737,6 +740,12 @@ def _custom_openapi():
app.openapi = _custom_openapi # type: ignore[method-assign]
@app.exception_handler(ServiceError)
async def _service_error_handler(request: Request, exc: ServiceError):
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``)."""
return JSONResponse(status_code=exc.status, content={"detail": exc.message})
# GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events)
from fastapi.middleware.gzip import GZipMiddleware
@@ -749,8 +758,16 @@ class SSESafeGZipMiddleware(GZipMiddleware):
GZip buffering breaks incremental streaming required by SSE.
We detect SSE endpoints by path and bypass compression entirely.
"""
# SSE endpoints that must not be buffered by GZip.
_SSE_PATHS = (
"/api/events",
"/api/admin/stream",
"/api/ai/bookslm/chat",
"/api/ai/bookslm/agent",
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope.get("path") in ("/api/events", "/api/admin/stream"):
if scope["type"] == "http" and scope.get("path") in self._SSE_PATHS:
# Bypass GZip: passthrough directly to the inner app
await self.app(scope, receive, send)
else:
@@ -864,11 +881,13 @@ def _content_disposition(disposition: str, filename: str) -> str:
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a relative path safely within the vault root.
Prevents directory traversal attacks by ensuring the resolved
absolute path is a descendant of *vault_root*.
Thin wrapper around the shared :func:`backend.services.paths.resolve_safe_path`
(single implementation used by both routes and tools). The raised
:class:`ServiceError` is mapped to an ``HTTPException`` response by the
global exception handler in this module.
Args:
vault_root: The vault's root directory (absolute).
@@ -876,34 +895,10 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
Returns:
Resolved absolute ``Path``.
Raises:
HTTPException(403): When the resolved path escapes the vault root.
"""
# Construct the full path without resolving symlinks first
full_path = vault_root / relative_path
# Resolve both paths to handle symlinks
try:
resolved = full_path.resolve(strict=False)
vault_resolved = vault_root.resolve(strict=False)
except Exception as e:
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
raise HTTPException(status_code=500, detail=f"Path resolution error: {e!s}")
# Check if resolved path is within vault using string comparison (case-insensitive on Windows)
try:
# This will raise ValueError if resolved is not relative to vault_resolved
resolved.relative_to(vault_resolved)
except ValueError:
# Try case-insensitive comparison for Windows/Docker compatibility
resolved_str = str(resolved).lower()
vault_str = str(vault_resolved).lower()
if not resolved_str.startswith(vault_str):
logger.warning(f"Path outside vault - vault: {vault_resolved}, requested: {relative_path}, resolved: {resolved}")
raise HTTPException(status_code=403, detail="Access denied: path outside vault")
return resolved
from backend.services.paths import resolve_safe_path as _service_resolve
return _service_resolve(vault_root, relative_path)
def _backup_file(file_path: Path, vault_name: str, relative_path: str):
@@ -1271,18 +1266,7 @@ async def api_vaults(current_user=Depends(require_auth)):
Returns:
List of vault summary objects filtered by user permissions.
"""
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
result = []
for name, data in index.items():
if "*" in user_vaults or name in user_vaults:
v_type = data.get("config", {}).get("type", "VAULT")
result.append({
"name": name,
"file_count": len(data["files"]),
"tag_count": len(data["tags"]),
"type": v_type,
})
return result
return list_accessible_vaults(current_user)
def humanize_mtime(mtime: float) -> str:
@@ -1516,57 +1500,7 @@ async def api_browse(vault_name: str, path: str = "", current_user=Depends(requi
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
# Path traversal protection
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
# Get vault settings for hideHiddenFiles
settings = get_vault_setting(vault_name) or {}
hide_hidden = settings.get("hideHiddenFiles", False)
items = []
try:
for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
# Skip hidden files/dirs if the setting is enabled
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
if entry.is_dir():
# Count only direct children (files and subdirs) for performance
try:
file_count = sum(
1 for child in entry.iterdir()
if (not hide_hidden or not child.name.startswith("."))
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
or child.is_dir())
)
except PermissionError:
file_count = 0
items.append({
"name": entry.name,
"path": rel,
"type": "directory",
"children_count": file_count,
})
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": entry.stat().st_size,
"extension": entry.suffix.lower(),
})
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
return {"vault": vault_name, "path": path, "items": items}
return browse_directory(vault_name, path)
# Map file extensions to highlight.js language hints
@@ -1599,33 +1533,7 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading raw file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary file - try to read as binary and decode with errors='replace'
try:
raw = file_path.read_bytes().decode("utf-8", errors="replace")
except Exception as e:
logger.error(f"Error reading binary raw file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Cannot read file: {e!s}")
except Exception as e:
logger.error(f"Unexpected error reading raw file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
return {"vault": vault_name, "path": path, "raw": raw}
return read_raw_file(vault_name, path)
@app.get("/api/file/{vault_name}/download", response_class=FileResponse)
@@ -3120,18 +3028,12 @@ async def api_search(
``SearchResponse`` with ranked results and snippets.
"""
loop = asyncio.get_event_loop()
# Fetch full result set (capped at DEFAULT_SEARCH_LIMIT internally)
all_results = await loop.run_in_executor(
# Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and
# paginate in the shared service so routes and tools share the same logic.
return await loop.run_in_executor(
_search_executor,
partial(search, q, vault_filter=vault, tag_filter=tag),
partial(search_vaults, q, vault, tag, limit, offset),
)
total = len(all_results)
page = all_results[offset: offset + limit]
return {
"query": q, "vault_filter": vault, "tag_filter": tag,
"count": len(page), "total": total, "offset": offset, "limit": limit,
"results": page,
}
@app.get("/api/tags", response_model=TagsResponse)
@@ -3144,8 +3046,7 @@ async def api_tags(vault: str | None = Query(None, description="Vault filter"),
Returns:
``TagsResponse`` with tags sorted by descending count.
"""
tags = get_all_tags(vault_filter=vault)
return {"vault_filter": vault, "tags": tags}
return {"vault_filter": vault, "tags": service_list_tags(vault)}
@app.get("/api/tree-search", response_model=TreeSearchResponse)