fix: resolve all CI lint and security issues
- 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:
+151
-115
@@ -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}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user