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
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:
+37
-136
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user