"""S3-compatible object storage service for file uploads.""" import base64 import logging import uuid from io import BytesIO from typing import Any from uuid import UUID import boto3 from botocore.config import Config as BotoConfig from botocore.exceptions import ClientError from app.core.config import settings logger = logging.getLogger(__name__) ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"} ALLOWED_TEXT_TYPES = {"text/plain", "text/csv", "application/octet-stream"} DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ALLOWED_DOCUMENT_TYPES = {"application/pdf", DOCX_MIME} ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_TEXT_TYPES | ALLOWED_DOCUMENT_TYPES MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB MAX_TEXT_SIZE = 1 * 1024 * 1024 # 1MB MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB MAX_FILES_PER_SESSION = 20 MAX_BYTES_PER_SESSION = 50 * 1024 * 1024 # 50MB PRESIGNED_URL_EXPIRY = 3600 # 1 hour def _get_client(): """Get S3 client configured for Railway Object Storage.""" if not settings.STORAGE_ENDPOINT: raise RuntimeError("Object storage not configured (STORAGE_ENDPOINT missing)") return boto3.client( "s3", endpoint_url=settings.STORAGE_ENDPOINT, aws_access_key_id=settings.STORAGE_ACCESS_KEY, aws_secret_access_key=settings.STORAGE_SECRET_KEY, region_name=settings.STORAGE_REGION, config=BotoConfig(signature_version="s3v4"), ) def validate_upload(content_type: str, size_bytes: int) -> str | None: """Validate file type and size. Returns error message or None.""" if content_type not in ALLOWED_TYPES: return f"File type {content_type} not allowed" if content_type in ALLOWED_IMAGE_TYPES: max_size = MAX_IMAGE_SIZE elif content_type in ALLOWED_DOCUMENT_TYPES: max_size = MAX_DOCUMENT_SIZE else: max_size = MAX_TEXT_SIZE if size_bytes > max_size: return f"File too large ({size_bytes} bytes, max {max_size})" return None async def upload_file( file_data: bytes, filename: str, content_type: str, account_id: str, ) -> str: """Upload file to S3, returns the storage key.""" ext = filename.rsplit(".", 1)[-1] if "." in filename else "bin" storage_key = f"uploads/{account_id}/{uuid.uuid4()}.{ext}" client = _get_client() client.upload_fileobj( BytesIO(file_data), settings.STORAGE_BUCKET_NAME, storage_key, ExtraArgs={"ContentType": content_type}, ) return storage_key def download_file(storage_key: str) -> bytes: """Download a file from S3 and return its contents as bytes.""" client = _get_client() buf = BytesIO() client.download_fileobj(settings.STORAGE_BUCKET_NAME, storage_key, buf) return buf.getvalue() def get_presigned_url(storage_key: str) -> str: """Generate a time-limited presigned URL for downloading a file.""" client = _get_client() return client.generate_presigned_url( "get_object", Params={"Bucket": settings.STORAGE_BUCKET_NAME, "Key": storage_key}, ExpiresIn=PRESIGNED_URL_EXPIRY, ) async def delete_file(storage_key: str) -> None: """Delete a file from S3.""" try: client = _get_client() client.delete_object(Bucket=settings.STORAGE_BUCKET_NAME, Key=storage_key) except ClientError: logger.warning(f"Failed to delete S3 object: {storage_key}") # ── Vision helpers (resize + fetch for AI) ───────────────────── # Claude vision costs: (width × height) / 750 tokens per image. # Claude auto-resizes images >1568px on the longest edge. # We resize server-side to avoid sending multi-MB base64 payloads over the wire. MAX_IMAGE_DIMENSION = 1568 # Claude's max efficient resolution MAX_IMAGES_PER_MESSAGE = 3 # Cap to control token budget def resize_image_for_vision(file_data: bytes, content_type: str) -> tuple[bytes, str]: """Resize image to fit within Claude's efficient vision bounds. Returns (resized_bytes, media_type). Converts PNG screenshots to JPEG when it reduces size significantly (screenshots are often huge PNGs). """ try: from PIL import Image img = Image.open(BytesIO(file_data)) w, h = img.size # Only resize if larger than Claude's max efficient dimension if max(w, h) > MAX_IMAGE_DIMENSION: ratio = MAX_IMAGE_DIMENSION / max(w, h) new_w, new_h = int(w * ratio), int(h * ratio) img = img.resize((new_w, new_h), Image.LANCZOS) # Convert RGBA (common in screenshots) to RGB for JPEG out_type = content_type if img.mode in ("RGBA", "P") and content_type == "image/png": img = img.convert("RGB") out_type = "image/jpeg" buf = BytesIO() if out_type == "image/jpeg": img.save(buf, format="JPEG", quality=85, optimize=True) else: img.save(buf, format=img.format or "PNG", optimize=True) result = buf.getvalue() # Only use resized version if it's actually smaller if len(result) < len(file_data): return result, out_type return file_data, content_type except ImportError: # Pillow not installed — send original (Claude auto-resizes) logger.debug("Pillow not available, sending original image to Claude") return file_data, content_type except Exception: logger.warning("Image resize failed, sending original") return file_data, content_type async def fetch_upload_images( upload_ids: list[UUID], account_id: UUID, db: Any, ) -> list[dict[str, Any]]: """Fetch uploaded images from S3 and return as base64-encoded dicts for Claude vision. Resizes images server-side to reduce network payload and applies a per-message cap to control token budget (~1,600 tokens per full-res image). """ if not upload_ids or not settings.STORAGE_ENDPOINT: return [] from sqlalchemy import select from app.models.file_upload import FileUpload # Cap the number of images to limit token cost capped_ids = upload_ids[:MAX_IMAGES_PER_MESSAGE] if len(upload_ids) > MAX_IMAGES_PER_MESSAGE: logger.info( "Capped images from %d to %d for token budget", len(upload_ids), MAX_IMAGES_PER_MESSAGE, ) result = await db.execute( select(FileUpload).where( FileUpload.id.in_(capped_ids), FileUpload.account_id == account_id, FileUpload.content_type.in_(ALLOWED_IMAGE_TYPES), ) ) uploads = result.scalars().all() images: list[dict[str, Any]] = [] for upload in uploads: try: file_data = download_file(upload.storage_key) resized_data, media_type = resize_image_for_vision( file_data, upload.content_type ) images.append({ "media_type": media_type, "data": base64.b64encode(resized_data).decode("ascii"), }) except Exception: logger.warning("Failed to fetch upload %s from S3", upload.id) return images DOCUMENT_CONTENT_TYPES = ALLOWED_DOCUMENT_TYPES | ALLOWED_TEXT_TYPES MAX_DOCUMENT_CONTEXT_CHARS = 10_000 # Cap total injected text to control token cost async def fetch_upload_documents( upload_ids: list[UUID], account_id: UUID, db: Any, ) -> list[dict[str, str]]: """Fetch extracted text content for non-image uploads (PDFs, text files). Returns a list of dicts with 'filename', 'content_type', and 'text' keys. Text is sourced from the FileUpload.extracted_content field (populated during upload by _generate_ai_description). Falls back to downloading and decoding text files from S3 if extracted_content is empty. """ if not upload_ids: return [] from sqlalchemy import select from app.models.file_upload import FileUpload result = await db.execute( select(FileUpload).where( FileUpload.id.in_(upload_ids), FileUpload.account_id == account_id, FileUpload.content_type.in_(DOCUMENT_CONTENT_TYPES), ) ) uploads = result.scalars().all() documents: list[dict[str, str]] = [] total_chars = 0 for upload in uploads: text = upload.extracted_content or "" # Fallback: for text files without extracted_content, fetch from S3 if not text and upload.content_type in ALLOWED_TEXT_TYPES and settings.STORAGE_ENDPOINT: try: file_data = download_file(upload.storage_key) try: text = file_data.decode("utf-8") except UnicodeDecodeError: text = file_data.decode("latin-1") text = text[:MAX_DOCUMENT_CONTEXT_CHARS] except Exception: logger.warning("Failed to fetch text upload %s from S3", upload.id) continue if not text: # PDF with no extractable text — include a note so AI knows documents.append({ "filename": upload.filename, "content_type": upload.content_type, "text": f"[Attached file: {upload.filename} — no extractable text content]", }) continue # Cap per-document and total to control token budget remaining = MAX_DOCUMENT_CONTEXT_CHARS - total_chars if remaining <= 0: break truncated = text[:remaining] total_chars += len(truncated) documents.append({ "filename": upload.filename, "content_type": upload.content_type, "text": truncated, }) return documents