| import boto3
|
| import os
|
| import requests
|
| import asyncio
|
| from typing import Optional
|
| from botocore.exceptions import NoCredentialsError, ClientError
|
| from fastapi import UploadFile, HTTPException
|
| import uuid
|
| from ..logger import SingletonLogger
|
| from .base import StorageProvider
|
|
|
|
|
| class SupabaseStorage(StorageProvider):
|
| ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/png"]
|
|
|
| def __init__(
|
| self,
|
| endpoint_url: Optional[str] = None,
|
| key_id: Optional[str] = None,
|
| application_key: Optional[str] = None,
|
| bucket_name: Optional[str] = None,
|
| region_name: Optional[str] = None,
|
| service_role_key: Optional[str] = None,
|
| supabase_api_key: Optional[str] = None,
|
| s3_client: Optional[object] = None,
|
| ):
|
|
|
| self.endpoint_url = endpoint_url or os.getenv("S3_STORAGE_URL")
|
| self.key_id = key_id or os.getenv("S3_ACCESS_KEY_ID")
|
| self.application_key = application_key or os.getenv("S3_SECRET_ACCESS_KEY")
|
| self.bucket_name = bucket_name or os.getenv("S3_STORAGE_BUCKET_NAME")
|
| self.region_name = region_name or os.getenv("S3_STORAGE_REGION")
|
| self.service_role_key = service_role_key or os.getenv(
|
| "SUPABASE_SERVICE_ROLE_KEY"
|
| )
|
| self.supabase_api_key = supabase_api_key or os.getenv("SUPABASE_API_KEY")
|
|
|
| if not all(
|
| [self.endpoint_url, self.key_id, self.application_key, self.bucket_name]
|
| ):
|
| raise ValueError(
|
| "Supabase storage credentials not properly configured. "
|
| "Make sure S3_STORAGE_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, "
|
| "and S3_STORAGE_BUCKET_NAME are set in your .env file."
|
| )
|
|
|
|
|
| self.project_ref = self.endpoint_url.split(".")[0].replace("https://", "")
|
|
|
|
|
| if s3_client is None:
|
| client_kwargs = {
|
| "endpoint_url": self.endpoint_url,
|
| "aws_access_key_id": self.key_id,
|
| "aws_secret_access_key": self.application_key,
|
| }
|
| if self.region_name:
|
| client_kwargs["region_name"] = self.region_name
|
| self.s3_client = boto3.client("s3", **client_kwargs)
|
| else:
|
| self.s3_client = s3_client
|
|
|
|
|
| self._ensure_bucket_exists_and_public()
|
|
|
| def _ensure_bucket_exists_and_public(self):
|
| """Check if bucket exists and make it public if needed"""
|
| try:
|
|
|
| self.s3_client.head_bucket(Bucket=self.bucket_name)
|
| except ClientError as e:
|
| error_code = e.response["Error"]["Code"]
|
| if error_code == "404" or error_code == "NoSuchBucket":
|
| raise ValueError(
|
| f"Bucket '{self.bucket_name}' does not exist. "
|
| "Please create it in your Supabase dashboard under Storage first."
|
| )
|
| else:
|
| raise ValueError(f"Bucket access error: {e}")
|
|
|
|
|
| self._make_bucket_public()
|
|
|
| def _make_bucket_public(self):
|
| """Make bucket public using Supabase REST API"""
|
| if not self.service_role_key or not self.supabase_api_key:
|
| SingletonLogger().get_logger().warning(
|
| "SUPABASE_SERVICE_ROLE_KEY or SUPABASE_API_KEY not found. Bucket may not be public."
|
| )
|
| SingletonLogger().get_logger().warning(
|
| "Please ensure the bucket is set to public in your Supabase dashboard."
|
| )
|
| return
|
|
|
| try:
|
| url = f"https://{self.project_ref}.supabase.co/storage/v1/bucket/{self.bucket_name}"
|
| headers = {
|
| "apikey": self.service_role_key,
|
| "Authorization": f"Bearer {self.service_role_key}",
|
| "Content-Type": "application/json",
|
| }
|
| data = {"name": self.bucket_name, "public": True}
|
|
|
| response = requests.put(url, headers=headers, json=data)
|
| if response.status_code == 200:
|
| SingletonLogger().get_logger().info(
|
| f"Successfully made bucket '{self.bucket_name}' public"
|
| )
|
| else:
|
| SingletonLogger().get_logger().warning(
|
| f"Could not make bucket public. Status: {response.status_code}"
|
| )
|
| SingletonLogger().get_logger().debug(f"Response: {response.text}")
|
| SingletonLogger().get_logger().warning(
|
| "Please ensure the bucket is set to public in your Supabase dashboard."
|
| )
|
| except Exception as e:
|
| SingletonLogger().get_logger().warning(f"Error making bucket public: {e}")
|
| SingletonLogger().get_logger().warning(
|
| "Please ensure the bucket is set to public in your Supabase dashboard."
|
| )
|
|
|
| async def upload_file(
|
| self, file: UploadFile, user_id: int, folder: str = "avatar"
|
| ) -> str:
|
| """Upload file to Supabase Storage and return the file key"""
|
|
|
| if not SupabaseStorage.is_allowed_content_type(file.content_type):
|
| raise HTTPException(
|
| status_code=400, detail="Only JPEG and PNG files are allowed"
|
| )
|
|
|
|
|
| file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
|
| unique_filename = f"{uuid.uuid4()}.{file_extension}"
|
|
|
|
|
| key = SupabaseStorage.build_key(user_id, folder, unique_filename)
|
|
|
| try:
|
|
|
| file_content = await file.read()
|
|
|
|
|
| await asyncio.to_thread(
|
| self.s3_client.put_object,
|
| Bucket=self.bucket_name,
|
| Key=key,
|
| Body=file_content,
|
| ContentType=file.content_type,
|
| )
|
|
|
| return key
|
|
|
| except NoCredentialsError as e:
|
| SingletonLogger().get_logger().error(f"Storage credentials error: {e}")
|
| raise HTTPException(status_code=500, detail="Storage credentials error")
|
| except ClientError as e:
|
| SingletonLogger().get_logger().error(f"Storage upload error: {e}")
|
| raise HTTPException(
|
| status_code=500, detail=f"Storage upload error: {str(e)}"
|
| )
|
| except Exception as e:
|
| SingletonLogger().get_logger().error(f"Upload failed: {e}")
|
| raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
|
|
| async def upload_pdf(
|
| self, file: UploadFile, user_id: int, folder: str = "pdfs"
|
| ) -> str:
|
| """Upload PDF file to Supabase Storage and return the file key"""
|
|
|
| if file.content_type != "application/pdf":
|
| raise HTTPException(status_code=400, detail="Only PDF files are allowed")
|
|
|
|
|
| original_name = file.filename if file.filename else "document.pdf"
|
| file_extension = "pdf"
|
|
|
| base_name = original_name.replace(".pdf", "").replace(" ", "_")[:50]
|
| unique_filename = f"{base_name}_{uuid.uuid4().hex[:8]}.{file_extension}"
|
|
|
|
|
| key = SupabaseStorage.build_key(user_id, folder, unique_filename)
|
|
|
| try:
|
|
|
| file_content = await file.read()
|
|
|
|
|
| await asyncio.to_thread(
|
| self.s3_client.put_object,
|
| Bucket=self.bucket_name,
|
| Key=key,
|
| Body=file_content,
|
| ContentType="application/pdf",
|
| )
|
|
|
| SingletonLogger().get_logger().info(
|
| f"Uploaded PDF to storage: key={key} size={len(file_content)} bytes"
|
| )
|
|
|
| return key
|
|
|
| except NoCredentialsError as e:
|
| SingletonLogger().get_logger().error(f"Storage credentials error: {e}")
|
| raise HTTPException(status_code=500, detail="Storage credentials error")
|
| except ClientError as e:
|
| SingletonLogger().get_logger().error(f"Storage upload error: {e}")
|
| raise HTTPException(
|
| status_code=500, detail=f"Storage upload error: {str(e)}"
|
| )
|
| except Exception as e:
|
| SingletonLogger().get_logger().error(f"PDF upload failed: {e}")
|
| raise HTTPException(status_code=500, detail=f"PDF upload failed: {str(e)}")
|
|
|
| def get_file_url(self, file_key: str) -> Optional[str]:
|
| """Generate public URL for the file"""
|
| if not file_key:
|
| return None
|
|
|
|
|
|
|
| project_ref = self.endpoint_url.split(".")[0].replace("https://", "")
|
|
|
|
|
| return f"https://{project_ref}.supabase.co/storage/v1/object/public/{self.bucket_name}/{file_key}"
|
|
|
| async def delete_file(self, file_key: str) -> bool:
|
| """Delete file from Supabase Storage"""
|
| try:
|
| await asyncio.to_thread(
|
| self.s3_client.delete_object, Bucket=self.bucket_name, Key=file_key
|
| )
|
| return True
|
| except ClientError as e:
|
| SingletonLogger().get_logger().error(
|
| f"Client error deleting file {file_key}: {e}"
|
| )
|
| return False
|
| except Exception as e:
|
| SingletonLogger().get_logger().error(f"Error deleting file {file_key}: {e}")
|
| return False
|
|
|
| async def upload_bytes(
|
| self,
|
| content: bytes,
|
| user_id: int,
|
| folder: str = "thumbnails",
|
| filename: Optional[str] = None,
|
| content_type: str = "image/png",
|
| ) -> str:
|
| """Upload raw bytes to Supabase Storage and return the file key.
|
|
|
| This is useful for programmatically generated assets (e.g., PDF thumbnails).
|
| """
|
| try:
|
| name = filename or f"{uuid.uuid4()}.png"
|
| key = SupabaseStorage.build_key(user_id, folder, name)
|
|
|
| await asyncio.to_thread(
|
| self.s3_client.put_object,
|
| Bucket=self.bucket_name,
|
| Key=key,
|
| Body=content,
|
| ContentType=content_type,
|
| ACL="public-read",
|
| )
|
| SingletonLogger().get_logger().info(
|
| f"Uploaded thumbnail to storage: bucket={self.bucket_name} key={key} bytes={len(content)}"
|
| )
|
| return key
|
| except NoCredentialsError as e:
|
| SingletonLogger().get_logger().error(f"Storage credentials error: {e}")
|
| raise HTTPException(status_code=500, detail="Storage credentials error")
|
| except ClientError as e:
|
| SingletonLogger().get_logger().error(f"Storage upload error: {e}")
|
| raise HTTPException(
|
| status_code=500, detail=f"Storage upload error: {str(e)}"
|
| )
|
| except Exception as e:
|
| SingletonLogger().get_logger().error(f"Upload failed: {e}")
|
| raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
|
|
| @classmethod
|
| def from_env(cls) -> "SupabaseStorage":
|
| """Factory constructor using environment variables."""
|
| return cls()
|
|
|
| @staticmethod
|
| def is_allowed_content_type(content_type: str) -> bool:
|
| return content_type in SupabaseStorage.ALLOWED_CONTENT_TYPES
|
|
|
| @staticmethod
|
| def build_key(user_id: int, folder: str, filename: str) -> str:
|
| return f"{user_id}/{folder}/{filename}"
|
|
|
| async def download_file(self, file_key: str) -> Optional[bytes]:
|
| """Download file bytes from Supabase Storage."""
|
| try:
|
| response = await asyncio.to_thread(
|
| self.s3_client.get_object, Bucket=self.bucket_name, Key=file_key
|
| )
|
| body = response.get("Body")
|
| if body is None:
|
| return None
|
| content = await asyncio.to_thread(body.read)
|
| return content
|
| except ClientError as e:
|
| SingletonLogger().get_logger().error(
|
| f"Client error downloading file {file_key}: {e}"
|
| )
|
| return None
|
| except Exception as e:
|
| SingletonLogger().get_logger().error(
|
| f"Error downloading file {file_key}: {e}"
|
| )
|
| return None
|
|
|
| async def file_exists(self, file_key: str) -> bool:
|
| """Check if a file exists in Supabase Storage bucket via HEAD."""
|
| try:
|
| await asyncio.to_thread(
|
| self.s3_client.head_object, Bucket=self.bucket_name, Key=file_key
|
| )
|
| return True
|
| except ClientError as e:
|
|
|
| code = e.response.get("Error", {}).get("Code")
|
| if code in ("404", "NotFound", "NoSuchKey"):
|
| return False
|
| SingletonLogger().get_logger().error(
|
| f"Error checking existence for {file_key}: {e}"
|
| )
|
| return False
|
| except Exception as e:
|
| SingletonLogger().get_logger().error(
|
| f"Unexpected error checking existence for {file_key}: {e}"
|
| )
|
| return False
|
|
|
|
|
|
|
| storage = SupabaseStorage.from_env()
|
|
|