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
+3 -29
View File
@@ -15,14 +15,12 @@ Security:
from __future__ import annotations
import urllib.parse
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Share, User
from db.models import User
from api.documents.shared import get_accessible_document
from deps.auth import get_regular_user
from deps.db import get_db
from services.rate_limiting import account_limiter
@@ -31,15 +29,12 @@ from storage.exceptions import CloudConnectionError
router = APIRouter()
# ── Range header parsing helper ───────────────────────────────────────────────
def _parse_range(range_header: str, file_size: int) -> tuple:
"""Parse a 'bytes=X-Y' Range header and return (start, end).
Returns (start, end) where both are inclusive byte offsets.
Raises HTTP 416 on any invalid or out-of-bounds range.
T-04-05-03: validates start <= end, start >= 0, end < file_size.
"""
try:
h = range_header.replace("bytes=", "").split("-")
@@ -52,8 +47,6 @@ def _parse_range(range_header: str, file_size: int) -> tuple:
return start, end
# ── GET /api/documents/{doc_id}/content ──────────────────────────────────────
@router.get("/{doc_id}/content")
@account_limiter.limit("100/minute")
async def stream_document_content(
@@ -63,26 +56,7 @@ async def stream_document_content(
current_user: User = Depends(get_regular_user),
):
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(status_code=404, detail="Document not found")
doc = await session.get(Document, uid)
if doc is None:
raise HTTPException(status_code=404, detail="Document not found")
# Access control: owner OR share recipient (T-04-05-04)
if doc.user_id != current_user.id:
result = await session.execute(
select(Share).where(
Share.document_id == doc.id,
Share.recipient_id == current_user.id,
)
)
share = result.scalar_one_or_none()
if share is None:
raise HTTPException(status_code=404, detail="Document not found")
doc, _is_recipient = await get_accessible_document(session, doc_id, current_user.id)
try:
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
+39 -153
View File
@@ -1,22 +1,4 @@
"""Document CRUD endpoints — list, get, patch, delete, and re-classify.
Endpoints:
GET "" — list documents with sort, folder filter, and FTS (list_documents)
GET /{doc_id} — get document metadata (get_document)
PATCH /{doc_id} — update filename and/or folder_id (patch_document)
DELETE /{doc_id} — delete document, decrement quota atomically (delete_document)
POST /{doc_id}/classify — re-queue Celery classification (classify_document, D-08)
Sub-router carries NO prefix — prefix="/api/documents" lives in __init__.py (D-04).
Security:
T-03-11: ownership assertion on every resource endpoint — cross-user access returns 404.
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
T-05-09-02: response uses storage.get_metadata() whitelist — no credentials_enc, no password_hash.
T-06.2-03-01: cloud documents skip MinIO quota decrement.
T-06.2-03-02: cloud delete failure returns {success: false, cloud_delete_failed: true} (HTTP 200).
T-07-10: classify endpoint — IDOR returns 404 per ownership assertion.
"""
"""Document CRUD endpoints."""
from __future__ import annotations
import uuid
@@ -41,14 +23,31 @@ from services.rate_limiting import account_limiter
from storage import get_storage_backend_for_document as _get_storage_backend_for_document
from tasks.document_tasks import extract_and_classify
from api.documents.shared import DocumentPatch, _CLOUD_PROVIDERS
from api.documents.shared import DocumentPatch, get_accessible_document, get_owned_document
router = APIRouter()
# ── GET /api/documents ────────────────────────────────────────────────────────
# Route registered on parent router in __init__.py (FastAPI 0.100+ disallows
# include_router when both the include prefix and route path are empty strings).
async def _shared_document_ids(session: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]:
result = await session.execute(select(Share.document_id).where(Share.owner_id == user_id))
return {row[0] for row in result.fetchall()}
async def _decorate_shared_flags(
session: AsyncSession,
user_id: uuid.UUID,
items: list[dict],
) -> list[dict]:
shared_ids = await _shared_document_ids(session, user_id)
for item in items:
try:
doc_id = uuid.UUID(item.get("id", ""))
except (TypeError, ValueError):
item["is_shared"] = False
else:
item["is_shared"] = doc_id in shared_ids
return items
@account_limiter.limit("100/minute")
async def list_documents(
@@ -63,40 +62,17 @@ async def list_documents(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""List documents with optional sort, folder filter, and full-text search.
D-16: requires authenticated regular user (get_regular_user rejects admins).
Returns only documents belonging to the current user.
FOLD-05: sort by name|date|size; order asc|desc; folder_id filter;
q full-text search via plainto_tsquery (PostgreSQL only — silently skipped
on SQLite when function is unavailable). FTS scope is always scoped to
current_user.id (T-04-03-02).
Backward-compat: when sort/order/folder_id/q are not provided, behaviour
is identical to the pre-Phase-4 implementation.
"""
"""List documents with optional sort, folder filter, and full-text search."""
request.state.current_user = current_user
# If no new params used, fall through to the legacy storage.list_metadata path
# to preserve full backward compatibility with topic filtering.
if folder_id is None and q is None and sort == "date" and order == "desc":
docs = await storage.list_metadata(session, user_id=current_user.id, topic=topic)
total = len(docs)
start = (page - 1) * per_page
# Add is_shared field (Phase 4 addition)
shared_result = await session.execute(
select(Share.document_id).where(Share.owner_id == current_user.id)
items = await _decorate_shared_flags(
session,
current_user.id,
docs[start : start + per_page],
)
shared_ids = {row[0] for row in shared_result.fetchall()}
items = []
for d in docs[start : start + per_page]:
doc_id_str = d.get("id", "")
try:
doc_uuid = uuid.UUID(doc_id_str)
except (ValueError, AttributeError):
doc_uuid = None
d["is_shared"] = doc_uuid in shared_ids if doc_uuid else False
items.append(d)
return {"items": items, "total": total, "page": page, "per_page": per_page}
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
@@ -126,8 +102,6 @@ async def list_documents(
order_fn = sort_col.asc if order == "asc" else sort_col.desc
stmt = stmt.order_by(order_fn())
# Full-text search — plainto_tsquery on extracted_text (PostgreSQL only)
# Falls back to unfiltered if the DB dialect doesn't support @@ (e.g. SQLite in test env)
fts_requested = q is not None and len(q) >= 2
if fts_requested:
fts_stmt = stmt.where(
@@ -143,14 +117,11 @@ async def list_documents(
result = await session.execute(stmt)
docs_orm = result.scalars().all()
shared_result = await session.execute(
select(Share.document_id).where(Share.owner_id == current_user.id)
)
shared_ids = {row[0] for row in shared_result.fetchall()}
all_items = []
shared_ids = await _shared_document_ids(session, current_user.id)
for doc in docs_orm:
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
topic_names = await _load_topic_names(session, doc.id)
d = _doc_to_dict(doc, topic_names)
d["is_shared"] = doc.id in shared_ids
@@ -166,8 +137,6 @@ async def list_documents(
}
# ── GET /api/documents/{doc_id} ───────────────────────────────────────────────
@router.get("/{doc_id}")
@account_limiter.limit("100/minute")
async def get_document(
@@ -176,44 +145,18 @@ async def get_document(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Return document metadata by ID.
D-16: requires authenticated regular user. Asserts ownership — cross-user
access returns 404 (not 403) to avoid information leakage (T-03-11).
"""
"""Return document metadata by ID."""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None:
raise HTTPException(404, "Document not found")
is_recipient = False
if doc.user_id != current_user.id:
share_result = await session.execute(
select(Share).where(
Share.document_id == uid,
Share.recipient_id == current_user.id,
)
)
if share_result.scalar_one_or_none() is None:
raise HTTPException(404, "Document not found")
is_recipient = True
_, is_recipient = await get_accessible_document(session, doc_id, current_user.id)
meta = await storage.get_metadata(session, doc_id)
if meta is None:
raise HTTPException(404, "Document not found")
# T-04-04-03: recipients get metadata only — extracted_text excluded (consistent with /shares/received)
if is_recipient:
meta.pop("extracted_text", None)
return meta
# ── PATCH /api/documents/{doc_id} ────────────────────────────────────────────
@router.patch("/{doc_id}")
@account_limiter.limit("100/minute")
async def patch_document(
@@ -223,25 +166,9 @@ async def patch_document(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Update document metadata (filename and/or folder_id).
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
T-05-09-01: ownership check — non-owner gets 404 to avoid leaking document IDs (D-16).
T-05-09-02: response uses storage.get_metadata() which excludes credentials_enc and
password_hash via the _doc_to_dict whitelist.
At least one field must be provided — empty body returns 422.
folder_id=null moves the document to the root (no folder).
"""
"""Update document metadata."""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
doc = await get_owned_document(session, doc_id, current_user.id)
if not body.model_fields_set:
raise HTTPException(422, "At least one field (filename, folder_id) must be provided")
@@ -250,7 +177,6 @@ async def patch_document(
doc.filename = body.filename
if "folder_id" in body.model_fields_set:
# folder_id=null → move to root (no folder); folder_id=<uuid> → move to folder
if body.folder_id is not None:
target = await session.get(Folder, body.folder_id)
if target is None or target.user_id != current_user.id:
@@ -265,8 +191,6 @@ async def patch_document(
return meta
# ── DELETE /api/documents/{doc_id} ───────────────────────────────────────────
@router.delete("/{doc_id}")
@account_limiter.limit("100/minute")
async def delete_document(
@@ -276,27 +200,9 @@ async def delete_document(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Delete a document and decrement quota atomically.
For cloud-stored documents:
- Default path: attempt cloud provider delete first; on failure return
{success: false, cloud_delete_failed: true} (HTTP 200) so the frontend
can offer a "Remove from app" fallback (T-06.2-03-02).
- remove_only=true: skip cloud delete, remove DB row only, skip quota decrement.
- Cloud docs always use skip_quota=True (never charged MinIO quota, T-06.2-03-01).
D-16: requires authenticated regular user. Asserts ownership — cross-user
delete returns 404 (not 403) to avoid information leakage (T-03-11).
"""
"""Delete a document and decrement quota when appropriate."""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
doc = await get_owned_document(session, doc_id, current_user.id)
is_cloud = doc.storage_backend != "minio"
_doc_size = doc.size_bytes
@@ -305,7 +211,8 @@ async def delete_document(
if is_cloud and not remove_only:
try:
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
import api.documents as _doc_pkg
_gsb = _doc_pkg.get_storage_backend_for_document
cloud_backend = await _gsb(doc, current_user, session)
await cloud_backend.delete_object(doc.object_key)
@@ -320,13 +227,10 @@ async def delete_document(
},
)
# auto_commit=False defers the commit so the audit log write below happens
# in the same transaction — avoids the split-transaction gap (WR-08).
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False)
if not ok:
raise HTTPException(404, "Document not found")
# D-13: document deleted event — written in the same transaction as the delete (WR-08).
await write_audit_log(
session,
event_type="document.deleted",
@@ -341,8 +245,6 @@ async def delete_document(
return {"success": True}
# ── POST /api/documents/{doc_id}/classify ────────────────────────────────────
@router.post("/{doc_id}/classify")
@account_limiter.limit("100/minute")
async def classify_document(
@@ -351,25 +253,9 @@ async def classify_document(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Re-queue a document for classification via Celery (D-11).
Sets doc.status='processing', commits, dispatches extract_and_classify.delay(),
and returns {'document_id': str, 'status': 'processing'}.
D-16: requires authenticated regular user. Asserts ownership — cross-user
classify returns 404 (not 403) to avoid information leakage (T-03-11).
T-07-10: ownership enforced here; IDOR returns 404 per STATE.md policy.
Placed in crud.py per D-08: same ownership-check pattern as get/patch/delete.
"""
"""Re-queue a document for classification via Celery."""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
raise HTTPException(404, "Document not found")
doc = await session.get(Document, uid)
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
doc = await get_owned_document(session, doc_id, current_user.id)
doc.status = "processing"
await session.commit()
+53 -20
View File
@@ -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
+99 -179
View File
@@ -1,32 +1,11 @@
"""Document upload endpoints — presigned URL flow and direct cloud upload.
Endpoints:
POST /upload-url — create pending Document row, return presigned PUT URL (D-05 step 1)
POST /upload — direct multipart upload supporting cloud backends (D-10, D-14, D-15)
POST /{doc_id}/confirm — stat MinIO for authoritative size, enforce quota atomically (D-05 step 3)
Sub-router carries NO prefix — prefix="/api/documents" lives in __init__.py (D-04).
Security:
T-03-04: object_key computed server-side using str(current_user.id) — never user-supplied.
T-03-05: size from backend.stat_object() — never from client.
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
T-03-11: ownership assertion on confirm — cross-user access returns 404.
T-03-15: object_key prefix always the authenticated user's id.
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist.
T-05-06-02: CloudConnectionError detail never includes provider error detail.
"""
"""Document upload endpoints."""
from __future__ import annotations
import uuid
from pathlib import Path
import structlog as _structlog
_log = _structlog.get_logger(__name__)
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from sqlalchemy import text
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
@@ -36,24 +15,91 @@ from deps.db import get_db
from deps.utils import get_client_ip
from services.audit import write_audit_log
from services.rate_limiting import account_limiter
from storage import get_storage_backend, get_storage_backend_for_document
from storage import get_storage_backend
from storage.cloud_backend_factory import build_cloud_backend
from storage.cloud_utils import decrypt_credentials
from storage.exceptions import CloudConnectionError
from tasks.document_tasks import extract_and_classify
try:
from minio.error import S3Error
except ImportError:
S3Error = Exception # type: ignore[assignment,misc]
from sqlalchemy import select
from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS
router = APIRouter()
# ── POST /api/documents/upload-url ───────────────────────────────────────────
def _new_minio_document(user_id: uuid.UUID, filename: str, content_type: str) -> Document:
doc_id = uuid.uuid4()
suffix = Path(filename).suffix.lower()
return Document(
id=doc_id,
user_id=user_id,
filename=filename,
content_type=content_type,
size_bytes=0,
storage_backend="minio",
status="pending",
object_key=f"{user_id}/{doc_id}/{uuid.uuid4()}{suffix}",
)
async def _create_presigned_upload(
session: AsyncSession,
user_id: uuid.UUID,
filename: str,
content_type: str,
) -> dict:
doc = _new_minio_document(user_id, filename, content_type)
session.add(doc)
await session.commit()
upload_url = await get_storage_backend().generate_presigned_put_url(
doc.object_key, expires_minutes=15
)
return {"upload_url": upload_url, "document_id": str(doc.id)}
async def _get_active_cloud_connection(
session: AsyncSession,
user_id: uuid.UUID,
provider: str,
) -> CloudConnection:
result = await session.execute(
select(CloudConnection).where(
CloudConnection.user_id == user_id,
CloudConnection.provider == provider,
CloudConnection.status == "ACTIVE",
)
)
conn = result.scalar_one_or_none()
if conn is None:
raise HTTPException(
status_code=404,
detail=f"No active {provider} connection found. Please connect in Settings.",
)
return conn
def _decrypt_cloud_credentials(conn: CloudConnection, user_id: uuid.UUID) -> dict:
return decrypt_credentials(settings.cloud_creds_key.encode(), str(user_id), conn.credentials_enc)
async def _record_upload(
session: AsyncSession,
request: Request,
current_user: User,
doc: Document,
size_bytes: int,
storage_backend: str,
) -> None:
await write_audit_log(
session,
event_type="document.uploaded",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=doc.id,
ip_address=get_client_ip(request) if request else None,
metadata_={"size_bytes": size_bytes, "storage_backend": storage_backend},
)
@router.post("/upload-url")
@account_limiter.limit("100/minute")
@@ -63,41 +109,15 @@ async def request_upload_url(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Create a pending Document row and return a presigned PUT URL.
D-05 step 1: FastAPI creates a Document row (status='pending'), generates a
15-minute presigned PUT URL, returns {upload_url, document_id}.
Quota is NOT reserved at this step — quota enforcement happens at /confirm.
T-03-04: object_key is computed server-side using str(current_user.id); filename
stored in DB only (CLAUDE.md MinIO key schema).
T-03-15: object_key prefix is always the authenticated user's id — never user-supplied.
"""
"""Create a pending Document row and return a presigned PUT URL."""
request.state.current_user = current_user
doc_id = uuid.uuid4()
suffix = Path(body.filename).suffix.lower()
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
doc = Document(
id=doc_id,
user_id=current_user.id,
filename=body.filename,
content_type=body.content_type,
size_bytes=0,
storage_backend="minio",
status="pending",
object_key=object_key,
return await _create_presigned_upload(
session,
current_user.id,
body.filename,
body.content_type,
)
session.add(doc)
await session.commit()
upload_url = await get_storage_backend().generate_presigned_put_url(
object_key, expires_minutes=15
)
return {"upload_url": upload_url, "document_id": str(doc_id)}
# ── POST /api/documents/upload ────────────────────────────────────────────────
@router.post("/upload")
@account_limiter.limit("100/minute")
@@ -109,47 +129,15 @@ async def upload_document(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Direct multipart upload endpoint supporting cloud backends (D-10, D-14, D-15).
If target_backend == "minio": generates a presigned PUT URL (unchanged MinIO flow).
If target_backend in ("google_drive", "onedrive", "nextcloud", "webdav"):
1. Reads file bytes from UploadFile
2. Loads CloudConnection for current_user.id + target_backend; 404 if not found/not ACTIVE
3. Decrypts credentials and instantiates the correct backend class
4. Calls cloud_backend.put_object() to upload directly to the provider
5. Creates Document with storage_backend=target_backend
6. Returns {document_id, storage_backend} — no upload_url (cloud upload is synchronous)
Cloud uploads do NOT use the atomic quota UPDATE — cloud files are not counted
against MinIO quota (D-11: separate backends; cloud storage quota is provider-side).
Security:
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist → 422 on invalid value
T-05-06-02: CloudConnectionError detail message never includes provider error detail
"""
"""Direct multipart upload endpoint supporting cloud backends."""
request.state.current_user = current_user
if target_backend == "minio":
doc_id = uuid.uuid4()
suffix = Path(file.filename or "file").suffix.lower()
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
doc = Document(
id=doc_id,
user_id=current_user.id,
filename=file.filename or "upload",
content_type=file.content_type or "application/octet-stream",
size_bytes=0,
storage_backend="minio",
status="pending",
object_key=object_key,
return await _create_presigned_upload(
session,
current_user.id,
file.filename or "upload",
file.content_type or "application/octet-stream",
)
session.add(doc)
await session.commit()
upload_url = await get_storage_backend().generate_presigned_put_url(
object_key, expires_minutes=15
)
return {"upload_url": upload_url, "document_id": str(doc_id)}
if target_backend not in _CLOUD_PROVIDERS:
raise HTTPException(
@@ -157,23 +145,8 @@ async def upload_document(
detail=f"Invalid target_backend '{target_backend}'. Valid values: minio, {', '.join(sorted(_CLOUD_PROVIDERS))}",
)
# Load active CloudConnection for current user + provider (T-05-06-01: user-scoped query)
result = await session.execute(
select(CloudConnection).where(
CloudConnection.user_id == current_user.id,
CloudConnection.provider == target_backend,
CloudConnection.status == "ACTIVE",
)
)
conn = result.scalar_one_or_none()
if conn is None:
raise HTTPException(
status_code=404,
detail=f"No active {target_backend} connection found. Please connect in Settings.",
)
master_key = settings.cloud_creds_key.encode()
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
conn = await _get_active_cloud_connection(session, current_user.id, target_backend)
credentials = _decrypt_cloud_credentials(conn, current_user.id)
file_bytes = await file.read()
filename = file.filename or "upload"
@@ -182,26 +155,7 @@ async def upload_document(
doc_id = uuid.uuid4()
if target_backend == "google_drive":
from storage.google_drive_backend import GoogleDriveBackend # lazy import
cloud_backend = GoogleDriveBackend(credentials)
elif target_backend == "onedrive":
from storage.onedrive_backend import OneDriveBackend # lazy import
cloud_backend = OneDriveBackend(credentials)
elif target_backend == "nextcloud":
from storage.nextcloud_backend import NextcloudBackend # lazy import
cloud_backend = NextcloudBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
elif target_backend == "webdav":
from storage.webdav_backend import WebDAVBackend # lazy import
cloud_backend = WebDAVBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
cloud_backend = build_cloud_backend(target_backend, credentials)
try:
object_key = await cloud_backend.put_object(
@@ -219,9 +173,9 @@ async def upload_document(
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
) from exc
# Bust folder listing cache so the next GET /folders reflects the new file
if cloud_folder_path:
from services.cloud_cache import invalidate_provider_cache # lazy import
invalidate_provider_cache(str(current_user.id), target_backend)
doc = Document(
@@ -236,16 +190,7 @@ async def upload_document(
)
session.add(doc)
_ip = get_client_ip(request) if request else None
await write_audit_log(
session,
event_type="document.uploaded",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=doc.id,
ip_address=_ip,
metadata_={"size_bytes": len(file_bytes), "storage_backend": target_backend},
)
await _record_upload(session, request, current_user, doc, len(file_bytes), target_backend)
await session.commit()
extract_and_classify.delay(str(doc.id))
@@ -253,8 +198,6 @@ async def upload_document(
return {"document_id": str(doc.id), "storage_backend": target_backend}
# ── POST /api/documents/{doc_id}/confirm ─────────────────────────────────────
@router.post("/{doc_id}/confirm")
@account_limiter.limit("100/minute")
async def confirm_upload(
@@ -263,18 +206,7 @@ async def confirm_upload(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Confirm a presigned PUT upload: stat MinIO for size, enforce quota atomically.
D-05 step 3: FastAPI reads authoritative file size from MinIO stat_object (never
from client), runs atomic quota UPDATE, sets status='uploaded', enqueues Celery task.
Quota exceeded: HTTP 413 with {"used_bytes": N, "limit_bytes": M, "rejected_bytes": K}
Upload not found: HTTP 422 (presigned URL may have expired)
T-03-05: size always comes from backend.stat_object(doc.object_key) — never client.
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
T-03-11: ownership assertion — cross-user access returns 404 (D-16).
"""
"""Confirm a presigned PUT upload and enforce quota."""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
@@ -285,7 +217,6 @@ async def confirm_upload(
if doc is None or doc.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Document not found")
# Get authoritative file size from MinIO (T-03-05 — never trust client-supplied size)
try:
size = await get_storage_backend().stat_object(doc.object_key)
except Exception as exc:
@@ -300,7 +231,6 @@ async def confirm_upload(
doc.size_bytes = size
await session.flush()
# Atomic quota enforcement — user_id is always set post-migration (Plan 03-03+)
result = await session.execute(
text(
"UPDATE quotas "
@@ -323,7 +253,7 @@ async def confirm_upload(
try:
await get_storage_backend().delete_object(doc.object_key)
except Exception:
pass # MinIO cleanup is best-effort; object TTL will eventually expire
pass
await session.commit()
raise HTTPException(
status_code=413,
@@ -337,17 +267,7 @@ async def confirm_upload(
used_bytes = row.used_bytes
doc.status = "uploaded"
# D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02)
_ip = get_client_ip(request)
await write_audit_log(
session,
event_type="document.uploaded",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=doc.id,
ip_address=_ip,
metadata_={"size_bytes": size, "storage_backend": "minio"},
)
await _record_upload(session, request, current_user, doc, size, "minio")
await session.commit()
extract_and_classify.delay(str(doc.id))