""" Storage Service — Presigned URL Generation for Cloud Object Storage Architecture Reference: insights/architecture_rule.md "Direct Upload (Pemisahan Beban): FastAPI tidak menerima file binary gambar. API hanya memberikan Presigned URL, lalu Flutter mengunggah foto langsung ke S3/MinIO." This service generates short-lived presigned URLs so that the Flutter client can upload photo evidence directly to cloud storage (S3 / GCS / MinIO) without routing binary payloads through FastAPI. Flow: 1. Flutter requests → POST /api/v1/photos/presigned-url 2. This service generates a presigned PUT URL (expires in 5 min) 3. Flutter uploads binary directly to the presigned URL 4. Flutter confirms → POST /api/v1/photos/confirm (saves URL to DB) Environment Variables: STORAGE_BACKEND = "minio" | "s3" | "gcs" STORAGE_ENDPOINT = http://localhost:9000 (MinIO only) STORAGE_BUCKET = apl-gs-photos STORAGE_ACCESS_KEY = minioadmin STORAGE_SECRET_KEY = minioadmin STORAGE_REGION = us-east-1 (S3 only) """ import os import uuid import logging from datetime import timedelta logger = logging.getLogger(__name__) # ── Configuration ──────────────────────────────────────────── STORAGE_BACKEND = os.getenv("STORAGE_BACKEND", "minio") STORAGE_ENDPOINT = os.getenv("STORAGE_ENDPOINT", "http://localhost:9000") STORAGE_BUCKET = os.getenv("STORAGE_BUCKET", "apl-gs-photos") STORAGE_ACCESS_KEY = os.getenv("STORAGE_ACCESS_KEY", "minioadmin") STORAGE_SECRET_KEY = os.getenv("STORAGE_SECRET_KEY", "minioadmin") STORAGE_REGION = os.getenv("STORAGE_REGION", "us-east-1") # Presigned URL expiry (5 minutes — matches architecture spec) PRESIGN_EXPIRY = timedelta(minutes=5) class StorageService: """ Generates presigned URLs for direct client-to-storage uploads. Supports three backends: - MinIO (local development via docker-compose) - AWS S3 (production) - Google Cloud Storage (production alternative) """ def __init__(self): self._client = None self._backend = STORAGE_BACKEND logger.info(f"StorageService initialized — backend: {self._backend}") # ── Lazy Client Init ───────────────────────────────────── def _get_s3_client(self): """Create boto3 S3 client (works for both S3 and MinIO).""" if self._client is None: try: import boto3 from botocore.config import Config kwargs = { "aws_access_key_id": STORAGE_ACCESS_KEY, "aws_secret_access_key": STORAGE_SECRET_KEY, "region_name": STORAGE_REGION, "config": Config(signature_version="s3v4"), } # MinIO requires explicit endpoint_url if self._backend == "minio": kwargs["endpoint_url"] = STORAGE_ENDPOINT self._client = boto3.client("s3", **kwargs) logger.info(f"S3 client created — endpoint: {kwargs.get('endpoint_url', 'AWS default')}") except ImportError: logger.error( "boto3 not installed. Add 'boto3' to requirements.txt " "to enable S3/MinIO storage." ) raise return self._client # ── Generate Presigned URL ─────────────────────────────── async def generate_presigned_upload_url( self, file_extension: str = "jpg", visit_id: str | None = None, assessment_id: str | None = None, ) -> dict: """ Generate a presigned PUT URL for direct upload. Returns: { "upload_url": "https://...", "object_key": "visits//.jpg", "expires_in_seconds": 300, "storage_url": "https:///" } """ # Build a meaningful object key prefix = "photos" if visit_id: prefix = f"visits/{visit_id}" if assessment_id: prefix = f"assessments/{assessment_id}" object_key = f"{prefix}/{uuid.uuid4().hex}.{file_extension}" if self._backend in ("s3", "minio"): client = self._get_s3_client() upload_url = client.generate_presigned_url( "put_object", Params={ "Bucket": STORAGE_BUCKET, "Key": object_key, "ContentType": f"image/{file_extension}", }, ExpiresIn=int(PRESIGN_EXPIRY.total_seconds()), ) # The permanent storage URL after upload completes if self._backend == "minio": storage_url = f"{STORAGE_ENDPOINT}/{STORAGE_BUCKET}/{object_key}" else: storage_url = f"https://{STORAGE_BUCKET}.s3.{STORAGE_REGION}.amazonaws.com/{object_key}" elif self._backend == "gcs": # Google Cloud Storage presigned URL # Requires: google-cloud-storage package try: from google.cloud import storage as gcs gcs_client = gcs.Client() bucket = gcs_client.bucket(STORAGE_BUCKET) blob = bucket.blob(object_key) upload_url = blob.generate_signed_url( version="v4", expiration=PRESIGN_EXPIRY, method="PUT", content_type=f"image/{file_extension}", ) storage_url = f"https://storage.googleapis.com/{STORAGE_BUCKET}/{object_key}" except ImportError: logger.error( "google-cloud-storage not installed. Add it to requirements.txt." ) raise else: raise ValueError(f"Unsupported storage backend: {self._backend}") logger.info(f"Presigned URL generated — key: {object_key}") return { "upload_url": upload_url, "object_key": object_key, "expires_in_seconds": int(PRESIGN_EXPIRY.total_seconds()), "storage_url": storage_url, } # ── Ensure Bucket Exists (for local dev) ───────────────── async def ensure_bucket(self): """Create the storage bucket if it doesn't exist (MinIO/S3).""" if self._backend in ("s3", "minio"): client = self._get_s3_client() try: client.head_bucket(Bucket=STORAGE_BUCKET) logger.info(f"Bucket '{STORAGE_BUCKET}' exists") except Exception: client.create_bucket(Bucket=STORAGE_BUCKET) logger.info(f"Bucket '{STORAGE_BUCKET}' created") # Singleton instance storage_service = StorageService()