"""File & directory mutation endpoints (ROADMAP #85, tranche 6b). Handlers déplacés depuis :mod:`backend.main` sans changement de comportement : mêmes chemins (``PUT/DELETE/PATCH/POST /api/file/*``, ``/api/directory/*``, ``/api/move/*``, ``/api/vault/*/batch-upload``), mêmes modèles de requête/réponse (déménagés dans :mod:`backend.schemas`), mêmes dépendances d'authentification et mêmes effets de bord (audit, index incrémental, SSE, webhooks, plugins, historique). La logique métier vit déjà dans :mod:`backend.services.mutations`. """ import logging from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException, Query from backend.audit import log_file_delete, log_file_save from backend.auth.middleware import check_vault_access, require_auth from backend.history import ( remove_recent, update_bookmarks_after_rename, update_history_after_rename, ) from backend.indexer import handle_file_move, remove_single_file, update_single_file from backend.schemas import ( BatchUploadRequest, BatchUploadResponse, DirectoryCreateRequest, DirectoryCreateResponse, DirectoryDeleteResponse, DirectoryRenameRequest, DirectoryRenameResponse, FileCreateRequest, FileCreateResponse, FileDeleteResponse, FileMoveRequest, FileMoveResponse, FileRenameRequest, FileRenameResponse, FileSaveResponse, ) from backend.services.mutations import ( batch_upload_files as service_batch_upload_files, ) from backend.services.mutations import ( create_directory as service_create_directory, ) from backend.services.mutations import ( create_file as service_create_file, ) from backend.services.mutations import ( delete_directory as service_delete_directory, ) from backend.services.mutations import ( delete_file as service_delete_file, ) from backend.services.mutations import ( edit_file as service_edit_file, ) from backend.services.mutations import ( edit_xlsx_cells as service_edit_xlsx_cells, ) from backend.services.mutations import ( move_path as service_move_path, ) from backend.services.mutations import ( rename_directory as service_rename_directory, ) from backend.services.mutations import ( rename_file as service_rename_file, ) from backend.share import update_shares_after_rename from backend.sse import sse_manager from backend.webhooks import dispatch_webhooks logger = logging.getLogger("obsigate") router = APIRouter(tags=["files"]) @router.put("/api/file/{vault_name}/save", response_model=FileSaveResponse) async def api_file_save( vault_name: str, path: str = Query(..., description="Relative path to file"), body: dict = Body(...), backup: bool = Query(True, description="Create a backup before saving (default true, set false for auto-save)"), current_user=Depends(require_auth), ): """Save (overwrite) a file's content. Expects a JSON body with a ``content`` key containing the new text. The path is validated against traversal attacks before writing. Args: vault_name: Name of the vault. path: Relative file path within the vault. body: JSON body with ``content`` string. Returns: ``FileSaveResponse`` confirming the write. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") content = body.get("content", "") result = service_edit_file(vault_name, path, content, backup=backup) # Audit log client_ip = current_user.get("_request_ip", "unknown") log_file_save(current_user["username"], vault_name, path, len(content), client_ip) return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} @router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse) def api_file_xlsx_save( vault_name: str, path: str = Query(..., description="Relative path to the .xlsx file"), body: dict = Body( ..., description=( '{"sheet": str, "cells": {"A1": value}, ' '"allow_formula": false, "force": false}' ), ), current_user=Depends(require_auth), ): """Apply cell edits to an .xlsx workbook. Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new scalar values, max 500 per request) plus two optional boolean flags: * ``allow_formula`` — keep values starting with ``=``/``@`` as real formulas. Off by default (#153 A4): such a value is stored as text so a later Excel session cannot execute it (DDE). * ``force`` — write a workbook carrying features openpyxl cannot re-serialize (slicers, form controls, connections, custom XML, signature, cached formula results). Without it the call fails **409** ``xlsx_lossy_content`` and the client asks the user to confirm (#153 A1). A backup is created before the workbook is rewritten, and the new archive swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl round-trip and the per-file lock wait (#153 A3) then run in the threadpool instead of blocking the event loop. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") sheet = body.get("sheet") cells = body.get("cells") if not isinstance(sheet, str) or not sheet: raise HTTPException(status_code=400, detail="Feuille manquante") if not isinstance(cells, dict) or not cells or len(cells) > 500: raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)") for ref, value in cells.items(): if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") flags: dict[str, bool] = {} for name in ("allow_formula", "force"): raw = body.get(name, False) if not isinstance(raw, bool): raise HTTPException(status_code=400, detail=f"Flag invalide: {name}") flags[name] = raw result = service_edit_xlsx_cells( vault_name, path, sheet, cells, **flags ) log_file_save( current_user["username"], vault_name, path, sum(len(str(v)) for v in cells.values()), current_user.get("_request_ip", "unknown"), ) return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} @router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse) async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): """Delete a file from the vault. The path is validated against traversal attacks before deletion. Args: vault_name: Name of the vault. path: Relative file path within the vault. Returns: ``FileDeleteResponse`` confirming the deletion. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_delete_file(vault_name, path) # Audit log client_ip = current_user.get("_request_ip", "unknown") log_file_delete(current_user["username"], vault_name, path, client_ip) # Update index await remove_single_file(vault_name, path) # Broadcast SSE event await sse_manager.broadcast("file_deleted", { "vault": vault_name, "path": path, }) from backend.plugins import emit_file_deleted emit_file_deleted(vault_name, path) # Remove from recent files remove_recent(current_user["username"], vault_name, path) # Dispatch webhooks await dispatch_webhooks("file_deleted", {"vault": vault_name, "path": path}) return {"status": "ok", "vault": result["vault"], "path": result["path"]} @router.post("/api/directory/{vault_name}", response_model=DirectoryCreateResponse) async def api_directory_create( vault_name: str, body: DirectoryCreateRequest, current_user=Depends(require_auth), ): """Create a new directory in a vault. Args: vault_name: Name of the vault. body: Request body with directory path. Returns: DirectoryCreateResponse confirming creation. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_create_directory(vault_name, body.path) # Update path_index with the new directory 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] = [] existing = {p["path"] for p in _path_idx[vault_name]} # Build all parent segments parts = body.path.split("/") for i in range(1, len(parts) + 1): seg_path = "/".join(parts[:i]) if seg_path and seg_path not in existing: existing.add(seg_path) _path_idx[vault_name].append({ "path": seg_path, "name": parts[i - 1], "type": "directory", }) # Broadcast SSE event await sse_manager.broadcast("directory_created", { "vault": vault_name, "path": result["path"], }) await dispatch_webhooks("directory_created", {"vault": vault_name, "path": result["path"]}) return {"success": True, "path": result["path"]} @router.patch("/api/directory/{vault_name}", response_model=DirectoryRenameResponse) async def api_directory_rename( vault_name: str, body: DirectoryRenameRequest, current_user=Depends(require_auth), ): """Rename a directory in a vault. Args: vault_name: Name of the vault. body: Request body with current path and new name. Returns: DirectoryRenameResponse with old and new paths. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_rename_directory(vault_name, body.path, body.new_name) old_path_str = result["old_path"] new_path_str = result["new_path"] # Update index for all files in the directory from backend.indexer import reload_single_vault await reload_single_vault(vault_name) # Broadcast SSE event await sse_manager.broadcast("directory_renamed", { "vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, }) await dispatch_webhooks("directory_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) return {"success": True, "old_path": old_path_str, "new_path": new_path_str} @router.delete("/api/directory/{vault_name}", response_model=DirectoryDeleteResponse) async def api_directory_delete( vault_name: str, path: str = Query(..., description="Relative path to directory"), current_user=Depends(require_auth), ): """Delete a directory and all its contents from a vault. Args: vault_name: Name of the vault. path: Relative directory path within the vault. Returns: DirectoryDeleteResponse with count of deleted files. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_delete_directory(vault_name, path, recursive=True) file_count = result["deleted_count"] # Update index from backend.indexer import reload_single_vault await reload_single_vault(vault_name) # Broadcast SSE event await sse_manager.broadcast("directory_deleted", { "vault": vault_name, "path": result["path"], "deleted_count": file_count, }) await dispatch_webhooks("directory_deleted", {"vault": vault_name, "path": result["path"]}) return {"success": True, "deleted_count": file_count} @router.post("/api/file/{vault_name}", response_model=FileCreateResponse) async def api_file_create( vault_name: str, body: FileCreateRequest, current_user=Depends(require_auth), ): """Create a new file in a vault. Args: vault_name: Name of the vault. body: Request body with file path and initial content. Returns: FileCreateResponse confirming creation. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_create_file(vault_name, body.path, body.content) # Update index await update_single_file(vault_name, result["path"]) # Broadcast SSE event await sse_manager.broadcast("file_created", { "vault": vault_name, "path": result["path"], }) await dispatch_webhooks("file_created", {"vault": vault_name, "path": result["path"]}) from backend.plugins import emit_file_created emit_file_created(vault_name, result["path"]) return {"success": True, "path": result["path"]} @router.post("/api/vault/{vault_name}/batch-upload", response_model=BatchUploadResponse) async def api_batch_upload( vault_name: str, body: BatchUploadRequest, current_user=Depends(require_auth), ): """Upload multiple files and directories (recursively) into a vault. Accepts base64 encoded or plain text files with relative directory paths. Creates missing parent folders safely. Args: vault_name: Target vault name. body: BatchUploadRequest with target_dir and files list. Returns: BatchUploadResponse with summary of uploaded files and errors. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") import base64 items: list[dict[str, Any]] = [] for f in body.files: if f.is_dir: items.append({"path": f.path, "is_dir": True}) continue raw_bytes = b"" if f.content is not None: # Check if content is base64 encoded data URI or raw base64 content_str = f.content if content_str.startswith("data:") and ";base64," in content_str: content_str = content_str.split(";base64,", 1)[1] try: raw_bytes = base64.b64decode(content_str) except Exception: # Fallback to utf-8 text encoding raw_bytes = f.content.encode("utf-8") items.append({"path": f.path, "content": raw_bytes, "is_dir": False}) result = service_batch_upload_files( vault_name, body.target_dir, items, overwrite=body.overwrite, ) # Update index and SSE notifications for uploaded files for path in result["uploaded"]: try: await update_single_file(vault_name, path) await sse_manager.broadcast("file_created", { "vault": vault_name, "path": path, }) await dispatch_webhooks("file_created", {"vault": vault_name, "path": path}) except Exception as e: logger.warning(f"Failed to post-process upload of {path}: {e}") # SSE notification for tree refresh if result["uploaded"] or result["created_dirs"]: await sse_manager.broadcast("tree_updated", { "vault": vault_name, "target_dir": result["target_dir"], }) return result @router.patch("/api/file/{vault_name}", response_model=FileRenameResponse) async def api_file_rename( vault_name: str, body: FileRenameRequest, current_user=Depends(require_auth), ): """Rename a file in a vault. Args: vault_name: Name of the vault. body: Request body with current path and new name. Returns: FileRenameResponse with old and new paths. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_rename_file(vault_name, body.path, body.new_name) old_path_str = result["old_path"] new_path_str = result["new_path"] # Update index await handle_file_move(vault_name, old_path_str, new_path_str) # Update bookmarks, history, and shares update_bookmarks_after_rename(vault_name, old_path_str, new_path_str) update_history_after_rename(vault_name, old_path_str, new_path_str) update_shares_after_rename(vault_name, old_path_str, new_path_str) # Broadcast SSE event await sse_manager.broadcast("file_renamed", { "vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, }) await dispatch_webhooks("file_renamed", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str}) return {"success": True, "old_path": old_path_str, "new_path": new_path_str} @router.post("/api/move/{vault_name}", response_model=FileMoveResponse) async def api_file_move( vault_name: str, body: FileMoveRequest, current_user=Depends(require_auth), ): """Move a file or directory to a different parent directory within the same vault. Supports both files and directories. The item keeps its original name; only the parent directory changes. Args: vault_name: Name of the vault. body: Request body with source_path and destination_dir. Returns: FileMoveResponse with old and new paths. """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") result = service_move_path(vault_name, body.source_path, body.destination_dir) old_path_str = result["old_path"] new_path_str = result["new_path"] item_type = result["item_type"] # Update index if item_type == "directory": from backend.indexer import reload_single_vault await reload_single_vault(vault_name) else: await handle_file_move(vault_name, old_path_str, new_path_str) # Broadcast SSE event await sse_manager.broadcast("item_moved", { "vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type, }) await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}) return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}