feat(evidence): add S3 storage service and file_uploads model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
86
backend/app/services/storage_service.py
Normal file
86
backend/app/services/storage_service.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""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}")
|
||||
Reference in New Issue
Block a user