import re import httpx from urllib.parse import urlparse from fastapi import APIRouter, File, HTTPException, UploadFile, status from pydantic import BaseModel from app.schemas.document import ( DocumentInfoResponse, PageInfoResponse, PermissionsResponse, ) from app.services import engine from app.services.store import document_store router = APIRouter(prefix="/documents", tags=["documents"]) class RemoteImportRequest(BaseModel): stream_url: str upload_url: str auth_token: str | None = None resource_id: str | None = None @router.post("/import-remote", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED) async def import_remote_document(req: RemoteImportRequest) -> DocumentInfoResponse: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) stream_parsed = urlparse(req.stream_url) upload_parsed = urlparse(req.upload_url) if stream_parsed.scheme not in ("http", "https") or upload_parsed.scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="Invalid URL scheme. Only http/https are allowed.") headers = {} if req.auth_token: headers["Authorization"] = f"Bearer {req.auth_token}" MAX_SIZE = 50 * 1024 * 1024 # 50 MB limit async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: try: async with client.stream("GET", req.stream_url, headers=headers) as res: if res.status_code != 200: await res.aread() raise HTTPException( status_code=res.status_code, detail=f"Failed to stream remote document: {res.text[:200]}", ) content_length = res.headers.get("Content-Length") if content_length and int(content_length) > MAX_SIZE: raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).") bytes_data_arr = bytearray() async for chunk in res.aiter_bytes(): bytes_data_arr.extend(chunk) if len(bytes_data_arr) > MAX_SIZE: raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).") bytes_data = bytes(bytes_data_arr) except Exception as err: if isinstance(err, HTTPException): raise err raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to connect to remote stream_url: {err!s}", ) cd = res.headers.get("Content-Disposition", "") match = re.search(r'filename="?([^";]+)"?', cd) filename = match.group(1) if match else f"remote_document_{req.resource_id or 'file'}.pdf" try: pdfengine = engine.require() doc = pdfengine.PdfDocument.load_from_memory(bytes_data, "") info = document_store.add_document(filename, bytes_data, doc) info["remote_context"] = { "stream_url": req.stream_url, "upload_url": req.upload_url, "auth_token": req.auth_token, "resource_id": req.resource_id, } return make_document_response(info) except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load remote PDF into engine: {e!s}", ) def make_document_response(d: dict) -> DocumentInfoResponse: pages_list = [] if "doc_instance" in d: doc = d["doc_instance"] for i in range(doc.page_count): try: page = doc.get_page(i) pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height)) except Exception: pass perms = d.get("permissions") return DocumentInfoResponse( id=d["id"], filename=d["filename"], sizeBytes=d["sizeBytes"], totalPages=d["totalPages"], pageWidth=d.get("pageWidth", 612.0), pageHeight=d.get("pageHeight", 792.0), uploadedAt=d["uploadedAt"], status=d["status"], pages=pages_list, permissions=PermissionsResponse(**perms) if perms else PermissionsResponse(), ) @router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED) async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) bytes_data = await file.read() try: pdfengine = engine.require() doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password) info = document_store.add_document(file.filename, bytes_data, doc) return make_document_response(info) except ValueError as e: detail = str(e) if "Password required" in detail: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required" ) elif "Invalid password" in detail: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password") else: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {e!s}" ) @router.get("", response_model=list[DocumentInfoResponse]) def list_documents() -> list[DocumentInfoResponse]: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) docs = document_store.list_documents() return [make_document_response(d) for d in docs] @router.get("/{document_id}", response_model=DocumentInfoResponse) def get_document(document_id: str) -> DocumentInfoResponse: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) d = document_store.get_document(document_id) if not d: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") return make_document_response(d) @router.delete("/{document_id}") def delete_document(document_id: str): if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) deleted = document_store.delete_document(document_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") return {"success": True}