feat: add file and directory management endpoints with context menu support
- Add POST/PATCH/DELETE endpoints for directory operations (create, rename, delete) - Add POST/PATCH endpoints for file operations (create, rename) - Implement writable vault check to prevent modifications on read-only vaults - Update file delete endpoint to broadcast SSE events and update index - Add Pydantic models for all new request/response schemas - Integrate context menu support in frontend for files and directories - Broadcast real
This commit is contained in:
+418
@@ -6,6 +6,7 @@ import html as html_mod
|
||||
import logging
|
||||
import mimetypes
|
||||
import secrets
|
||||
import shutil
|
||||
import string
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -234,6 +235,61 @@ class HealthResponse(BaseModel):
|
||||
total_files: int
|
||||
|
||||
|
||||
class DirectoryCreateRequest(BaseModel):
|
||||
"""Request to create a new directory."""
|
||||
path: str = Field(description="Relative path of the new directory")
|
||||
|
||||
|
||||
class DirectoryCreateResponse(BaseModel):
|
||||
"""Response after creating a directory."""
|
||||
success: bool
|
||||
path: str
|
||||
|
||||
|
||||
class DirectoryRenameRequest(BaseModel):
|
||||
"""Request to rename a directory."""
|
||||
path: str = Field(description="Current path of the directory")
|
||||
new_name: str = Field(description="New name for the directory")
|
||||
|
||||
|
||||
class DirectoryRenameResponse(BaseModel):
|
||||
"""Response after renaming a directory."""
|
||||
success: bool
|
||||
old_path: str
|
||||
new_path: str
|
||||
|
||||
|
||||
class DirectoryDeleteResponse(BaseModel):
|
||||
"""Response after deleting a directory."""
|
||||
success: bool
|
||||
deleted_count: int
|
||||
|
||||
|
||||
class FileCreateRequest(BaseModel):
|
||||
"""Request to create a new file."""
|
||||
path: str = Field(description="Relative path of the new file")
|
||||
content: str = Field(default="", description="Initial content")
|
||||
|
||||
|
||||
class FileCreateResponse(BaseModel):
|
||||
"""Response after creating a file."""
|
||||
success: bool
|
||||
path: str
|
||||
|
||||
|
||||
class FileRenameRequest(BaseModel):
|
||||
"""Request to rename a file."""
|
||||
path: str = Field(description="Current path of the file")
|
||||
new_name: str = Field(description="New name for the file")
|
||||
|
||||
|
||||
class FileRenameResponse(BaseModel):
|
||||
"""Response after renaming a file."""
|
||||
success: bool
|
||||
old_path: str
|
||||
new_path: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE Manager — Server-Sent Events for real-time notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -523,6 +579,18 @@ def _resolve_safe_path(vault_root: Path, relative_path: str) -> Path:
|
||||
return resolved
|
||||
|
||||
|
||||
def _check_vault_writable(vault_root: Path) -> bool:
|
||||
"""Check if a vault is writable (not mounted read-only).
|
||||
|
||||
Args:
|
||||
vault_root: The vault's root directory (absolute).
|
||||
|
||||
Returns:
|
||||
True if the vault is writable, False otherwise.
|
||||
"""
|
||||
return os.access(vault_root, os.W_OK)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering helpers (singleton renderer)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1028,9 +1096,23 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
try:
|
||||
file_path.unlink()
|
||||
logger.info(f"File deleted: {vault_name}/{path}")
|
||||
|
||||
# Update index
|
||||
await remove_single_file(vault_name, path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_deleted", {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
return {"status": "ok", "vault": vault_name, "path": path}
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied: vault may be read-only")
|
||||
@@ -1039,6 +1121,342 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
||||
raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Directory management endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.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}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve and validate path
|
||||
dir_path = _resolve_safe_path(vault_root, body.path)
|
||||
|
||||
# Check if directory already exists
|
||||
if dir_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Directory already exists: {body.path}")
|
||||
|
||||
try:
|
||||
# Create directory with parents
|
||||
dir_path.mkdir(parents=True, exist_ok=False)
|
||||
logger.info(f"Directory created: {vault_name}/{body.path}")
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_created", {
|
||||
"vault": vault_name,
|
||||
"path": body.path,
|
||||
})
|
||||
|
||||
return {"success": True, "path": body.path}
|
||||
except PermissionError:
|
||||
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)}")
|
||||
|
||||
|
||||
@app.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}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve old path
|
||||
old_path = _resolve_safe_path(vault_root, body.path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"Directory not found: {body.path}")
|
||||
|
||||
# Construct new path (same parent, new name)
|
||||
new_path = old_path.parent / body.new_name
|
||||
|
||||
# Validate new path is still within vault
|
||||
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
|
||||
|
||||
# Check if destination already exists
|
||||
if new_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
|
||||
|
||||
try:
|
||||
# Rename directory
|
||||
old_path.rename(new_path)
|
||||
|
||||
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
|
||||
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
logger.info(f"Directory renamed: {vault_name}/{old_path_str} -> {new_path_str}")
|
||||
|
||||
# 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,
|
||||
})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
except PermissionError:
|
||||
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)}")
|
||||
|
||||
|
||||
@app.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}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve and validate path
|
||||
dir_path = _resolve_safe_path(vault_root, path)
|
||||
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"Directory not found: {path}")
|
||||
|
||||
try:
|
||||
# Count files before deletion
|
||||
file_count = sum(1 for _ in dir_path.rglob('*') if _.is_file())
|
||||
|
||||
# Delete directory recursively
|
||||
shutil.rmtree(dir_path)
|
||||
logger.info(f"Directory deleted: {vault_name}/{path} ({file_count} files)")
|
||||
|
||||
# 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": path,
|
||||
"deleted_count": file_count,
|
||||
})
|
||||
|
||||
return {"success": True, "deleted_count": file_count}
|
||||
except PermissionError:
|
||||
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)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File creation and rename endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.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}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve and validate path
|
||||
file_path = _resolve_safe_path(vault_root, body.path)
|
||||
|
||||
# Validate file extension
|
||||
ext = file_path.suffix.lower()
|
||||
if ext not in SUPPORTED_EXTENSIONS and file_path.name.lower() not in ("dockerfile", "makefile"):
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
|
||||
|
||||
# Check if file already exists
|
||||
if file_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"File already exists: {body.path}")
|
||||
|
||||
try:
|
||||
# Create parent directories if needed
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create file with initial content
|
||||
file_path.write_text(body.content, encoding="utf-8")
|
||||
logger.info(f"File created: {vault_name}/{body.path}")
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, body.path)
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("file_created", {
|
||||
"vault": vault_name,
|
||||
"path": body.path,
|
||||
})
|
||||
|
||||
return {"success": True, "path": body.path}
|
||||
except PermissionError:
|
||||
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)}")
|
||||
|
||||
|
||||
@app.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}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
|
||||
# Check if vault is writable
|
||||
if not _check_vault_writable(vault_root):
|
||||
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||
|
||||
# Resolve old path
|
||||
old_path = _resolve_safe_path(vault_root, body.path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {body.path}")
|
||||
|
||||
# Construct new path (same parent, new name)
|
||||
new_path = old_path.parent / body.new_name
|
||||
|
||||
# Validate new path is still within vault
|
||||
new_path = _resolve_safe_path(vault_root, str(new_path.relative_to(vault_root)))
|
||||
|
||||
# Validate file extension
|
||||
ext = new_path.suffix.lower()
|
||||
if ext not in SUPPORTED_EXTENSIONS and new_path.name.lower() not in ("dockerfile", "makefile"):
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
|
||||
|
||||
# Check if destination already exists
|
||||
if new_path.exists():
|
||||
raise HTTPException(status_code=409, detail=f"Destination already exists: {body.new_name}")
|
||||
|
||||
try:
|
||||
# Rename file
|
||||
old_path.rename(new_path)
|
||||
|
||||
old_path_str = str(old_path.relative_to(vault_root)).replace("\\", "/")
|
||||
new_path_str = str(new_path.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
logger.info(f"File renamed: {vault_name}/{old_path_str} -> {new_path_str}")
|
||||
|
||||
# Update index
|
||||
await handle_file_move(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,
|
||||
})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str}
|
||||
except PermissionError:
|
||||
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)}")
|
||||
|
||||
|
||||
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
|
||||
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return rendered HTML and metadata for a file.
|
||||
|
||||
Reference in New Issue
Block a user