theo-agent-dashboard

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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     )