attachments.py (5152B)
1 """Attachment routes: upload, download, and data URL generation.""" 2 3 import base64 4 import mimetypes 5 import time 6 import uuid 7 from pathlib import Path 8 9 from fastapi import APIRouter, File, Form, HTTPException, UploadFile 10 from fastapi.responses import FileResponse, JSONResponse 11 12 from app.config import get_config 13 from app.db import Database 14 from app.models import AttachmentResponse 15 16 router = APIRouter(prefix="/api/attachments", tags=["attachments"]) 17 18 _db: Database | None = None 19 20 21 def set_db(db: Database) -> None: # pragma: no mutate: block 22 """Set the module-level database instance.""" 23 global _db # noqa: PLW0603 24 _db = db 25 26 27 _MAX_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB 28 _IMAGE_PREFIXES = ("image/",) 29 30 31 def _ensure_db() -> Database: # pragma: no mutate: block 32 """Return the module-level database instance.""" 33 if _db is None: 34 raise RuntimeError("Attachment DB not initialized") 35 return _db 36 37 38 def _attachment_dir(session_id: str) -> Path: # pragma: no mutate: block 39 """Return (and create) the storage directory for a session.""" 40 base = Path(get_config().data_dir) / "attachments" / session_id 41 base.mkdir(parents=True, exist_ok=True) 42 return base 43 44 45 def _save_file( # pragma: no mutate: block 46 content: bytes, 47 filename: str, 48 session_id: str, 49 ) -> tuple[str, str]: 50 """Write file to disk and return (attachment_id, disk_path).""" 51 ext = Path(filename).suffix or "" 52 attachment_id = str(uuid.uuid4()) 53 disk_path = _attachment_dir(session_id) / f"{attachment_id}{ext}" 54 disk_path.write_bytes(content) 55 return attachment_id, str(disk_path) 56 57 58 def _store_metadata( # pragma: no mutate: block 59 attachment_id: str, 60 session_id: str, 61 filename: str, 62 content_type: str, 63 size_bytes: int, 64 disk_path: str, 65 created_at: int, 66 ) -> None: # pragma: no mutate: block 67 """Persist attachment metadata in SQLite.""" 68 db = _ensure_db() 69 db.connection.execute( 70 """INSERT INTO attachments 71 (id, session_id, filename, content_type, size_bytes, disk_path, created_at) 72 VALUES (?, ?, ?, ?, ?, ?, ?)""", 73 (attachment_id, session_id, filename, content_type, size_bytes, disk_path, created_at), 74 ) 75 db.connection.commit() 76 77 78 def _fetch_metadata(attachment_id: str) -> dict | None: # pragma: no mutate: block 79 """Look up attachment metadata by id.""" 80 db = _ensure_db() 81 rows = db.connection.execute( 82 "SELECT id, filename, content_type, size_bytes, disk_path, created_at " 83 "FROM attachments WHERE id = ?", 84 (attachment_id,), 85 ).fetchall() 86 if not rows: 87 return None 88 row = rows[0] 89 return { 90 "id": row[0], 91 "filename": row[1], 92 "content_type": row[2], 93 "size_bytes": row[3], 94 "disk_path": row[4], 95 "created_at": row[5], 96 } 97 98 99 def _is_image(content_type: str) -> bool: # pragma: no mutate: block 100 """Check if the MIME type is an image.""" 101 return content_type.startswith(_IMAGE_PREFIXES) 102 103 104 def _to_data_url(content: bytes, content_type: str) -> str: # pragma: no mutate: block 105 """Convert binary content to a base64 data URL.""" 106 encoded = base64.b64encode(content).decode("ascii") 107 return f"data:{content_type};base64,{encoded}" 108 109 110 @router.post("", response_model=AttachmentResponse) 111 async def upload_attachment( # pragma: no mutate: block 112 file: UploadFile = File(...), 113 session_id: str = Form(...), 114 return_data_url: str = Form("false"), 115 ) -> AttachmentResponse: # pragma: no mutate: block 116 """Upload a file attachment. Rejects files > 50 MB with 413.""" 117 content = await file.read() 118 if len(content) > _MAX_SIZE_BYTES: 119 raise HTTPException(status_code=413, detail="File exceeds 50 MB limit") 120 121 content_type = file.content_type or "application/octet-stream" 122 created_at = int(time.time()) 123 124 attachment_id, disk_path = _save_file(content, file.filename, session_id) 125 _store_metadata( 126 attachment_id, session_id, file.filename, 127 content_type, len(content), disk_path, created_at, 128 ) 129 130 response = AttachmentResponse( 131 id=attachment_id, 132 filename=file.filename, 133 content_type=content_type, 134 size_bytes=len(content), 135 created_at=created_at, 136 ) 137 138 # Attach data URL if requested for image uploads 139 if return_data_url == "true" and _is_image(content_type): 140 payload = response.model_dump() 141 payload["data_url"] = _to_data_url(content, content_type) 142 return JSONResponse(content=payload) 143 144 return response 145 146 147 @router.get("/{attachment_id}") 148 async def download_attachment(attachment_id: str) -> FileResponse: # pragma: no mutate: block 149 """Download an attachment by ID.""" 150 meta = _fetch_metadata(attachment_id) 151 if meta is None: 152 raise HTTPException(status_code=404, detail="Attachment not found") 153 154 disk_path = Path(meta["disk_path"]) 155 if not disk_path.exists(): 156 raise HTTPException(status_code=404, detail="File missing from disk") 157 158 return FileResponse( 159 path=str(disk_path), 160 media_type=meta["content_type"], 161 filename=meta["filename"], 162 )