ghost-shopper-api / python /services /storage_service.py
azzaraqi
Deploy FastAPI backend with Supabase pooler support
b84ea83
Raw
History Blame Contribute Delete
7.15 kB
"""
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()