arxplorer / src /core /storage /supabase.py
Subhadeep Mandal
Fresh deploy
54eb2ce
Raw
History Blame Contribute Delete
14.4 kB
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,
):
# Load from env if not provided
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."
)
# Extract project reference for REST API calls
self.project_ref = self.endpoint_url.split(".")[0].replace("https://", "")
# Allow dependency injection of a preconfigured client
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
# Ensure bucket exists and is public
self._ensure_bucket_exists_and_public()
def _ensure_bucket_exists_and_public(self):
"""Check if bucket exists and make it public if needed"""
try:
# Check if bucket exists
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}")
# Try to make the bucket public using Supabase REST API
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"""
# Validate file type
if not SupabaseStorage.is_allowed_content_type(file.content_type):
raise HTTPException(
status_code=400, detail="Only JPEG and PNG files are allowed"
)
# Generate unique filename
file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
unique_filename = f"{uuid.uuid4()}.{file_extension}"
# Create the key path
key = SupabaseStorage.build_key(user_id, folder, unique_filename)
try:
# Read file content
file_content = await file.read()
# Upload to Supabase Storage (wrap blocking boto3 call)
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"""
# Validate file type
if file.content_type != "application/pdf":
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
# Generate unique filename preserving original name
original_name = file.filename if file.filename else "document.pdf"
file_extension = "pdf"
# Sanitize filename and add UUID to avoid conflicts
base_name = original_name.replace(".pdf", "").replace(" ", "_")[:50]
unique_filename = f"{base_name}_{uuid.uuid4().hex[:8]}.{file_extension}"
# Create the key path
key = SupabaseStorage.build_key(user_id, folder, unique_filename)
try:
# Read file content
file_content = await file.read()
# Upload to Supabase Storage (wrap blocking boto3 call)
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
# Extract project ref from endpoint URL
# S3_STORAGE_URL format: https://<project_ref>.storage.supabase.co/storage/v1/s3
project_ref = self.endpoint_url.split(".")[0].replace("https://", "")
# Supabase Storage public URL format: https://<project_ref>.supabase.co/storage/v1/object/public/<bucket>/<file_key>
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)
# Upload via S3-compatible API
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:
# Not found codes vary by provider; treat 404/NoSuchKey as missing
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
# Global storage instance
storage = SupabaseStorage.from_env()