"""S3-compatible object storage service for file uploads.""" import logging import uuid from io import BytesIO 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"} ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_TEXT_TYPES MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB MAX_TEXT_SIZE = 1 * 1024 * 1024 # 1MB 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" max_size = MAX_IMAGE_SIZE if content_type in ALLOWED_IMAGE_TYPES else 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 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}")