"""Syncthing conflict endpoints (ROADMAP #85, tranche 8). Handlers déplacés depuis :mod:`backend.main` sans changement de comportement : mêmes chemins (``/api/conflicts*``), mêmes modèles de réponse, mêmes dépendances d'authentification. Adaptations strictement équivalentes : - ``_resolve_safe_path`` / ``_backup_file`` → :mod:`backend.services.paths` et :mod:`backend.services.backups` (pass-through). """ import logging import shutil from pathlib import Path from fastapi import APIRouter, Body, Depends, HTTPException from backend.audit import log_file_delete from backend.auth.middleware import check_vault_access, require_auth from backend.indexer import get_conflicts, get_vault_data, remove_single_file from backend.schemas import ConflictResolveResponse, ConflictsResponse from backend.services.backups import create_backup from backend.services.paths import resolve_safe_path from backend.sse import sse_manager logger = logging.getLogger("obsigate") router = APIRouter(tags=["conflicts"]) @router.get("/api/conflicts", response_model=ConflictsResponse) async def api_conflicts(current_user=Depends(require_auth)): """List sync-conflict files across accessible vaults.""" user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", []) all_conflicts = get_conflicts() if "*" not in user_vaults: all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults] return {"conflicts": all_conflicts, "total": len(all_conflicts)} @router.post("/api/conflicts/resolve", response_model=ConflictResolveResponse) async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(require_auth)): """Resolve a conflict: keep_local (delete conflict file) or keep_conflict (replace original).""" vault_name = body.get("vault") conflict_path = body.get("conflict_path") original_path = body.get("original_path") action = body.get("action") # "keep_local" or "keep_conflict" # mypy: narrow down from dict values assert isinstance(vault_name, str), "'vault' is required and must be a string" assert isinstance(conflict_path, str), "'conflict_path' is required and must be a string" assert isinstance(original_path, str), "'original_path' is required and must be a string" if not check_vault_access(vault_name, current_user): raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") vault_data = get_vault_data(vault_name) if not vault_data: raise HTTPException(404, "Vault not found") vault_root = Path(vault_data["path"]) conf_file = resolve_safe_path(vault_root, conflict_path) orig_file = resolve_safe_path(vault_root, original_path) if not conf_file.exists(): raise HTTPException(404, "Conflict file not found") try: if action == "keep_conflict": create_backup(orig_file, vault_name, original_path) shutil.copy2(conf_file, orig_file) logger.info(f"Conflict resolved (keep_conflict): {conflict_path} → {original_path}") conf_file.unlink() await remove_single_file(vault_name, conflict_path) log_file_delete(current_user["username"], vault_name, conflict_path) 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: {e!s}")