From 0483af8023bc178f7ee4b77aa7a81090d467920a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 2 Jun 2026 16:10:44 +0200 Subject: [PATCH] feat(file-storage): add listing and download routes --- surfsense_backend/app/file_storage/api.py | 89 +++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 surfsense_backend/app/file_storage/api.py diff --git a/surfsense_backend/app/file_storage/api.py b/surfsense_backend/app/file_storage/api.py new file mode 100644 index 000000000..c649ba63d --- /dev/null +++ b/surfsense_backend/app/file_storage/api.py @@ -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)}, + )