87 lines
2.8 KiB
Python
87 lines
2.8 KiB
Python
"""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}")
|