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
+23 -22
View File
@@ -4,12 +4,13 @@ Supports: DeepSeek, OpenRouter, Google Gemini.
Configured via environment variables.
"""
import os
import json
import logging
import os
from pathlib import Path
from typing import Literal
import httpx
from typing import Optional, Literal
logger = logging.getLogger("obsigate.ai")
@@ -29,7 +30,7 @@ def _read_ai_keys() -> dict:
def get_ai_key(env_name: str) -> str:
"""Get AI key: stored file first, then .env fallback."""
keys = _read_ai_keys()
if env_name in keys and keys[env_name]:
if keys.get(env_name):
return keys[env_name]
return os.getenv(env_name, "").strip()
@@ -67,7 +68,7 @@ PROVIDERS = _load_provider_keys()
DEFAULT_PROVIDER: ProviderName = os.getenv("AI_DEFAULT_PROVIDER", "deepseek") # type: ignore
def _get_provider_config(provider: Optional[ProviderName] = None) -> dict:
def _get_provider_config(provider: ProviderName | None = None) -> dict:
"""Get provider config, falling back to default if requested provider unavailable."""
p = provider or DEFAULT_PROVIDER
if p not in PROVIDERS:
@@ -83,7 +84,7 @@ def _get_provider_config(provider: Optional[ProviderName] = None) -> dict:
return {"name": p, **cfg}
async def _call_deepseek_openrouter(prompt: str, system: str, provider: Optional[ProviderName] = None,
async def _call_deepseek_openrouter(prompt: str, system: str, provider: ProviderName | None = None,
temperature: float = 0.7, max_tokens: int = 2048) -> str:
"""Call OpenAI-compatible API (DeepSeek, OpenRouter)."""
cfg = _get_provider_config(provider)
@@ -133,7 +134,7 @@ async def _call_gemini(prompt: str, system: str, temperature: float = 0.7, max_t
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
async def ai_complete(prompt: str, provider: Optional[ProviderName] = None) -> str:
async def ai_complete(prompt: str, provider: ProviderName | None = None) -> str:
"""Generic AI completion. Routes to appropriate provider."""
cfg = _get_provider_config(provider)
if cfg["name"] == "gemini":
@@ -148,7 +149,7 @@ Your responses should be direct and concise. When editing text, return ONLY the
no explanations or markdown fences."""
async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_improve_writing(text: str, provider: ProviderName | None = None) -> str:
"""Improve writing quality while preserving meaning."""
return await _call_deepseek_openrouter(
f"Improve the following text. Fix grammar, clarity, and flow. Preserve the original language and meaning.\n\n{text}",
@@ -156,7 +157,7 @@ async def ai_improve_writing(text: str, provider: Optional[ProviderName] = None)
)
async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_fix_spelling(text: str, provider: ProviderName | None = None) -> str:
"""Fix spelling and grammar errors."""
return await _call_deepseek_openrouter(
f"Fix all spelling and grammar errors in this text. Return only the corrected text.\n\n{text}",
@@ -164,7 +165,7 @@ async def ai_fix_spelling(text: str, provider: Optional[ProviderName] = None) ->
)
async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_make_shorter(text: str, provider: ProviderName | None = None) -> str:
"""Make text more concise."""
return await _call_deepseek_openrouter(
f"Make this text shorter and more concise while preserving the key information.\n\n{text}",
@@ -172,7 +173,7 @@ async def ai_make_shorter(text: str, provider: Optional[ProviderName] = None) ->
)
async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_make_longer(text: str, provider: ProviderName | None = None) -> str:
"""Expand text with more detail."""
return await _call_deepseek_openrouter(
f"Expand this text with more detail, examples, or explanation while keeping the same tone.\n\n{text}",
@@ -180,7 +181,7 @@ async def ai_make_longer(text: str, provider: Optional[ProviderName] = None) ->
)
async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_simplify(text: str, provider: ProviderName | None = None) -> str:
"""Simplify language."""
return await _call_deepseek_openrouter(
f"Simplify this text. Use clearer, more straightforward language. Avoid jargon.\n\n{text}",
@@ -188,7 +189,7 @@ async def ai_simplify(text: str, provider: Optional[ProviderName] = None) -> str
)
async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName] = None) -> str:
async def ai_change_tone(text: str, tone: str, provider: ProviderName | None = None) -> str:
"""Change the tone of the text."""
return await _call_deepseek_openrouter(
f"Rewrite this text in a {tone} tone. Preserve the original meaning.\n\n{text}",
@@ -196,7 +197,7 @@ async def ai_change_tone(text: str, tone: str, provider: Optional[ProviderName]
)
async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderName] = None) -> str:
async def ai_translate(text: str, target_lang: str, provider: ProviderName | None = None) -> str:
"""Translate text to target language."""
# Gemini is better at translation
if provider is None and PROVIDERS["gemini"]["api_key"]:
@@ -212,7 +213,7 @@ async def ai_translate(text: str, target_lang: str, provider: Optional[ProviderN
)
async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_explain(text: str, provider: ProviderName | None = None) -> str:
"""Explain the selected text."""
return await _call_deepseek_openrouter(
f"Explain the following text clearly and concisely:\n\n{text}",
@@ -220,7 +221,7 @@ async def ai_explain(text: str, provider: Optional[ProviderName] = None) -> str:
)
async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_summarize(text: str, provider: ProviderName | None = None) -> str:
"""Summarize the selected text."""
return await _call_deepseek_openrouter(
f"Summarize the following text concisely:\n\n{text}",
@@ -228,7 +229,7 @@ async def ai_summarize(text: str, provider: Optional[ProviderName] = None) -> st
)
async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_continue_writing(text: str, provider: ProviderName | None = None) -> str:
"""Continue writing from the selected text."""
return await _call_deepseek_openrouter(
f"Continue writing from where this text leaves off. Match the style and tone:\n\n{text}",
@@ -236,7 +237,7 @@ async def ai_continue_writing(text: str, provider: Optional[ProviderName] = None
)
async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[ProviderName] = None) -> str:
async def ai_custom_rewrite(text: str, instruction: str, provider: ProviderName | None = None) -> str:
"""Rewrite text based on a custom instruction."""
return await _call_deepseek_openrouter(
f"Rewrite the following text according to this instruction: {instruction}\n\n{text}",
@@ -244,7 +245,7 @@ async def ai_custom_rewrite(text: str, instruction: str, provider: Optional[Prov
)
async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_convert_to_list(text: str, provider: ProviderName | None = None) -> str:
"""Convert paragraph text to a markdown list."""
return await _call_deepseek_openrouter(
f"Convert this text into a well-organized markdown bullet list. Extract key points.\n\n{text}",
@@ -252,7 +253,7 @@ async def ai_convert_to_list(text: str, provider: Optional[ProviderName] = None)
)
async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_convert_to_table(text: str, provider: ProviderName | None = None) -> str:
"""Convert text to a markdown table."""
return await _call_deepseek_openrouter(
f"Convert this information into a markdown table. Choose appropriate columns.\n\n{text}",
@@ -260,7 +261,7 @@ async def ai_convert_to_table(text: str, provider: Optional[ProviderName] = None
)
async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_generate_frontmatter(text: str, provider: ProviderName | None = None) -> str:
"""Generate YAML frontmatter for a markdown document."""
return await _call_deepseek_openrouter(
f"Generate YAML frontmatter for this markdown document. Include: titre, tags (as list), catégorie, statut, date. Return ONLY the YAML between --- markers.\n\n{text}",
@@ -268,7 +269,7 @@ async def ai_generate_frontmatter(text: str, provider: Optional[ProviderName] =
)
async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_inline_complete(text: str, provider: ProviderName | None = None) -> str:
"""Inline completion — suggest continuation."""
return await _call_deepseek_openrouter(
f"Complete this text naturally. Return only the completion (just the new text, no repetition):\n\n{text}",
@@ -276,7 +277,7 @@ async def ai_inline_complete(text: str, provider: Optional[ProviderName] = None)
)
async def ai_convert_to_canvas(text: str, provider: Optional[ProviderName] = None) -> str:
async def ai_convert_to_canvas(text: str, provider: ProviderName | None = None) -> str:
"""Convert text to a Mermaid diagram or canvas representation."""
return await _call_deepseek_openrouter(
f"Convert this content into a Mermaid.js diagram if applicable, or a structured outline. Choose the best format.\n\n{text}",
+25 -12
View File
@@ -1,16 +1,29 @@
"""ObsiGate AI — API routes for AI-powered editor features."""
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
import logging
from backend.ai import (
ai_improve_writing, ai_fix_spelling, ai_make_shorter, ai_make_longer,
ai_simplify, ai_change_tone, ai_translate, ai_explain, ai_summarize,
ai_continue_writing, ai_custom_rewrite, ai_convert_to_list,
ai_convert_to_table, ai_generate_frontmatter, ai_inline_complete,
ai_convert_to_canvas, DEFAULT_PROVIDER, PROVIDERS,
DEFAULT_PROVIDER,
PROVIDERS,
ai_change_tone,
ai_continue_writing,
ai_convert_to_canvas,
ai_convert_to_list,
ai_convert_to_table,
ai_custom_rewrite,
ai_explain,
ai_fix_spelling,
ai_generate_frontmatter,
ai_improve_writing,
ai_inline_complete,
ai_make_longer,
ai_make_shorter,
ai_simplify,
ai_summarize,
ai_translate,
)
logger = logging.getLogger("obsigate.ai_routes")
@@ -81,10 +94,10 @@ async def api_status():
class AIRequest(BaseModel):
text: str = Field(..., description="Input text to process", min_length=1)
instruction: Optional[str] = Field(None, description="Custom instruction for rewrite")
target_lang: Optional[str] = Field(None, description="Target language for translation")
tone: Optional[str] = Field(None, description="Target tone (professional, casual, etc.)")
provider: Optional[str] = Field(None, description="AI provider override")
instruction: str | None = Field(None, description="Custom instruction for rewrite")
target_lang: str | None = Field(None, description="Target language for translation")
tone: str | None = Field(None, description="Target tone (professional, casual, etc.)")
provider: str | None = Field(None, description="AI provider override")
class AIResponse(BaseModel):
@@ -101,7 +114,7 @@ async def _handle(action, request: AIRequest):
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"AI error: {e}")
raise HTTPException(status_code=500, detail=f"AI service error: {str(e)}")
raise HTTPException(status_code=500, detail=f"AI service error: {e!s}")
@router.post("/improve", response_model=AIResponse)
+17 -17
View File
@@ -1,8 +1,8 @@
import asyncio
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.attachment_indexer")
@@ -10,16 +10,16 @@ logger = logging.getLogger("obsigate.attachment_indexer")
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
attachment_index: Dict[str, Dict[str, List[Path]]] = {}
attachment_index: dict[str, dict[str, list[Path]]] = {}
# Resolution cache: {(vault_name, filename): resolved_path}
_resolution_cache: Dict[tuple, Optional[Path]] = {}
_resolution_cache: dict[tuple, Path | None] = {}
# Thread-safe lock for index updates
_attachment_lock = threading.Lock()
def clear_resolution_cache(vault_name: Optional[str] = None) -> None:
def clear_resolution_cache(vault_name: str | None = None) -> None:
"""Clear the resolution cache for a specific vault or all vaults.
Args:
@@ -29,12 +29,12 @@ def clear_resolution_cache(vault_name: Optional[str] = None) -> None:
if vault_name is None:
_resolution_cache.clear()
else:
keys_to_remove = [k for k in _resolution_cache.keys() if k[0] == vault_name]
keys_to_remove = [k for k in _resolution_cache if k[0] == vault_name]
for key in keys_to_remove:
del _resolution_cache[key]
def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> Dict[str, List[Path]]:
def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict | None = None) -> dict[str, list[Path]]:
"""Synchronously scan a vault directory for image attachments.
Walks the vault tree and builds a filename -> absolute path mapping
@@ -49,7 +49,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict |
Dict mapping lowercase filenames to lists of absolute paths.
"""
vault_root = Path(vault_path)
index: Dict[str, List[Path]] = {}
index: dict[str, list[Path]] = {}
if not vault_root.exists():
logger.warning(f"Vault path does not exist for attachment scan: {vault_path}")
@@ -84,7 +84,7 @@ def _scan_vault_attachments(vault_name: str, vault_path: str, vault_cfg: dict |
return index
async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> None:
async def build_attachment_index(vault_config: dict[str, dict[str, Any]]) -> None:
"""Build the attachment index for all configured vaults.
Runs vault scans concurrently in a thread pool, then performs
@@ -101,9 +101,9 @@ async def build_attachment_index(vault_config: Dict[str, Dict[str, Any]]) -> Non
return
loop = asyncio.get_event_loop()
new_index: Dict[str, Dict[str, List[Path]]] = {}
new_index: dict[str, dict[str, list[Path]]] = {}
tasks: list[tuple[str, asyncio.Future[Dict[str, List[Path]]]]] = []
tasks: list[tuple[str, asyncio.Future[dict[str, list[Path]]]]] = []
for name, config in vault_config.items():
vault_path = config.get("path")
if not vault_path:
@@ -159,9 +159,9 @@ def resolve_image_path(
image_src: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path] = None,
attachments_path: Optional[str] = None
) -> Optional[Path]:
current_file_path: Path | None = None,
attachments_path: str | None = None
) -> Path | None:
"""Resolve an image source path using multi-strategy resolution.
Applies 7 resolution strategies in priority order:
@@ -193,8 +193,8 @@ def resolve_image_path(
resolved = None
# Strategy 1: Absolute path
if src_path.is_absolute():
if src_path.exists() and src_path.is_file():
if src_path.is_absolute() and src_path.exists() and src_path.is_file():
resolved = src_path
logger.debug(f"Image resolved via strategy 1 (absolute): {image_src}")
@@ -279,7 +279,7 @@ def resolve_image_path(
return resolved
def get_attachment_stats(vault_name: Optional[str] = None) -> Dict[str, int]:
def get_attachment_stats(vault_name: str | None = None) -> dict[str, int]:
"""Get attachment statistics for a vault or all vaults.
Args:
+7 -8
View File
@@ -8,11 +8,10 @@ Format: JSON lines (one JSON object per line) for easy parsing.
"""
import json
import os
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger("obsigate.audit")
@@ -51,7 +50,7 @@ def log_file_save(
vault_name: str,
file_path: str,
size: int,
ip: Optional[str] = None,
ip: str | None = None,
):
"""Log a file save (PUT) operation."""
_write_entry({
@@ -69,7 +68,7 @@ def log_file_delete(
username: str,
vault_name: str,
file_path: str,
ip: Optional[str] = None,
ip: str | None = None,
):
"""Log a file delete operation."""
_write_entry({
@@ -85,7 +84,7 @@ def log_file_delete(
def log_config_change(
username: str,
changes: dict,
ip: Optional[str] = None,
ip: str | None = None,
):
"""Log a configuration change."""
_write_entry({
@@ -97,7 +96,7 @@ def log_config_change(
})
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[str] = None):
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
"""Log a vault addition."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
@@ -109,7 +108,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: Optional[
})
def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None):
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
"""Log a vault removal."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
@@ -120,7 +119,7 @@ def log_vault_remove(username: str, vault_name: str, ip: Optional[str] = None):
})
def get_recent_entries(limit: int = 100, action: Optional[str] = None) -> list:
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries.
Args:
+5 -5
View File
@@ -4,14 +4,14 @@
# Revoked token JTIs persisted to data/revoked_tokens.json.
import json
import logging
import os
import secrets
import uuid
import time
import logging
import uuid
from pathlib import Path
from jose import jwt, JWTError
from typing import Optional
from jose import JWTError, jwt
logger = logging.getLogger("obsigate.auth.jwt")
@@ -76,7 +76,7 @@ def create_refresh_token(username: str) -> tuple:
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
def decode_token(token: str) -> Optional[dict]:
def decode_token(token: str) -> dict | None:
"""Decode and validate a JWT. Returns None if invalid/expired."""
try:
return jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM])
+6 -6
View File
@@ -2,11 +2,11 @@
# FastAPI dependencies for authentication and authorization.
# Reads JWT from Authorization header OR access_token cookie.
import os
import logging
from fastapi import Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional
import os
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .jwt_handler import decode_token
from .user_store import get_user
@@ -26,8 +26,8 @@ def is_auth_enabled() -> bool:
def get_current_user(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
) -> Optional[dict]:
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> dict | None:
"""Extract and validate the current user from JWT.
Reads token from Authorization header first, falls back to access_token cookie.
+1 -1
View File
@@ -3,7 +3,7 @@
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
from argon2.exceptions import VerificationError, VerifyMismatchError
ph = PasswordHasher(
time_cost=2,
+34 -21
View File
@@ -2,24 +2,37 @@
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
# and admin user CRUD.
import re
import logging
from fastapi import APIRouter, HTTPException, Response, Request, Depends
import re
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator
from typing import List, Optional
from backend.ratelimit import is_rate_limited
from backend.ratelimit import record_failure as rl_record_failure
from backend.ratelimit import record_success as rl_record_success
from .user_store import (
get_user, get_all_users, create_user, update_user, delete_user,
record_login_success, record_login_failure, is_locked, has_users,
)
from .jwt_handler import (
create_access_token, create_refresh_token, decode_token,
revoke_token, is_token_revoked,
ACCESS_TOKEN_EXPIRE_SECONDS,
create_access_token,
create_refresh_token,
decode_token,
is_token_revoked,
revoke_token,
)
from .middleware import is_auth_enabled, require_admin, require_auth
from .password import hash_password, verify_password
from .user_store import (
create_user,
delete_user,
get_all_users,
get_user,
has_users,
is_locked,
record_login_failure,
record_login_success,
update_user,
)
from .middleware import require_auth, require_admin, is_auth_enabled
from .password import verify_password, hash_password
from backend.ratelimit import is_rate_limited, record_failure as rl_record_failure, record_success as rl_record_success
logger = logging.getLogger("obsigate.auth.router")
@@ -48,9 +61,9 @@ class ChangePasswordRequest(BaseModel):
class CreateUserRequest(BaseModel):
username: str
password: str
display_name: Optional[str] = None
display_name: str | None = None
role: str = "user"
vaults: List[str] = []
vaults: list[str] = []
@validator("username")
def username_valid(cls, v):
@@ -66,11 +79,11 @@ class CreateUserRequest(BaseModel):
class UpdateUserRequest(BaseModel):
display_name: Optional[str] = None
vaults: Optional[List[str]] = None
active: Optional[bool] = None
password: Optional[str] = None
role: Optional[str] = None
display_name: str | None = None
vaults: list[str] | None = None
active: bool | None = None
password: str | None = None
role: str | None = None
# ── Public endpoints ──────────────────────────────────────────────────
@@ -249,8 +262,8 @@ async def get_me(current_user=Depends(require_auth)):
class UpdateMeRequest(BaseModel):
"""Fields the user can update on their own profile."""
display_name: Optional[str] = None
language: Optional[str] = None
display_name: str | None = None
language: str | None = None
@router.patch("/me")
+6 -7
View File
@@ -4,12 +4,11 @@
# via tmp+rename to prevent corruption on crash.
import json
import uuid
import logging
import shutil
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from .password import hash_password
@@ -54,12 +53,12 @@ def has_users() -> bool:
return bool(_read()["users"])
def get_user(username: str) -> Optional[dict]:
def get_user(username: str) -> dict | None:
"""Get a user by username. Returns None if not found."""
return _read()["users"].get(username)
def get_all_users() -> List[dict]:
def get_all_users() -> list[dict]:
"""Get all users WITHOUT password_hash (safe for API responses)."""
users = _read()["users"]
return [
@@ -72,8 +71,8 @@ def create_user(
username: str,
password: str,
role: str = "user",
vaults: Optional[List[str]] = None,
display_name: Optional[str] = None,
vaults: list[str] | None = None,
display_name: str | None = None,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
data = _read()
+3 -2
View File
@@ -11,14 +11,15 @@ Docker usage:
docker exec obsigate python backend/create_admin.py list
"""
import sys
import argparse
import sys
# Add parent directory to path for imports
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from backend.auth.user_store import create_user, get_all_users, delete_user
from backend.auth.user_store import create_user, delete_user, get_all_users
def main():
+6 -6
View File
@@ -1,10 +1,10 @@
# backend/history.py
import json
import time
import logging
import shutil
import time
from pathlib import Path
from typing import List, Dict, Any, Optional
from typing import Any
logger = logging.getLogger("obsigate.history")
@@ -18,7 +18,7 @@ def _get_bookmarks_file(username: str) -> Path:
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
return HISTORY_DIR / f"{username}_bookmarks.json"
def _read_data(file: Path) -> List[Dict[str, Any]]:
def _read_data(file: Path) -> list[dict[str, Any]]:
if not file.exists():
return []
try:
@@ -27,7 +27,7 @@ def _read_data(file: Path) -> List[Dict[str, Any]]:
logger.error(f"Failed to read data from {file.name}: {e}")
return []
def _write_data(file: Path, data: List[Dict[str, Any]]):
def _write_data(file: Path, data: list[dict[str, Any]]):
try:
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
@@ -58,7 +58,7 @@ def record_open(username: str, vault: str, path: str, title: str = ""):
_write_data(_get_history_file(username), history)
def get_recent_opened(username: str, vault_filter: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
def get_recent_opened(username: str, vault_filter: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
"""Get the most recently opened files for a user."""
if not username:
return []
@@ -105,7 +105,7 @@ def toggle_bookmark(username: str, vault: str, path: str, title: str = ""):
_write_data(b_file, bookmarks)
return True
def get_bookmarks(username: str, vault_filter: Optional[str] = None) -> List[Dict[str, Any]]:
def get_bookmarks(username: str, vault_filter: str | None = None) -> list[dict[str, Any]]:
"""Get the bookmarks for a user."""
if not username:
return []
+10 -11
View File
@@ -1,8 +1,7 @@
import re
import logging
from pathlib import Path
from typing import Optional
import re
from html import escape as html_escape
from pathlib import Path
from backend.attachment_indexer import resolve_image_path
@@ -13,8 +12,8 @@ def preprocess_images(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path] = None,
attachments_path: Optional[str] = None
current_file_path: Path | None = None,
attachments_path: str | None = None
) -> str:
"""Preprocess markdown content to transform all Obsidian image syntaxes.
@@ -55,8 +54,8 @@ def _process_html_img_in_link(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path],
attachments_path: Optional[str]
current_file_path: Path | None,
attachments_path: str | None
) -> str:
"""Process [<img ... src="path"/>](url) syntax.
@@ -119,8 +118,8 @@ def _process_wikilink_embeds(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path],
attachments_path: Optional[str]
current_file_path: Path | None,
attachments_path: str | None
) -> str:
"""Process ![[image]] and ![[path/to/image]] wiki-link embeds.
@@ -158,8 +157,8 @@ def _process_standard_images(
content: str,
vault_name: str,
vault_root: Path,
current_file_path: Optional[Path],
attachments_path: Optional[str]
current_file_path: Path | None,
attachments_path: str | None
) -> str:
"""Process ![alt](path) standard markdown images.
+41 -40
View File
@@ -1,21 +1,22 @@
import os
import asyncio
import logging
import os
import re
import threading
from pathlib import Path
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
from pathlib import Path
from typing import Any
import frontmatter
logger = logging.getLogger("obsigate.indexer")
# Global in-memory index
index: Dict[str, Dict[str, Any]] = {}
index: dict[str, dict[str, Any]] = {}
# Vault config: {name: {path, attachmentsPath, scanAttachmentsOnStartup}}
vault_config: Dict[str, Dict[str, Any]] = {}
vault_config: dict[str, dict[str, Any]] = {}
# Thread-safe lock for index updates
_index_lock = threading.Lock()
@@ -41,13 +42,13 @@ def set_index_change_hook(hook):
_on_index_change = hook
# O(1) lookup table for wikilink resolution: {filename_lower: [{vault, path}, ...]}
_file_lookup: Dict[str, List[Dict[str, str]]] = {}
_file_lookup: dict[str, list[dict[str, str]]] = {}
# Backlink index: {vault_name: {relative_path: [{vault, path, title}, ...]}}
_backlink_index: Dict[str, Dict[str, List[Dict[str, str]]]] = {}
_backlink_index: dict[str, dict[str, list[dict[str, str]]]] = {}
# O(1) path index for tree filtering: {vault_name: [{path, name, type}, ...]}
path_index: Dict[str, List[Dict[str, str]]] = {}
path_index: dict[str, list[dict[str, str]]] = {}
# Maximum content size stored per file for in-memory search (bytes)
SEARCH_CONTENT_LIMIT = 100_000
@@ -68,10 +69,10 @@ SUPPORTED_EXTENSIONS = {
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
_DEFAULT_IGNORED = {'.obsidian', '.trash', '.git', '__pycache__', 'node_modules', '.obsigate-backup'}
_env_ignored = os.environ.get("OBSIGATE_IGNORED_DIRS", "")
IGNORED_DIRS = set(d.strip() for d in _env_ignored.split(",") if d.strip()) if _env_ignored else _DEFAULT_IGNORED.copy()
IGNORED_DIRS = {d.strip() for d in _env_ignored.split(",") if d.strip()} if _env_ignored else _DEFAULT_IGNORED.copy()
def load_vault_config() -> Dict[str, Dict[str, Any]]:
def load_vault_config() -> dict[str, dict[str, Any]]:
"""Read VAULT_N_* and DIR_N_* env vars and return vault configuration.
Scans environment variables ``VAULT_1_NAME``/``VAULT_1_PATH``,
@@ -89,7 +90,7 @@ def load_vault_config() -> Dict[str, Dict[str, Any]]:
- scanAttachmentsOnStartup: boolean (default True)
- type: "VAULT" or "DIR"
"""
vaults: Dict[str, Dict[str, Any]] = {}
vaults: dict[str, dict[str, Any]] = {}
n = 1
while True:
name = os.environ.get(f"VAULT_{n}_NAME")
@@ -135,7 +136,7 @@ _CODE_BLOCK_RE = re.compile(r'```[\s\S]*?```', re.MULTILINE)
_INLINE_CODE_RE = re.compile(r'`[^`]+`')
def _extract_tags(post: frontmatter.Post) -> List[str]:
def _extract_tags(post: frontmatter.Post) -> list[str]:
"""Extract tags from frontmatter metadata.
@@ -158,7 +159,7 @@ def _extract_tags(post: frontmatter.Post) -> List[str]:
return tags
def _extract_inline_tags(content: str) -> List[str]:
def _extract_inline_tags(content: str) -> list[str]:
"""Extract inline #tag patterns from markdown content.
Strips fenced and inline code blocks before scanning to avoid
@@ -215,10 +216,10 @@ def parse_markdown_file(raw: str) -> frontmatter.Post:
match = re.match(r"^---\s*\r?\n.*?\r?\n---\s*\r?\n?", raw, flags=re.DOTALL)
if match:
content = raw[match.end():]
return frontmatter.Post(content, **{})
return frontmatter.Post(content)
def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any]:
"""Synchronously scan a single vault directory and build file index.
Walks the vault tree, reads supported files, extracts metadata
@@ -236,9 +237,9 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
Dict with keys ``files`` (list), ``tags`` (counter dict), ``path`` (str), ``paths`` (list).
"""
vault_root = Path(vault_path)
files: List[Dict[str, Any]] = []
tag_counts: Dict[str, int] = {}
paths: List[Dict[str, str]] = []
files: list[dict[str, Any]] = []
tag_counts: dict[str, int] = {}
paths: list[dict[str, str]] = []
if not vault_root.exists():
logger.warning(f"Vault path does not exist: {vault_path}")
@@ -283,15 +284,15 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: Optional[Dict[str,
# PDF handling — special path (binary, uses pdf_reader)
if ext == ".pdf":
from backend.pdf_reader import extract_pdf_text, extract_pdf_metadata
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
raw = extract_pdf_text(fpath, max_chars=100000)
pdf_meta = extract_pdf_metadata(fpath)
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
tags: List[str] = []
tags: list[str] = []
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
tags: List[str] = []
tags: list[str] = []
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
@@ -365,13 +366,13 @@ async def build_index(progress_callback=None) -> None:
loop = asyncio.get_event_loop()
async def _process_vault(name: str, config: Dict[str, Any]):
async def _process_vault(name: str, config: dict[str, Any]):
vault_path = config["path"]
vault_data = await loop.run_in_executor(None, _scan_vault, name, vault_path, config)
vault_data["config"] = config
# Build lookup entries for the new vault
new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
new_lookup_entries: dict[str, list[dict[str, str]]] = {}
for f in vault_data["files"]:
entry = {"vault": name, "path": f["path"]}
fname = f["path"].rsplit("/", 1)[-1].lower()
@@ -419,7 +420,7 @@ async def build_index(progress_callback=None) -> None:
await progress_callback("complete", {"total_vaults": len(vault_config), "total_files": total_files})
async def reload_index() -> Dict[str, Any]:
async def reload_index() -> dict[str, Any]:
"""Force a full re-index of all vaults and return per-vault statistics.
Returns:
@@ -432,7 +433,7 @@ async def reload_index() -> Dict[str, Any]:
return stats
async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
async def reload_single_vault(vault_name: str) -> dict[str, Any]:
"""Force a re-index of a single vault and return its statistics.
Args:
@@ -464,7 +465,7 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
vault_data["config"] = config
# Build lookup entries for the vault
new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
new_lookup_entries: dict[str, list[dict[str, str]]] = {}
for f in vault_data["files"]:
entry = {"vault": vault_name, "path": f["path"]}
fname = f["path"].rsplit("/", 1)[-1].lower()
@@ -495,12 +496,12 @@ async def reload_single_vault(vault_name: str) -> Dict[str, Any]:
return stats
def get_vault_names() -> List[str]:
def get_vault_names() -> list[str]:
"""Return the list of all indexed vault names."""
return list(index.keys())
def get_vault_data(vault_name: str) -> Optional[Dict[str, Any]]:
def get_vault_data(vault_name: str) -> dict[str, Any] | None:
"""Return the full index data for a vault, or ``None`` if not found."""
return index.get(vault_name)
@@ -513,7 +514,7 @@ def _get_async_lock() -> asyncio.Lock:
return _async_index_lock
def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, vault_cfg: dict[str, Any] | None = None) -> dict[str, Any] | None:
"""Synchronously read and parse a single file for indexing.
All files are indexed, including hidden files (starting with '.').
@@ -545,7 +546,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
raw = fpath.read_text(encoding="utf-8", errors="replace")
tags: List[str] = []
tags: list[str] = []
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
@@ -575,7 +576,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
return None
def _remove_file_from_structures(vault_name: str, rel_path: str) -> Optional[Dict[str, Any]]:
def _remove_file_from_structures(vault_name: str, rel_path: str) -> dict[str, Any] | None:
"""Remove a file from all index structures. Returns removed file info or None.
Must be called under _index_lock or _async_index_lock.
@@ -650,7 +651,7 @@ def _ensure_parent_dirs_in_path_index(vault_name: str, rel_path: str, existing:
})
def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
def _add_file_to_structures(vault_name: str, file_info: dict[str, Any]):
"""Add a file entry to all index structures.
Must be called under _index_lock or _async_index_lock.
@@ -695,7 +696,7 @@ def _add_file_to_structures(vault_name: str, file_info: Dict[str, Any]):
_on_index_change('add', vault_name, file_info["path"], file_info) # type: ignore[misc]
async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]:
async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None:
"""Re-index a single file without full rebuild.
Reads the file, removes the old entry if present, inserts the new one.
@@ -742,7 +743,7 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> Optional[Di
return file_info
async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Dict[str, Any]]:
async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, Any] | None:
"""Remove a single file from the index.
Args:
@@ -774,7 +775,7 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> Optional[Di
return removed
async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> Optional[Dict[str, Any]]:
async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict[str, Any] | None:
"""Handle a file move/rename by removing old entry and indexing new location.
Args:
@@ -823,7 +824,7 @@ async def remove_vault_from_index(vault_name: str):
logger.info(f"Removed vault '{vault_name}' from index")
async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]:
async def add_vault_to_index(vault_name: str, vault_path: str) -> dict[str, Any]:
"""Add a new vault to the index dynamically.
Args:
@@ -846,7 +847,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]
vault_data["config"] = vault_config[vault_name]
# Build lookup entries for the new vault
new_lookup_entries: Dict[str, List[Dict[str, str]]] = {}
new_lookup_entries: dict[str, list[dict[str, str]]] = {}
for f in vault_data["files"]:
entry = {"vault": vault_name, "path": f["path"]}
fname = f["path"].rsplit("/", 1)[-1].lower()
@@ -871,7 +872,7 @@ async def add_vault_to_index(vault_name: str, vault_path: str) -> Dict[str, Any]
return stats
def find_file_in_index(link_target: str, current_vault: str) -> Optional[Dict[str, str]]:
def find_file_in_index(link_target: str, current_vault: str) -> dict[str, str] | None:
"""Find a file matching a wikilink target using O(1) lookup table.
Searches by filename first, then by full relative path.
@@ -943,7 +944,7 @@ def _extract_wikilinks_for_backlinks(
})
def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
def get_backlinks(vault_name: str, file_path: str) -> list[dict[str, str]]:
"""Get all files that link to the given file via wikilinks.
Searches across all vaults for backlinks pointing to the target file.
@@ -961,7 +962,7 @@ def get_backlinks(vault_name: str, file_path: str) -> List[Dict[str, str]]:
target_key += ".md"
results = []
for vname, vindex in _backlink_index.items():
for vindex in _backlink_index.values():
bl = vindex.get(target_key, [])
results.extend(bl)
return results
+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}")
# ---------------------------------------------------------------------------
+1 -2
View File
@@ -6,14 +6,13 @@ Used by both the authenticated API and public share views.
"""
import logging
from typing import Optional
from weasyprint import HTML
logger = logging.getLogger("obsigate.pdf")
def generate_pdf(html_content: str, title: str = "document", base_url: Optional[str] = None) -> bytes:
def generate_pdf(html_content: str, title: str = "document", base_url: str | None = None) -> bytes:
"""Generate a PDF from HTML content.
Args:
+3 -4
View File
@@ -10,11 +10,10 @@ Configuration via environment variables:
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900 = 15min)
"""
import logging
import os
import time
import logging
from collections import defaultdict
from typing import Dict, Tuple
logger = logging.getLogger("obsigate.ratelimit")
@@ -23,7 +22,7 @@ MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_LOGIN_MAX_ATTEMPTS", "10"))
WINDOW_SECONDS = int(os.environ.get("OBSIGATE_LOGIN_WINDOW_SECONDS", "900")) # 15 min
# --- In-memory store: {ip: [(timestamp, success_bool), ...]} ---
_ip_attempts: Dict[str, list] = defaultdict(list)
_ip_attempts: dict[str, list] = defaultdict(list)
_last_cleanup = time.time()
CLEANUP_INTERVAL = 60 # seconds
@@ -45,7 +44,7 @@ def _cleanup_expired():
del _ip_attempts[ip]
def record_failure(ip: str) -> Tuple[int, int]:
def record_failure(ip: str) -> tuple[int, int]:
"""Record a failed login attempt from an IP.
Returns:
+5 -5
View File
@@ -5,11 +5,11 @@ Stored in data/saved_searches.json per user.
"""
import json
import time
import logging
import shutil
import time
from pathlib import Path
from typing import List, Dict, Any
from typing import Any
logger = logging.getLogger("obsigate.saved_searches")
@@ -21,7 +21,7 @@ def _get_file(username: str) -> Path:
return DATA_DIR / f"{username}_saved_searches.json"
def _read(file: Path) -> List[Dict[str, Any]]:
def _read(file: Path) -> list[dict[str, Any]]:
if not file.exists():
return []
try:
@@ -30,13 +30,13 @@ def _read(file: Path) -> List[Dict[str, Any]]:
return []
def _write(file: Path, data: List[Dict[str, Any]]):
def _write(file: Path, data: list[dict[str, Any]]):
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
shutil.move(str(tmp), str(file))
def get_saved(username: str) -> List[Dict[str, Any]]:
def get_saved(username: str) -> list[dict[str, Any]]:
return _read(_get_file(username))
+60 -64
View File
@@ -1,13 +1,13 @@
from sortedcontainers import SortedList
import logging
import math
import re
import time
import unicodedata
from collections import defaultdict
from typing import List, Dict, Any, Optional, Tuple
from typing import Any
from snowballstemmer import stemmer as _snowball_stemmer
from sortedcontainers import SortedList
from backend import indexer as _indexer
from backend.indexer import index
@@ -86,7 +86,7 @@ def normalize_text(text: str) -> str:
return stripped.lower()
def tokenize(text: str) -> List[str]:
def tokenize(text: str) -> list[str]:
"""Split text into normalized tokens (accent-stripped, lowercased words).
Args:
@@ -101,7 +101,7 @@ def tokenize(text: str) -> List[str]:
# ---------------------------------------------------------------------------
# Tag filter helper (unchanged for backward compat)
# ---------------------------------------------------------------------------
def _normalize_tag_filter(tag_filter: Optional[str]) -> List[str]:
def _normalize_tag_filter(tag_filter: str | None) -> list[str]:
"""Parse a comma-separated tag filter string into a clean list.
Strips whitespace and leading ``#`` from each tag.
@@ -154,7 +154,7 @@ def _extract_snippet(content: str, query: str, context_chars: int = SNIPPET_CONT
def _extract_highlighted_snippet(
content: str,
query_terms: List[str],
query_terms: list[str],
context_chars: int = SNIPPET_CONTEXT_CHARS,
max_highlights: int = MAX_SNIPPET_HIGHLIGHTS,
) -> str:
@@ -256,14 +256,13 @@ def _extract_regex_snippet(
parts.append(_escape_html(snippet[prev:m.start()]))
parts.append(f"<mark>{_escape_html(snippet[m.start():m.end()])}</mark>")
prev = m.end()
count += 1
if prev < len(snippet):
parts.append(_escape_html(snippet[prev:]))
return prefix + "".join(parts) + suffix
def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
def _highlight_terms(text: str, terms: list[str], max_highlights: int) -> str:
"""Wrap occurrences of *terms* in *text* with ``<mark>`` tags.
Uses accent-normalized comparison so diacritical variants are matched.
@@ -282,7 +281,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
norm = normalize_text(text)
# Collect (start, end) spans for all term matches
spans: List[Tuple[int, int]] = []
spans: list[tuple[int, int]] = []
for term in terms:
idx = 0
while idx < len(norm):
@@ -297,7 +296,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
# Merge overlapping spans and limit count
spans.sort()
merged: List[Tuple[int, int]] = [spans[0]]
merged: list[tuple[int, int]] = [spans[0]]
for s, e in spans[1:]:
if s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
@@ -306,7 +305,7 @@ def _highlight_terms(text: str, terms: List[str], max_highlights: int) -> str:
merged = merged[:max_highlights]
# Build result with highlights
parts: List[str] = []
parts: list[str] = []
prev = 0
for s, e in merged:
if s > prev:
@@ -348,17 +347,17 @@ class InvertedIndex:
"""
def __init__(self) -> None:
self.word_index: Dict[str, Dict[str, int]] = defaultdict(dict)
self.title_index: Dict[str, List[str]] = defaultdict(list)
self.tag_norm_map: Dict[str, str] = {}
self.tag_prefix_index: Dict[str, List[str]] = defaultdict(list)
self.title_norm_map: Dict[str, List[Dict[str, str]]] = defaultdict(list)
self.word_index: dict[str, dict[str, int]] = defaultdict(dict)
self.title_index: dict[str, list[str]] = defaultdict(list)
self.tag_norm_map: dict[str, str] = {}
self.tag_prefix_index: dict[str, list[str]] = defaultdict(list)
self.title_norm_map: dict[str, list[dict[str, str]]] = defaultdict(list)
self.doc_count: int = 0
self.doc_info: Dict[str, Dict[str, Any]] = {}
self.doc_vault: Dict[str, str] = {}
self.vault_docs: Dict[str, set] = defaultdict(set)
self.tag_docs: Dict[str, set] = defaultdict(set)
self._sorted_tokens: "SortedList" = SortedList()
self.doc_info: dict[str, dict[str, Any]] = {}
self.doc_vault: dict[str, str] = {}
self.vault_docs: dict[str, set] = defaultdict(set)
self.tag_docs: dict[str, set] = defaultdict(set)
self._sorted_tokens: SortedList = SortedList()
self._ready: bool = False # True after initial build
def is_stale(self) -> bool:
@@ -415,12 +414,12 @@ class InvertedIndex:
content = file_info.get("content", "")
full_text = (file_info.get("title", "") + " " + content)
tokens = tokenize(full_text)
tf: Dict[str, int] = defaultdict(int)
tf: dict[str, int] = defaultdict(int)
for token in tokens:
tf[token] += 1
# Also index stemmed forms (French stemming)
# Compute stem frequencies in one pass instead of O(S×T)
stem_freqs: Dict[str, int] = defaultdict(int)
stem_freqs: dict[str, int] = defaultdict(int)
for token, freq in tf.items():
stemmed = stem_token(token)
if stemmed != token: # only index stem if different
@@ -489,12 +488,12 @@ class InvertedIndex:
content = file_info.get("content", "")
full_text = title + " " + content
tokens = tokenize(full_text)
tf: Dict[str, int] = defaultdict(int)
tf: dict[str, int] = defaultdict(int)
for token in tokens:
if token:
tf[token] += 1
# Also compute stems in one pass
stem_freqs: Dict[str, int] = defaultdict(int)
stem_freqs: dict[str, int] = defaultdict(int)
for token, freq in tf.items():
stemmed = stem_token(token)
if stemmed != token:
@@ -615,7 +614,7 @@ class InvertedIndex:
return 0.0
return tf * self.idf(term)
def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> List[str]:
def get_prefix_tokens(self, prefix: str, max_expansions: int = 50) -> list[str]:
"""Get all tokens starting with *prefix* using binary search.
Uses a pre-sorted token list for O(log V + k) lookup instead
@@ -631,7 +630,7 @@ class InvertedIndex:
if not prefix or not self._sorted_tokens:
return []
lo = self._sorted_tokens.bisect_left(prefix)
results: List[str] = []
results: list[str] = []
for i in range(lo, len(self._sorted_tokens)):
if self._sorted_tokens[i].startswith(prefix):
results.append(self._sorted_tokens[i])
@@ -688,9 +687,9 @@ def get_inverted_index() -> InvertedIndex:
def search(
query: str,
vault_filter: str = "all",
tag_filter: Optional[str] = None,
tag_filter: str | None = None,
limit: int = DEFAULT_SEARCH_LIMIT,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Full-text search across indexed vaults with relevance scoring.
Scoring heuristics (when a text query is provided):
@@ -722,7 +721,7 @@ def search(
return []
query_lower = query.lower()
results: List[Dict[str, Any]] = []
results: list[dict[str, Any]] = []
for vault_name, vault_data in index.items():
if vault_filter != "all" and vault_name != vault_filter:
@@ -786,7 +785,7 @@ def search(
# ---------------------------------------------------------------------------
# Advanced search with TF-IDF scoring
# ---------------------------------------------------------------------------
def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
def _parse_advanced_query(raw_query: str) -> dict[str, Any]:
"""Parse an advanced query string into structured filters and free text.
Supported operators:
@@ -803,7 +802,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
Returns:
Dict with keys ``tags``, ``vault``, ``title``, ``path``, ``ext``, ``terms``.
"""
parsed: Dict[str, Any] = {
parsed: dict[str, Any] = {
"tags": [],
"vault": None,
"title": None,
@@ -838,7 +837,7 @@ def _parse_advanced_query(raw_query: str) -> Dict[str, Any]:
return parsed
def _split_query_tokens(raw: str) -> List[str]:
def _split_query_tokens(raw: str) -> list[str]:
"""Split a query string respecting quoted phrases.
``tag:"my tag" hello world`` → ``['tag:my tag', 'hello', 'world']``
@@ -849,7 +848,7 @@ def _split_query_tokens(raw: str) -> List[str]:
Returns:
List of token strings.
"""
tokens: List[str] = []
tokens: list[str] = []
i = 0
n = len(raw)
while i < n:
@@ -890,14 +889,14 @@ def _split_query_tokens(raw: str) -> List[str]:
def _passes_search_filters(
file_info: dict,
query_terms: List[str],
query_terms_raw: List[str],
query_terms: list[str],
query_terms_raw: list[str],
raw_query: str,
case_sensitive: bool,
whole_word: bool,
regex: bool,
include_paths: Optional[str],
exclude_paths: Optional[str],
include_paths: str | None,
exclude_paths: str | None,
) -> bool:
"""Post-filter a candidate by case-sensitive, whole-word, regex, and path filters."""
title = file_info.get("title", "")
@@ -937,7 +936,7 @@ def _passes_search_filters(
return _passes_path_filters(path, include_paths, exclude_paths)
def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[str]) -> bool:
def _passes_path_filters(path: str, include: str | None, exclude: str | None) -> bool:
"""Check if a file path passes include/exclude glob patterns."""
import fnmatch
if include:
@@ -955,7 +954,7 @@ def _passes_path_filters(path: str, include: Optional[str], exclude: Optional[st
# Date and size filter helpers
# ---------------------------------------------------------------------------
def _parse_date_range(raw: Optional[str]) -> Optional[tuple]:
def _parse_date_range(raw: str | None) -> tuple | None:
"""Parse a date range filter string.
Supported formats:
@@ -1008,7 +1007,7 @@ def _parse_date_range(raw: Optional[str]) -> Optional[tuple]:
return None
def _parse_date_to_ts(s: str) -> Optional[float]:
def _parse_date_to_ts(s: str) -> float | None:
"""Parse a date string to Unix timestamp. Supports YYYY-MM-DD."""
s = s.strip()
for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y'):
@@ -1020,14 +1019,14 @@ def _parse_date_to_ts(s: str) -> Optional[float]:
return None
def _matches_date_range(file_ts: Optional[float], date_range: tuple) -> bool:
def _matches_date_range(file_ts: float | None, date_range: tuple) -> bool:
"""Check if a file timestamp falls within the given range."""
if file_ts is None:
return False
return date_range[0] <= file_ts <= date_range[1]
def _parse_size_range(raw: Optional[str]) -> Optional[tuple]:
def _parse_size_range(raw: str | None) -> tuple | None:
"""Parse a size range filter string.
Supported formats:
@@ -1040,7 +1039,7 @@ def _parse_size_range(raw: Optional[str]) -> Optional[tuple]:
return None
raw = raw.strip()
def _parse_size(s: str) -> Optional[int]:
def _parse_size(s: str) -> int | None:
s = s.strip().upper()
mult = 1
if s.endswith('GB'):
@@ -1052,10 +1051,7 @@ def _parse_size_range(raw: Optional[str]) -> Optional[tuple]:
elif s.endswith('KB'):
mult = 1024
s = s[:-2]
elif s.endswith('B'):
mult = 1
s = s[:-1]
elif s.endswith('O'):
elif s.endswith('B') or s.endswith('O'):
mult = 1
s = s[:-1]
try:
@@ -1094,19 +1090,19 @@ def _matches_size_range(file_size: int, size_range: tuple) -> bool:
def advanced_search(
query: str,
vault_filter: str = "all",
tag_filter: Optional[str] = None,
tag_filter: str | None = None,
limit: int = ADVANCED_SEARCH_DEFAULT_LIMIT,
offset: int = 0,
sort_by: str = "relevance",
case_sensitive: bool = False,
whole_word: bool = False,
regex: bool = False,
include_paths: Optional[str] = None,
exclude_paths: Optional[str] = None,
created: Optional[str] = None,
modified: Optional[str] = None,
size: Optional[str] = None,
) -> Dict[str, Any]:
include_paths: str | None = None,
exclude_paths: str | None = None,
created: str | None = None,
modified: str | None = None,
size: str | None = None,
) -> dict[str, Any]:
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
Uses the inverted index for O(k × postings) candidate retrieval instead
@@ -1242,12 +1238,12 @@ def advanced_search(
# ------------------------------------------------------------------
# Step 3: Score only the candidates (not all N documents)
# ------------------------------------------------------------------
scored_results: List[Tuple[float, Dict[str, Any]]] = []
facet_tags: Dict[str, int] = defaultdict(int)
facet_vaults: Dict[str, int] = defaultdict(int)
scored_results: list[tuple[float, dict[str, Any]]] = []
facet_tags: dict[str, int] = defaultdict(int)
facet_vaults: dict[str, int] = defaultdict(int)
# Pre-compute prefix expansions once per term (avoid repeated binary search)
prefix_expansions: Dict[str, List[str]] = {}
prefix_expansions: dict[str, list[str]] = {}
if has_terms:
for term in query_terms:
if len(term) >= MIN_PREFIX_LENGTH:
@@ -1357,7 +1353,7 @@ def suggest_titles(
prefix: str,
vault_filter: str = "all",
limit: int = SUGGEST_LIMIT,
) -> List[Dict[str, str]]:
) -> list[dict[str, str]]:
"""Suggest file titles matching a prefix (accent-insensitive).
Args:
@@ -1373,7 +1369,7 @@ def suggest_titles(
inv = get_inverted_index()
norm_prefix = normalize_text(prefix)
results: List[Dict[str, str]] = []
results: list[dict[str, str]] = []
seen: set = set()
for norm_title, entries in inv.title_norm_map.items():
@@ -1395,7 +1391,7 @@ def suggest_tags(
prefix: str,
vault_filter: str = "all",
limit: int = SUGGEST_LIMIT,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
"""Suggest tags matching a prefix (accent-insensitive).
Args:
@@ -1413,7 +1409,7 @@ def suggest_tags(
norm_prefix = normalize_text(prefix)
all_tag_counts = get_all_tags(vault_filter)
matches: List[Dict[str, Any]] = []
matches: list[dict[str, Any]] = []
for tag, count in all_tag_counts.items():
norm_tag = normalize_text(tag)
if norm_prefix in norm_tag:
@@ -1427,7 +1423,7 @@ def suggest_tags(
# ---------------------------------------------------------------------------
# Backward-compatible tag aggregation (unchanged API)
# ---------------------------------------------------------------------------
def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
"""Aggregate tag counts across vaults, sorted by descending count.
Args:
@@ -1436,7 +1432,7 @@ def get_all_tags(vault_filter: Optional[str] = None) -> Dict[str, int]:
Returns:
Dict mapping tag names to their total occurrence count.
"""
merged: Dict[str, int] = {}
merged: dict[str, int] = {}
for vault_name, vault_data in index.items():
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
continue
+1 -1
View File
@@ -13,8 +13,8 @@ Patterns detected:
- Connection strings with passwords
"""
import re
import logging
import re
logger = logging.getLogger("obsigate.redactor")
+6 -7
View File
@@ -8,11 +8,10 @@ No authentication required for public share views.
"""
import json
import secrets
from pathlib import Path
from datetime import datetime, timezone, timedelta
from typing import Optional
import logging
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
logger = logging.getLogger("obsigate.share")
@@ -39,7 +38,7 @@ def create_share(
vault: str,
path: str,
created_by: str,
expires_in_hours: Optional[int] = None,
expires_in_hours: int | None = None,
) -> dict:
"""Create a new share token for a document."""
data = _read()
@@ -66,7 +65,7 @@ def create_share(
return share
def get_share_by_token(token: str) -> Optional[dict]:
def get_share_by_token(token: str) -> dict | None:
"""Look up a share by token. Returns None if expired or not found."""
data = _read()
share = data["shares"].get(token)
@@ -100,7 +99,7 @@ def revoke_share(share_id: str) -> bool:
return False
def list_shares(vault_filter: Optional[str] = None) -> list:
def list_shares(vault_filter: str | None = None) -> list:
"""List all shares, optionally filtered by vault."""
data = _read()
shares = list(data["shares"].values())
+7 -7
View File
@@ -10,9 +10,9 @@ Current settings:
import json
import logging
from pathlib import Path
from typing import Dict, Any, Optional
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.vault_settings")
@@ -21,10 +21,10 @@ _SETTINGS_PATH = _BASE_DIR / "data" / "vault_settings.json"
_settings_lock = threading.RLock()
# In-memory cache of vault settings
_vault_settings: Dict[str, Dict[str, Any]] = {}
_vault_settings: dict[str, dict[str, Any]] = {}
def load_vault_settings() -> Dict[str, Dict[str, Any]]:
def load_vault_settings() -> dict[str, dict[str, Any]]:
"""Load vault settings from disk.
Returns:
@@ -78,7 +78,7 @@ def save_vault_settings() -> None:
raise
def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]:
def get_vault_setting(vault_name: str) -> dict[str, Any] | None:
"""Get settings for a specific vault.
Args:
@@ -91,7 +91,7 @@ def get_vault_setting(vault_name: str) -> Optional[Dict[str, Any]]:
return _vault_settings.get(vault_name)
def update_vault_setting(vault_name: str, settings: Dict[str, Any]) -> Dict[str, Any]:
def update_vault_setting(vault_name: str, settings: dict[str, Any]) -> dict[str, Any]:
"""Update settings for a specific vault.
Args:
@@ -128,7 +128,7 @@ def delete_vault_setting(vault_name: str) -> bool:
return False
def get_all_vault_settings() -> Dict[str, Dict[str, Any]]:
def get_all_vault_settings() -> dict[str, dict[str, Any]]:
"""Get all vault settings.
Returns:
+8 -7
View File
@@ -1,13 +1,14 @@
import asyncio
import logging
import time
from collections.abc import Callable
from pathlib import Path
from typing import Callable, Dict, List, Optional
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
from watchdog.events import FileSystemEventHandler
from backend.indexer import SUPPORTED_EXTENSIONS, IGNORED_DIRS
from backend.indexer import IGNORED_DIRS, SUPPORTED_EXTENSIONS
logger = logging.getLogger("obsigate.watcher")
@@ -89,10 +90,10 @@ class VaultWatcher:
self.polling_interval = polling_interval
self.observers: dict[str, object] = {}
self.event_queue: asyncio.Queue = asyncio.Queue()
self._processor_task: Optional[asyncio.Task] = None
self._processor_task: asyncio.Task | None = None
self._running = False
async def start(self, vaults: Dict[str, str]):
async def start(self, vaults: dict[str, str]):
"""Démarrer la surveillance de toutes les vaults.
Args:
@@ -170,7 +171,7 @@ class VaultWatcher:
Accumule les événements pendant debounce_seconds puis appelle
le callback une seule fois avec la liste consolidée.
"""
pending: Dict[str, dict] = {}
pending: dict[str, dict] = {}
while self._running:
try:
@@ -194,7 +195,7 @@ class VaultWatcher:
except Exception as e:
logger.error(f"Event processor error: {e}")
async def _dispatch(self, events: List[dict]):
async def _dispatch(self, events: list[dict]):
"""Appeler le callback avec les événements consolidés."""
try:
await self.on_file_change(events)
+7 -10
View File
@@ -8,19 +8,17 @@ Events: file_created, file_deleted, file_modified, file_renamed,
directory_created, directory_deleted, directory_renamed
"""
import json
import hmac
import hashlib
import asyncio
import hashlib
import hmac
import json
import logging
import uuid
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, List
from pathlib import Path
import aiohttp
logger = logging.getLogger("obsigate.webhooks")
WEBHOOKS_FILE = Path("data/webhooks.json")
@@ -51,7 +49,7 @@ def get_webhooks() -> list:
return _read()
def create_webhook(name: str, url: str, events: List[str], secret: Optional[str] = None) -> dict:
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
webhooks = _read()
wh = {
"id": str(uuid.uuid4()),
@@ -69,7 +67,7 @@ def create_webhook(name: str, url: str, events: List[str], secret: Optional[str]
return wh
def update_webhook(wh_id: str, updates: dict) -> Optional[dict]:
def update_webhook(wh_id: str, updates: dict) -> dict | None:
webhooks = _read()
for wh in webhooks:
if wh["id"] == wh_id:
@@ -110,8 +108,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(wh["url"], data=body, headers=headers) as resp:
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(wh["url"], data=body, headers=headers) as resp:
if resp.status < 400:
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else: