"""Document routes: upload, get file, list, delete, commands. Auth and rate limit on each.""" import logging from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from fastapi.responses import FileResponse from app.api.schemas import CommandsRequest, commands_to_dicts from app.auth import require_api_key from app.config import get_max_upload_bytes from app.engine.commands import apply_commands from app.limiter import limiter, rate_limit_string from app.storage import file_storage from app.storage.file_storage import delete_document_file, read_document, write_document from app.storage.metadata import ( delete_document_metadata, generate_document_id, get_document, insert_document, list_documents, update_document_size, ) logger = logging.getLogger(__name__) router = APIRouter() rate = rate_limit_string() ALLOWED_MIME = { "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", } def _check_owner(meta: dict | None, api_key_id: str, document_id: str) -> None: if meta is None: raise HTTPException(status_code=404, detail="Document not found") if meta.get("api_key_id") != api_key_id: raise HTTPException(status_code=404, detail="Document not found") @router.post("", status_code=201, response_model=dict) @limiter.limit(rate) async def upload_document( request: Request, api_key_id: Annotated[str, Depends(require_api_key)], file: Annotated[UploadFile, File()], ) -> dict: """Upload an Office file. Returns document_id.""" content = await file.read() if len(content) > get_max_upload_bytes(): raise HTTPException( status_code=413, detail="File too large", ) mime = (file.content_type or "").strip().split(";")[0] if mime not in ALLOWED_MIME: raise HTTPException( status_code=400, detail=f"Unsupported file type. Allowed: {sorted(ALLOWED_MIME)}", ) name = file.filename or "document" document_id = generate_document_id() write_document(document_id, content) insert_document( document_id=document_id, api_key_id=api_key_id, name=name, mime_type=mime, size=len(content), ) logger.info("Uploaded document %s for key %s", document_id, api_key_id) return {"document_id": document_id, "name": name, "mime_type": mime, "size": len(content)} @router.get("") @limiter.limit(rate) async def list_docs( request: Request, api_key_id: Annotated[str, Depends(require_api_key)], ) -> list: """List documents for the authenticated API key.""" return list_documents(api_key_id) @router.get("/{document_id}") @limiter.limit(rate) async def get_metadata( request: Request, document_id: str, api_key_id: Annotated[str, Depends(require_api_key)], ) -> dict: """Get document metadata.""" meta = get_document(document_id) _check_owner(meta, api_key_id, document_id) return meta @router.get("/{document_id}/file") @limiter.limit(rate) async def download_file( request: Request, document_id: str, api_key_id: Annotated[str, Depends(require_api_key)], ): """Download document file.""" meta = get_document(document_id) _check_owner(meta, api_key_id, document_id) path = file_storage.get_document_path(document_id) if not path.is_file(): raise HTTPException(status_code=404, detail="Document file not found") return FileResponse( path, media_type=meta.get("mime_type", "application/octet-stream"), filename=meta.get("name", "document"), ) @router.post("/{document_id}/commands") @limiter.limit(rate) async def apply_document_commands( request: Request, document_id: str, body: CommandsRequest, api_key_id: Annotated[str, Depends(require_api_key)], ) -> dict: """Apply commands to document. Supported for docx: replaceText, insertParagraph.""" meta = get_document(document_id) _check_owner(meta, api_key_id, document_id) content = read_document(document_id) mime = meta.get("mime_type", "") try: new_content = apply_commands(content, mime, commands_to_dicts(body.commands)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) write_document(document_id, new_content) update_document_size(document_id, len(new_content)) logger.info("Applied %d commands to document %s", len(body.commands), document_id) return {"document_id": document_id, "size": len(new_content)} @router.delete("/{document_id}", status_code=204) @limiter.limit(rate) async def delete_doc( request: Request, document_id: str, api_key_id: Annotated[str, Depends(require_api_key)], ) -> None: """Delete document and its file.""" meta = get_document(document_id) _check_owner(meta, api_key_id, document_id) deleted = delete_document_metadata(document_id) delete_document_file(document_id) if not deleted: raise HTTPException(status_code=404, detail="Document not found")