Refactor backend and frontend cleanup paths
This commit is contained in:
@@ -1,22 +1,16 @@
|
||||
"""Shared constants and Pydantic request models for the documents API package.
|
||||
|
||||
CODE-08: Single definition of _CLOUD_PROVIDERS, UploadUrlRequest, and DocumentPatch.
|
||||
These are imported by upload.py and crud.py — never duplicated.
|
||||
|
||||
T-05-06-01: _CLOUD_PROVIDERS is an allowlist frozenset; target_backend validated
|
||||
against it (never against user-supplied strings).
|
||||
T-05-09-01: DocumentPatch fields declared explicitly — mass assignment prevented.
|
||||
T-05-09-02: filename_no_path_separators validator preserved verbatim (path traversal
|
||||
defense at the API boundary — D-11 analysis: stays in Pydantic model).
|
||||
"""
|
||||
"""Shared constants, request models, and access helpers for document routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Share
|
||||
|
||||
# Valid cloud backend slugs (T-05-06-01: validated against allowlist, not user-supplied string)
|
||||
_CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"})
|
||||
|
||||
|
||||
@@ -26,14 +20,6 @@ class UploadUrlRequest(BaseModel):
|
||||
|
||||
|
||||
class DocumentPatch(BaseModel):
|
||||
"""Pydantic model for PATCH /api/documents/{doc_id}.
|
||||
|
||||
Optional fields — model_fields_set distinguishes "not provided" from "set to null".
|
||||
At least one field must be present in model_fields_set (enforced in the handler).
|
||||
|
||||
T-05-09-01: explicit field declaration prevents mass assignment.
|
||||
T-05-09-02: only filename and folder_id are accepted — no other fields can be set.
|
||||
"""
|
||||
filename: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
folder_id: Optional[uuid.UUID] = None
|
||||
|
||||
@@ -43,3 +29,50 @@ class DocumentPatch(BaseModel):
|
||||
if v is not None and ("/" in v or "\\" in v):
|
||||
raise ValueError("filename must not contain path separators")
|
||||
return v
|
||||
|
||||
|
||||
def _document_not_found() -> HTTPException:
|
||||
return HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
|
||||
def parse_document_uuid(doc_id: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise _document_not_found()
|
||||
|
||||
|
||||
async def get_owned_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = parse_document_uuid(doc_id)
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != user_id:
|
||||
raise _document_not_found()
|
||||
return doc
|
||||
|
||||
|
||||
async def get_accessible_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> tuple[Document, bool]:
|
||||
uid = parse_document_uuid(doc_id)
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None:
|
||||
raise _document_not_found()
|
||||
|
||||
if doc.user_id == user_id:
|
||||
return doc, False
|
||||
|
||||
result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == doc.id,
|
||||
Share.recipient_id == user_id,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise _document_not_found()
|
||||
return doc, True
|
||||
|
||||
Reference in New Issue
Block a user