feat(file-storage): add listing and download routes

This commit is contained in:
CREDO23 2026-06-02 16:10:44 +02:00
parent 7065615043
commit 0483af8023

View file

@ -0,0 +1,89 @@
"""HTTP routes for document file storage (metadata listing + original download)."""
from __future__ import annotations
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Document, Permission, User, get_async_session
from app.file_storage.persistence.enums import DocumentFileKind
from app.file_storage.schemas import DocumentFileRead
from app.file_storage.service import (
get_document_file,
list_document_files,
open_document_file_stream,
)
from app.users import current_active_user
from app.utils.rbac import check_permission
router = APIRouter()
async def _load_readable_document(
*, document_id: int, session: AsyncSession, user: User
) -> Document:
"""Load a document the user may read, or raise 404/403."""
document = (
await session.execute(select(Document).where(Document.id == document_id))
).scalar_one_or_none()
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
await check_permission(
session,
user,
document.search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
)
return document
def _content_disposition(filename: str) -> str:
"""Build an attachment header safe for arbitrary filenames (RFC 5987)."""
fallback = filename.encode("ascii", "ignore").decode("ascii") or "download"
fallback = fallback.replace('"', "")
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
@router.get(
"/documents/{document_id}/files",
response_model=list[DocumentFileRead],
)
async def read_document_files(
document_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> list[DocumentFileRead]:
"""Return metadata for every stored file of a document (gates the UI)."""
await _load_readable_document(document_id=document_id, session=session, user=user)
records = await list_document_files(session, document_id=document_id)
return [DocumentFileRead.model_validate(r) for r in records]
@router.get("/documents/{document_id}/download-original")
async def download_original_document_file(
document_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
) -> StreamingResponse:
"""Stream the document's original uploaded file."""
await _load_readable_document(document_id=document_id, session=session, user=user)
record = await get_document_file(
session, document_id=document_id, kind=DocumentFileKind.ORIGINAL
)
if record is None:
raise HTTPException(
status_code=404, detail="No original file stored for this document"
)
return StreamingResponse(
open_document_file_stream(record),
media_type=record.mime_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(record.original_filename)},
)