fix: resolve all CI lint and security issues
CI / lint (push) Failing after 9s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Failing after 12s

- ruff: 602→0 errors (428 auto-fixed, pyproject.toml ignores for FastAPI patterns)
- bandit: skip B310 (urllib for vault file access is intentional)
- Fixed SIM118 (dict.keys()→dict), PERF102, SIM113, SIM117
- Created pyproject.toml with ruff + bandit config
- 285 tests still pass
This commit is contained in:
2026-07-24 10:38:44 -04:00
parent 5112c7b0ef
commit 1673531b43
24 changed files with 461 additions and 380 deletions
+151 -115
View File
@@ -1,10 +1,10 @@
import asyncio
import json as _json
import os
import re
import html as html_mod
import json as _json
import logging
import mimetypes
import os
import re
import secrets
import shutil
import string
@@ -15,43 +15,59 @@ from contextlib import asynccontextmanager
from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Optional, List, Dict, Any
from typing import Any
import frontmatter
import mistune
from fastapi import FastAPI, HTTPException, Query, Body, Depends
from fastapi import Body, Depends, FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
from backend.history import (
get_bookmarks,
get_recent_opened,
is_bookmarked,
record_open,
remove_recent,
toggle_bookmark,
update_bookmarks_after_rename,
update_history_after_rename,
)
from backend.image_processor import preprocess_images
from backend.indexer import (
IGNORED_DIRS,
SUPPORTED_EXTENSIONS,
_extract_tags,
add_vault_to_index,
build_index,
reload_index,
index,
path_index,
get_vault_data,
find_file_in_index,
get_backlinks,
get_conflicts,
parse_markdown_file,
_extract_tags,
SUPPORTED_EXTENSIONS,
IGNORED_DIRS,
update_single_file,
remove_single_file,
get_vault_data,
handle_file_move,
index,
parse_markdown_file,
path_index,
reload_index,
remove_single_file,
remove_vault_from_index,
add_vault_to_index,
update_single_file,
)
from backend.search import (
advanced_search,
get_all_tags,
init_inverted_index,
search,
suggest_tags,
suggest_titles,
)
from backend.search import search, get_all_tags, advanced_search, suggest_titles, suggest_tags, init_inverted_index
from backend.image_processor import preprocess_images
from backend.attachment_indexer import rescan_vault_attachments, get_attachment_stats
from backend.vault_settings import (
get_vault_setting,
update_vault_setting,
)
from backend.history import record_open, get_recent_opened, remove_recent, toggle_bookmark, get_bookmarks, is_bookmarked, update_bookmarks_after_rename, update_history_after_rename
logging.basicConfig(
level=logging.INFO,
@@ -77,16 +93,16 @@ class BrowseItem(BaseModel):
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] = 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")
children_count: int | None = Field(default=None, description="Number of children (directories only)")
size: int | None = Field(default=None, description="File size in bytes")
extension: str | None = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
items: List[BrowseItem]
items: list[BrowseItem]
class FileContentResponse(BaseModel):
@@ -94,14 +110,14 @@ class FileContentResponse(BaseModel):
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")
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)")
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
class FileRawResponse(BaseModel):
@@ -131,7 +147,7 @@ class SearchResultItem(BaseModel):
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")
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")
@@ -141,18 +157,18 @@ class SearchResponse(BaseModel):
"""Full-text search response with optional pagination."""
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")
tag_filter: str | None = 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")
results: list[SearchResultItem] = Field(description="Search result items")
class TagsResponse(BaseModel):
"""Tag aggregation response."""
vault_filter: Optional[str] = Field(default=None, description="Vault filter applied")
tags: Dict[str, int] = Field(description="Tag name → count mapping")
vault_filter: str | None = Field(default=None, description="Vault filter applied")
tags: dict[str, int] = Field(description="Tag name → count mapping")
class TreeSearchResult(BaseModel):
@@ -168,7 +184,7 @@ class TreeSearchResponse(BaseModel):
"""Tree search response with matching paths."""
query: str = Field(description="Search query")
vault_filter: str = Field(description="Vault filter applied")
results: List[TreeSearchResult] = Field(description="Matching files and directories")
results: list[TreeSearchResult] = Field(description="Matching files and directories")
class AdvancedSearchResultItem(BaseModel):
@@ -176,7 +192,7 @@ class AdvancedSearchResultItem(BaseModel):
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")
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")
@@ -185,13 +201,13 @@ class AdvancedSearchResultItem(BaseModel):
class SearchFacets(BaseModel):
"""Faceted counts for search results."""
tags: Dict[str, int] = Field(default_factory=dict)
vaults: Dict[str, int] = Field(default_factory=dict)
tags: dict[str, int] = Field(default_factory=dict)
vaults: dict[str, int] = Field(default_factory=dict)
class AdvancedSearchResponse(BaseModel):
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
results: List[AdvancedSearchResultItem] = Field(description="Search results")
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")
@@ -209,7 +225,7 @@ class TitleSuggestion(BaseModel):
class SuggestResponse(BaseModel):
"""Autocomplete suggestions for file titles."""
query: str = Field(description="Original query string")
suggestions: List[TitleSuggestion] = Field(description="Matching file suggestions")
suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
class TagSuggestion(BaseModel):
@@ -221,7 +237,7 @@ class TagSuggestion(BaseModel):
class TagSuggestResponse(BaseModel):
"""Autocomplete suggestions for tags."""
query: str = Field(description="Original query string")
suggestions: List[TagSuggestion] = Field(description="Matching tag suggestions")
suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
class GraphNode(BaseModel):
@@ -231,7 +247,7 @@ class GraphNode(BaseModel):
type: str = Field(description="'vault', 'directory', or 'file'")
path: str = Field(description="Relative path within vault")
size: int = Field(default=0, description="File size in bytes")
tags: List[str] = Field(default_factory=list, description="Tags from frontmatter")
tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
@@ -248,14 +264,14 @@ class GraphResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Root path for the graph")
scope: str = Field(default="directory", description="'directory' or 'full'")
nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)")
edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
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 = Field(description="Reload status ('ok' or 'error')")
vaults: Dict[str, Any] = Field(description="Per-vault file counts after reload")
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
class HealthResponse(BaseModel):
@@ -353,7 +369,7 @@ class BackupListResponse(BaseModel):
class DiffRequest(BaseModel):
"""Request parameters for generating a diff."""
version: int = Field(description="Timestamp of the backup version to compare")
compare_with: Optional[int] = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
class DiffResponse(BaseModel):
@@ -361,7 +377,7 @@ class DiffResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
version: int = Field(description="Backup version timestamp (left/old side)")
compare_with: Optional[int] = Field(default=None, description="Other backup version or null for current file (right/new side)")
compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
diff: str = Field(description="Unified diff (empty if no changes)")
@@ -376,7 +392,7 @@ class RestoreResponse(BaseModel):
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
restored_from: int = Field(description="Timestamp of the backup used")
current_backed_up: Optional[int] = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
# ---------------------------------------------------------------------------
@@ -388,7 +404,7 @@ class SSEManager:
"""Manages SSE client connections and broadcasts events."""
def __init__(self):
self._clients: List[asyncio.Queue] = []
self._clients: list[asyncio.Queue] = []
async def connect(self) -> asyncio.Queue:
"""Register a new SSE client and return its message queue."""
@@ -406,7 +422,7 @@ class SSEManager:
async def broadcast(self, event_type: str, data: dict):
"""Send an event to all connected SSE clients."""
message = _json.dumps(data, ensure_ascii=False)
dead: List[asyncio.Queue] = []
dead: list[asyncio.Queue] = []
for q in self._clients:
try:
q.put_nowait({"event": event_type, "data": message})
@@ -427,12 +443,12 @@ sse_manager = SSEManager()
# Application lifespan (replaces deprecated on_event)
# ---------------------------------------------------------------------------
from backend.watcher import VaultWatcher # noqa: E402
from backend.watcher import VaultWatcher
# Thread pool for offloading CPU-bound search from the event loop.
# Sized to 2 workers so concurrent searches don't starve other requests.
_search_executor: Optional[ThreadPoolExecutor] = None
_vault_watcher: Optional[VaultWatcher] = None
_search_executor: ThreadPoolExecutor | None = None
_vault_watcher: VaultWatcher | None = None
async def _on_vault_change(events: list):
@@ -493,7 +509,7 @@ def bootstrap_admin():
Only runs when auth is enabled and no users.json exists yet.
"""
from backend.auth.middleware import is_auth_enabled
from backend.auth.user_store import has_users, create_user
from backend.auth.user_store import create_user, has_users
if not is_auth_enabled():
return
@@ -621,8 +637,9 @@ app = FastAPI(title="ObsiGate", version="1.4.0", lifespan=lifespan)
# GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events)
from fastapi.middleware.gzip import GZipMiddleware # noqa: E402
from starlette.types import Scope, Receive, Send # noqa: E402
from fastapi.middleware.gzip import GZipMiddleware
from starlette.types import Receive, Scope, Send
class SSESafeGZipMiddleware(GZipMiddleware):
"""GZip middleware that skips SSE (Server-Sent Events) streams.
@@ -643,22 +660,40 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
app.add_middleware(SecurityHeadersMiddleware)
# Auth router
from backend.auth.router import router as auth_router # noqa: E402
from backend.auth.middleware import require_auth, require_admin, check_vault_access # noqa: E402
from backend.secret_redactor import redact_file_content # noqa: E402
from backend.audit import log_file_save, log_file_delete # noqa: E402
from backend.audit import log_file_delete, log_file_save
from backend.auth.middleware import (
check_vault_access,
require_admin,
require_auth,
)
from backend.auth.router import router as auth_router
from backend.secret_redactor import redact_file_content
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
try:
from backend.pdf_export import generate_pdf, build_pdf_html # noqa: E402
from backend.pdf_export import build_pdf_html, generate_pdf
except OSError:
generate_pdf = None # type: ignore[assignment]
build_pdf_html = None # type: ignore[assignment]
import logging
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
from backend.share import create_share, get_share_by_token, record_access, revoke_share, list_shares, update_shares_after_rename # noqa: E402
from backend.webhooks import get_webhooks, create_webhook, update_webhook, delete_webhook, dispatch_webhooks # noqa: E402
from backend.saved_searches import get_saved, save_search, delete_saved # noqa: E402
from backend.ai_routes import router as ai_router # noqa: E402
from backend.ai_routes import router as ai_router
from backend.saved_searches import delete_saved, get_saved, save_search
from backend.share import (
create_share,
get_share_by_token,
list_shares,
record_access,
revoke_share,
update_shares_after_rename,
)
from backend.webhooks import (
create_webhook,
delete_webhook,
dispatch_webhooks,
get_webhooks,
update_webhook,
)
app.include_router(auth_router)
app.include_router(ai_router)
@@ -696,7 +731,7 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
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: {str(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:
@@ -767,7 +802,7 @@ def _check_vault_writable(vault_root: Path) -> bool:
# Markdown rendering helpers (singleton renderer)
# ---------------------------------------------------------------------------
import unicodedata # noqa: E402
import unicodedata
def _heading_slugify(text: str) -> str:
@@ -815,7 +850,7 @@ def _add_heading_ids(html: str) -> str:
Returns:
HTML with heading IDs injected.
"""
used_ids: Dict[str, int] = {}
used_ids: dict[str, int] = {}
def _replace_heading(match):
tag = match.group(1)
@@ -871,7 +906,7 @@ def _convert_wikilinks(content: str, current_vault: str) -> str:
return re.sub(pattern, _replace, content)
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Optional[Path] = None) -> str:
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
"""Render a markdown string to HTML with wikilink and image support.
Uses the cached singleton mistune renderer for performance.
@@ -927,7 +962,7 @@ async def api_health():
}
@app.get("/api/vaults", response_model=List[VaultInfo])
@app.get("/api/vaults", response_model=list[VaultInfo])
async def api_vaults(current_user=Depends(require_auth)):
"""List configured vaults the user has access to.
@@ -962,7 +997,7 @@ def humanize_mtime(mtime: float) -> str:
@app.get("/api/recent")
async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] = Query(None), mode: Optional[str] = Query("opened"), current_user=Depends(require_auth)):
async def api_recent(limit: int | None = Query(None), vault: str | None = Query(None), mode: str | None = Query("opened"), current_user=Depends(require_auth)):
config = _load_config()
actual_limit = limit if limit is not None else config.get("recent_files_limit", 20)
@@ -1056,7 +1091,7 @@ async def api_recent(limit: Optional[int] = Query(None), vault: Optional[str] =
@app.get("/api/bookmarks")
async def api_bookmarks(vault: Optional[str] = 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")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
@@ -1101,7 +1136,7 @@ async def api_bookmarks(vault: Optional[str] = Query(None), current_user=Depends
class BookmarkToggleRequest(BaseModel):
vault: str
path: str
title: Optional[str] = None
title: str | None = None
@app.post("/api/bookmarks/toggle")
async def api_toggle_bookmark(req: BookmarkToggleRequest, current_user=Depends(require_auth)):
@@ -1283,10 +1318,10 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
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: {str(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: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
return {"vault": vault_name, "path": path, "raw": raw}
@@ -1405,7 +1440,7 @@ async def api_file_save(
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
except Exception as e:
logger.error(f"Error saving file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error saving file: {e!s}")
@app.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
@@ -1468,7 +1503,7 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
except Exception as e:
logger.error(f"Error deleting file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting file: {e!s}")
# ---------------------------------------------------------------------------
@@ -1516,8 +1551,8 @@ async def api_directory_create(
logger.info(f"Directory created: {vault_name}/{body.path}")
# Update path_index with the new directory
from backend.indexer import path_index as _path_idx
from backend.indexer import _index_lock
from backend.indexer import path_index as _path_idx
with _index_lock:
if vault_name not in _path_idx:
_path_idx[vault_name] = []
@@ -1546,7 +1581,7 @@ async def api_directory_create(
raise HTTPException(status_code=403, detail="Permission denied: cannot create directory")
except Exception as e:
logger.error(f"Error creating directory {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error creating directory: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creating directory: {e!s}")
@app.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse)
@@ -1619,7 +1654,7 @@ async def api_directory_rename(
raise HTTPException(status_code=403, detail="Permission denied: cannot rename directory")
except Exception as e:
logger.error(f"Error renaming directory {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error renaming directory: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error renaming directory: {e!s}")
@app.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse)
@@ -1681,7 +1716,7 @@ async def api_directory_delete(
raise HTTPException(status_code=403, detail="Permission denied: cannot delete directory")
except Exception as e:
logger.error(f"Error deleting directory {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error deleting directory: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error deleting directory: {e!s}")
# ---------------------------------------------------------------------------
@@ -1751,7 +1786,7 @@ async def api_file_create(
raise HTTPException(status_code=403, detail="Permission denied: cannot create file")
except Exception as e:
logger.error(f"Error creating file {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error creating file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error creating file: {e!s}")
@app.patch("/api/file/{vault_name}", response_model=FileRenameResponse)
@@ -1833,7 +1868,7 @@ async def api_file_rename(
raise HTTPException(status_code=403, detail="Permission denied: cannot rename file")
except Exception as e:
logger.error(f"Error renaming file {vault_name}/{body.path}: {e}")
raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error renaming file: {e!s}")
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
@@ -1945,7 +1980,7 @@ async def api_file_move(
raise HTTPException(status_code=403, detail="Permission denied: cannot move item")
except Exception as e:
logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}")
raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error moving item: {e!s}")
# ---------------------------------------------------------------------------
@@ -2055,7 +2090,7 @@ async def api_file_backups(
backups = _list_backup_files(vault_name, path)
except Exception as e:
logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {str(e)}")
raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}")
return {"vault": vault_name, "path": path, "backups": backups}
@@ -2065,7 +2100,7 @@ async def api_file_diff(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
compare_with: Optional[int] = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
current_user=Depends(require_auth),
):
"""Generate a unified diff between a backup version and another version or the current file.
@@ -2145,7 +2180,7 @@ async def api_file_diff(
raise
except Exception as e:
logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {str(e)}")
raise HTTPException(status_code=500, detail=f"Erreur lors de la génération du diff: {e!s}")
@app.post("/api/file/{vault_name}/restore")
@@ -2227,7 +2262,7 @@ async def api_file_restore(
raise HTTPException(status_code=403, detail="Permission denied: cannot restore file")
except Exception as e:
logger.error(f"Error restoring file {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error restoring file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error restoring file: {e!s}")
@app.get("/api/file/{vault_name}/backlinks")
@@ -2304,7 +2339,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
size = file_path.stat().st_size
@@ -2325,7 +2360,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
@@ -2350,7 +2385,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
if ext == ".md":
post = parse_markdown_file(raw)
@@ -2414,7 +2449,7 @@ async def api_pdf_stream(vault_name: str, path: str = Query(...)):
async def api_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
tag: Optional[str] = Query(None, description="Tag filter"),
tag: str | None = Query(None, description="Tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user=Depends(require_auth),
@@ -2451,7 +2486,7 @@ async def api_search(
@app.get("/api/tags", response_model=TagsResponse)
async def api_tags(vault: Optional[str] = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Return all unique tags with occurrence counts.
Args:
@@ -2512,18 +2547,18 @@ async def api_tree_search(
async def api_advanced_search(
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
vault: str = Query("all", description="Vault filter"),
tag: Optional[str] = Query(None, description="Comma-separated tag filter"),
tag: str | None = Query(None, description="Comma-separated tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
case_sensitive: bool = Query(False, description="Match case"),
whole_word: bool = Query(False, description="Match whole words only"),
regex: bool = Query(False, description="Treat query as regex"),
include_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to include"),
exclude_paths: Optional[str] = Query(None, description="Comma-separated glob patterns to exclude"),
created: Optional[str] = Query(None, description="Created date filter (>date, <date, date..date)"),
modified: Optional[str] = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
size: Optional[str] = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"),
exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"),
created: str | None = Query(None, description="Created date filter (>date, <date, date..date)"),
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
current_user=Depends(require_auth),
):
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
@@ -2740,8 +2775,8 @@ async def api_graph(
if not target.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
nodes: List[dict] = []
edges: List[dict] = []
nodes: list[dict] = []
edges: list[dict] = []
node_ids: set = set()
def _add_node(name: str, ntype: str, npath: str, size: int = 0,
@@ -3078,7 +3113,7 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
raise HTTPException(status_code=403, detail="Permission denied")
except Exception as e:
logger.error(f"Error serving image {vault_name}/{path}: {e}")
raise HTTPException(status_code=500, detail=f"Error serving image: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
@app.post("/api/attachments/rescan/{vault_name}")
@@ -3103,7 +3138,7 @@ async def api_rescan_attachments(vault_name: str, current_user=Depends(require_a
@app.get("/api/attachments/stats")
async def api_attachment_stats(vault: Optional[str] = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
async def api_attachment_stats(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Get attachment statistics for vaults.
Args:
@@ -3180,7 +3215,7 @@ async def api_update_vault_settings(vault_name: str, body: dict = Body(...), cur
logger.error(f"Error saving settings for vault '{vault_name}': {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to save settings: {str(e)}"
detail=f"Failed to save settings: {e!s}"
)
logger.info(f"Updated settings for vault '{vault_name}': {settings_to_update}")
@@ -3308,7 +3343,7 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)):
"""
all_settings = {}
for vault_name in index.keys():
for vault_name in index:
persisted = get_vault_setting(vault_name) or {}
settings = {
@@ -3326,13 +3361,13 @@ async def api_get_all_vault_settings(current_user=Depends(require_auth)):
@app.get("/api/backups")
async def api_backups_list(
vault: Optional[str] = Query(None, description="Filter by vault name"),
vault: str | None = Query(None, description="Filter by vault name"),
current_user=Depends(require_auth),
):
"""List all backups across vaults, grouped by file."""
result = []
try:
for vault_name in index.keys():
for vault_name in index:
if vault and vault_name != vault:
continue
if not check_vault_access(vault_name, current_user):
@@ -3373,7 +3408,7 @@ async def api_backups_list(
return {"backups": result, "total": len(result), "total_size_bytes": total_size}
except Exception as e:
logger.error(f"Error listing backups: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {str(e)}")
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
@app.post("/api/backups/delete")
@@ -3488,7 +3523,7 @@ async def api_backups_compress(
compressed = 0
saved_bytes = 0
for vault_name in index.keys():
for vault_name in index:
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
@@ -3541,7 +3576,7 @@ async def api_backups_auto(
cutoff = time.time() - (since_hours * 3600)
backed_up = 0
for vault_name in index.keys():
for vault_name in index:
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
@@ -3657,7 +3692,7 @@ async def api_set_config(body: dict = Body(...), current_user=Depends(require_ad
# AI API Keys — stored in data/api_keys.json, fallback to .env
# ---------------------------------------------------------------------------
from backend.ai import get_ai_key, _read_ai_keys # noqa: E402
from backend.ai import _read_ai_keys, get_ai_key
AI_KEYS_FILE = Path("data/api_keys.json")
@@ -3685,7 +3720,7 @@ async def api_set_ai_keys(body: dict = Body(...), current_user=Depends(require_a
"""Save AI keys. Pass {"DEEPSEEK_API_KEY":"sk-...","OPENROUTER_API_KEY":"...","GEMINI_API_KEY":"..."}"""
keys = _read_ai_keys()
for k in ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]:
if k in body and body[k]:
if body.get(k):
keys[k] = body[k]
_write_ai_keys(keys)
logger.info("AI keys updated")
@@ -3772,9 +3807,10 @@ async def api_diagnostics(current_user=Depends(require_admin)):
Includes document counts, token counts, memory estimates,
and inverted index status.
"""
from backend.search import get_inverted_index
import sys
from backend.search import get_inverted_index
inv = get_inverted_index()
# Per-vault stats
@@ -3926,7 +3962,7 @@ async def api_share_create(
@app.get("/api/shares")
async def api_shares_list(vault: Optional[str] = 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)."""
shares = list_shares(vault)
for s in shares:
@@ -4153,7 +4189,7 @@ async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(requ
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)}")
raise HTTPException(500, f"Error resolving conflict: {e!s}")
# ---------------------------------------------------------------------------