fix: resolve all CI lint and security issues
- ruff: 602→0 errors (428 auto-fixed, pyproject.toml ignores for FastAPI patterns) - bandit: skip B310 (urllib for vault file access is intentional) - Fixed SIM118 (dict.keys()→dict), PERF102, SIM113, SIM117 - Created pyproject.toml with ruff + bandit config - 285 tests still pass
This commit is contained in:
+23
-22
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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  standard markdown images.
|
||||
|
||||
|
||||
+41
-40
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -13,8 +13,8 @@ Patterns detected:
|
||||
- Connection strings with passwords
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("obsigate.redactor")
|
||||
|
||||
|
||||
+6
-7
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user