Refactor backend and frontend cleanup paths

This commit is contained in:
curo1305
2026-06-16 11:50:17 +02:00
parent 6b56763689
commit e97ca164d7
29 changed files with 1106 additions and 2280 deletions
+90 -199
View File
@@ -1,21 +1,4 @@
"""
Folder API endpoints for DocuVault — Phase 4, Plan 03.
Implements FOLD-01 through FOLD-05:
POST /api/folders — create folder (FOLD-01)
GET /api/folders — list top-level folders (FOLD-02)
GET /api/folders/{id} — get folder + breadcrumb (FOLD-02)
PATCH /api/folders/{id} — rename folder (FOLD-03)
DELETE /api/folders/{id} — delete folder (cascade) (FOLD-03)
PATCH /api/documents/{id}/folder — move document to folder (FOLD-04)
Security invariants (all enforced):
T-04-03-01: get_regular_user on all endpoints (admin gets 403)
T-04-03-04: All folder IDOR paths return 404 not 403
T-04-03-05: PATCH /api/documents/{id}/folder validates both doc and target folder ownership
T-04-03-06: IntegrityError (duplicate folder name) → 409 Conflict
T-04-03-03: Atomic quota decrement with CASE WHEN pattern (SQLite compat)
"""
"""Folder and document-folder organization endpoints."""
from __future__ import annotations
import uuid
@@ -23,11 +6,11 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy import select, text, func
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Folder, Quota, Share, User
from db.models import Document, Folder, User
from deps.auth import get_regular_user
from deps.db import get_db
from deps.utils import get_client_ip
@@ -37,8 +20,6 @@ from storage import get_storage_backend
router = APIRouter(prefix="/api/folders", tags=["folders"])
# ── Request / response models ─────────────────────────────────────────────────
class FolderCreate(BaseModel):
name: str
parent_id: Optional[str] = None
@@ -52,9 +33,6 @@ class DocumentMove(BaseModel):
folder_id: Optional[str] = None
# ── Helper: folder serialization ──────────────────────────────────────────────
def _folder_to_dict(folder: Folder) -> dict:
return {
"id": str(folder.id),
@@ -65,8 +43,6 @@ def _folder_to_dict(folder: Folder) -> dict:
}
# ── Helper: document serialization ────────────────────────────────────────────
def _doc_to_dict(doc: Document) -> dict:
return {
"id": str(doc.id),
@@ -81,7 +57,67 @@ def _doc_to_dict(doc: Document) -> dict:
}
# ── POST /api/folders ─────────────────────────────────────────────────────────
def _parse_uuid(value: str, not_found_detail: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except ValueError:
raise HTTPException(status_code=404, detail=not_found_detail)
async def _get_owned_folder(
session: AsyncSession,
folder_id: str | uuid.UUID,
user_id: uuid.UUID,
detail: str = "Folder not found",
) -> Folder:
uid = folder_id if isinstance(folder_id, uuid.UUID) else _parse_uuid(folder_id, detail)
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=404, detail=detail)
return folder
async def _get_owned_document(
session: AsyncSession,
doc_id: str,
user_id: uuid.UUID,
) -> Document:
uid = _parse_uuid(doc_id, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != user_id:
raise HTTPException(status_code=404, detail="Document not found")
return doc
async def _ensure_unique_folder_name(
session: AsyncSession,
user_id: uuid.UUID,
name: str,
parent_id: Optional[uuid.UUID],
exclude_id: Optional[uuid.UUID] = None,
) -> None:
stmt = select(Folder).where(
Folder.user_id == user_id,
Folder.name == name,
Folder.parent_id == parent_id,
)
if exclude_id is not None:
stmt = stmt.where(Folder.id != exclude_id)
dup = await session.execute(stmt)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
def _duplicate_folder_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_folder(
@@ -90,35 +126,14 @@ async def create_folder(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Create a new folder for the current user.
FOLD-01: parent_id (if given) must belong to current_user — 404 otherwise.
Duplicate name under same parent returns 409 (T-04-03-06).
"""
parent_uuid: Optional[uuid.UUID] = None
if body.parent_id is not None:
try:
parent_uuid = uuid.UUID(body.parent_id)
except ValueError:
raise HTTPException(status_code=404, detail="Parent folder not found")
parent = await session.get(Folder, parent_uuid)
if parent is None or parent.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Parent folder not found")
parent = await _get_owned_folder(
session, body.parent_id, current_user.id, "Parent folder not found"
)
parent_uuid = parent.id
# Explicit duplicate check — UniqueConstraint won't fire when parent_id IS NULL
# because SQL treats NULL as distinct from NULL in unique indexes.
dup = await session.execute(
select(Folder).where(
Folder.user_id == current_user.id,
Folder.name == body.name,
Folder.parent_id == parent_uuid,
)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
await _ensure_unique_folder_name(session, current_user.id, body.name, parent_uuid)
folder = Folder(
user_id=current_user.id,
@@ -130,10 +145,7 @@ async def create_folder(
await session.commit()
except IntegrityError:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
raise _duplicate_folder_error()
await write_audit_log(
session,
@@ -148,34 +160,20 @@ async def create_folder(
return _folder_to_dict(folder)
# ── GET /api/folders ──────────────────────────────────────────────────────────
@router.get("")
async def list_folders(
parent_id: Optional[str] = None,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""List the current user's folders at a given level.
FOLD-02: when parent_id is omitted, returns root folders (parent_id IS NULL).
When parent_id is supplied, returns that folder's direct children (asserts ownership).
Each folder includes has_children so the frontend can hide expand arrows on leaf nodes.
"""
parent_uuid: Optional[uuid.UUID] = None
if parent_id is not None:
try:
parent_uuid = uuid.UUID(parent_id)
except ValueError:
raise HTTPException(status_code=404, detail="Parent folder not found")
parent_folder = await session.get(Folder, parent_uuid)
if parent_folder is None or parent_folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Parent folder not found")
parent_folder = await _get_owned_folder(
session, parent_id, current_user.id, "Parent folder not found"
)
parent_uuid = parent_folder.id
if parent_uuid is None:
where_clause = Folder.parent_id.is_(None)
else:
where_clause = Folder.parent_id == parent_uuid
where_clause = Folder.parent_id.is_(None) if parent_uuid is None else Folder.parent_id == parent_uuid
result = await session.execute(
select(Folder)
@@ -184,8 +182,6 @@ async def list_folders(
)
folders = result.scalars().all()
# One extra query to know which of these folders have sub-folders.
# Allows the frontend to hide expand arrows on leaf nodes without extra round-trips.
folder_ids = [f.id for f in folders]
folders_with_children: set = set()
if folder_ids:
@@ -205,55 +201,34 @@ async def list_folders(
}
# ── GET /api/folders/{folder_id} ──────────────────────────────────────────────
@router.get("/{folder_id}")
async def get_folder(
folder_id: str,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Get folder metadata + breadcrumb array from root to this folder.
folder = await _get_owned_folder(session, folder_id, current_user.id)
FOLD-02 / FOLD-05: breadcrumb is built via iterative parent-walk in Python
(not WITH RECURSIVE) so it is compatible with both PostgreSQL and SQLite tests.
Response: {id, name, parent_id, user_id, created_at, breadcrumb: [{id, name}, ...]}
The breadcrumb array is ordered root-first (root is breadcrumb[0]).
"""
try:
uid = uuid.UUID(folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
# Build breadcrumb by walking up the parent chain iteratively.
# Ownership check on each ancestor ensures no cross-user traversal.
crumbs = [{"id": str(folder.id), "name": folder.name}]
current = folder
visited: set = {current.id}
while current.parent_id is not None:
if current.parent_id in visited:
break # cycle guard (should not occur with proper constraints)
break
parent = await session.get(Folder, current.parent_id)
if parent is None or parent.user_id != current_user.id:
break # stop traversal if parent is inaccessible
break
visited.add(parent.id)
crumbs.append({"id": str(parent.id), "name": parent.name})
current = parent
crumbs.reverse() # root-first order
crumbs.reverse()
response = _folder_to_dict(folder)
response["breadcrumb"] = crumbs
return response
# ── PATCH /api/folders/{folder_id} ───────────────────────────────────────────
@router.patch("/{folder_id}")
async def rename_folder(
folder_id: str,
@@ -262,46 +237,20 @@ async def rename_folder(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Rename a folder.
FOLD-03: asserts ownership → 404 if not owner.
Duplicate name under same parent returns 409 (T-04-03-06).
"""
try:
uid = uuid.UUID(folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await _get_owned_folder(session, folder_id, current_user.id)
old_name = folder.name
# Explicit duplicate check — same NULL parent_id issue as create_folder.
if body.name != folder.name:
dup = await session.execute(
select(Folder).where(
Folder.user_id == current_user.id,
Folder.name == body.name,
Folder.parent_id == folder.parent_id,
Folder.id != folder.id,
)
await _ensure_unique_folder_name(
session, current_user.id, body.name, folder.parent_id, folder.id
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
folder.name = body.name
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A folder with that name already exists here",
)
raise _duplicate_folder_error()
await write_audit_log(
session,
@@ -316,8 +265,6 @@ async def rename_folder(
return _folder_to_dict(folder)
# ── DELETE /api/folders/{folder_id} ──────────────────────────────────────────
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder(
folder_id: str,
@@ -325,30 +272,9 @@ async def delete_folder(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Delete a folder and all of its contents (cascade).
FOLD-03 + D-03:
- Collects all documents in the folder subtree using WITH RECURSIVE CTE
(wraps in try/except OperationalError for SQLite test compat; fallback
uses direct children only).
- Sums size_bytes, performs atomic quota decrement (CASE WHEN pattern for
SQLite compat — T-04-03-03).
- Deletes MinIO objects best-effort (per-object try/except — PATTERNS.md Pattern 2).
- Deletes all document rows and the folder row via ORM.
"""
try:
uid = uuid.UUID(folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await session.get(Folder, uid)
if folder is None or folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
folder = await _get_owned_folder(session, folder_id, current_user.id)
folder_name = folder.name
# Collect all folder IDs in the subtree via WITH RECURSIVE CTE.
# Falls back to direct-children-only on SQLite (OperationalError on recursive CTE).
subtree_folder_ids: list[str] = []
try:
cte_result = await session.execute(
@@ -361,17 +287,13 @@ async def delete_folder(
" WHERE f.user_id = :uid"
") SELECT id FROM subtree"
),
# Use .hex (no dashes) — SQLite stores UUID as 32-char hex; PostgreSQL accepts both.
{"root_id": folder.id.hex, "uid": current_user.id.hex},
)
subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()]
except OperationalError:
# SQLite fallback: only direct children of this folder
subtree_folder_ids = [str(folder.id)]
# Collect all documents in the subtree folder IDs
if subtree_folder_ids:
# Build UUID list for IN query
subtree_uuids = []
for fid in subtree_folder_ids:
try:
@@ -394,7 +316,6 @@ async def delete_folder(
total_bytes = sum(d.size_bytes for d in docs)
# Atomic quota decrement (CASE WHEN for SQLite compat — never goes below 0)
if total_bytes > 0:
await session.execute(
text(
@@ -406,20 +327,16 @@ async def delete_folder(
{"delta": total_bytes, "uid": current_user.id.hex},
)
# Delete MinIO objects best-effort (per-object, never abort on failure)
storage_backend = get_storage_backend()
for doc in docs:
try:
await storage_backend.delete_object(doc.object_key)
except Exception:
pass # best-effort; stale MinIO objects will be garbage-collected
pass
# Delete document rows
for doc in docs:
await session.delete(doc)
# Delete the folder (cascade will handle sub-folders in PostgreSQL;
# in SQLite test env we already collected and deleted all documents)
await session.delete(folder)
await session.commit()
@@ -428,17 +345,12 @@ async def delete_folder(
event_type="folder.deleted",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=uid,
resource_id=folder.id,
ip_address=get_client_ip(request),
metadata_={"name": folder_name, "doc_count": len(docs)},
)
# ── PATCH /api/documents/{doc_id}/folder ─────────────────────────────────────
# This endpoint lives in the folders router (not documents router) because it
# is logically a folder organisation operation. The URL prefix /api/documents
# is achieved via an explicit path on this APIRouter. FastAPI supports this.
document_move_router = APIRouter(prefix="/api/documents", tags=["folders"])
@@ -450,33 +362,12 @@ async def move_document(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Move a document to a different folder (or to root if folder_id is null).
FOLD-04:
- Asserts document ownership → 404 if not owner.
- If folder_id given: asserts target folder ownership → 404 if not owner
(T-04-03-05: cross-user folder assignment blocked).
- Updates doc.folder_id and commits.
- Returns 200 with updated document dict.
"""
try:
doc_uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(status_code=404, detail="Document not found")
doc = await session.get(Document, doc_uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Document not found")
doc = await _get_owned_document(session, doc_id, current_user.id)
target_folder_uuid: Optional[uuid.UUID] = None
if body.folder_id is not None:
try:
target_folder_uuid = uuid.UUID(body.folder_id)
except ValueError:
raise HTTPException(status_code=404, detail="Folder not found")
target_folder = await session.get(Folder, target_folder_uuid)
if target_folder is None or target_folder.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Folder not found")
target_folder = await _get_owned_folder(session, body.folder_id, current_user.id)
target_folder_uuid = target_folder.id
doc.folder_id = target_folder_uuid
await session.commit()