L'édition d'un .xlsx pouvait détruire une partie du classeur, le concurrencer en silence, ou diffuser une injection de formule. - BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd (valeurs calculées en cache, slicers, contrôles, connexions, custom XML, signature, commentaires enrichis, macros) → xlsx_lossy_features exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans `force` (confirmation explicite puis reprise). Périmètre réel revalidé : graphiques, images et TCD survivent au round-trip. - BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage ne peut plus tronquer le classeur, le backup reste intact. - BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s, 409 conflict) ; endpoint xlsx/save devenu synchrone pour que l'attente s'exécute dans le threadpool. - BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in `allow_formula` ou le bouton f(x) de la visionneuse. Le handler ServiceError expose désormais code + details, que api() propage. - BUG-084 : la suppression d'une vault purge enfin l'index inversé (documents fantômes qui continuaient de matcher) et is_stale() devient is_ready(), le nom étant trompeur (la staleness n'existe plus). Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI), 3 E2E Playwright, suite E2E complète verte, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
533 lines
18 KiB
Python
533 lines
18 KiB
Python
"""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}
|