Spaces:
Sleeping
Sleeping
| """ | |
| 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/<visit_id>/<uuid>.jpg", | |
| "expires_in_seconds": 300, | |
| "storage_url": "https://<bucket>/<key>" | |
| } | |
| """ | |
| # 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() | |