CytoSight / backend /app /api /upload.py
Kaifulimaan's picture
Implement Dedicated Storage Client to resolve RLS bypass and history issues
2c988f5
"""
Image upload API endpoints using Supabase Storage.
Handles image uploads to Supabase buckets.
"""
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.database.supabase_client import get_supabase_client, get_storage_client, IMAGES_BUCKET
from app.database.database import get_db
from app.config import settings
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
import uuid
from datetime import datetime
import logging
import asyncio
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/upload", tags=["Upload"])
security = HTTPBearer()
class ImageUploadResponse(BaseModel):
"""Response after successful image upload."""
file_id: str
file_name: str
file_path: str
public_url: str
uploaded_at: str
@router.post("/image", response_model=ImageUploadResponse)
async def upload_image(
file: UploadFile = File(...),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(get_db)
):
"""
Upload an image to Supabase Storage.
Process:
1. Verify user (if authenticated)
2. Generate unique filename
3. Upload to Supabase Storage bucket
4. Return public signed URL
Args:
file: Image file (JPG, PNG, TIFF, SVS)
credentials: Optional Bearer token
db: Database session
Returns:
ImageUploadResponse with file URL and metadata
Raises:
HTTPException 400: If file type invalid
HTTPException 500: If upload fails
"""
supabase = get_supabase_client()
storage = get_storage_client()
# Extract user ID from token (optional)
user_id = None
if credentials:
try:
token = credentials.credentials
logger.info(f"[UPLOAD] Verifying token")
user_response = supabase.auth.get_user(token)
if user_response and user_response.user:
user_id = user_response.user.id
logger.info(f"[UPLOAD] Authenticated user: {user_id}")
else:
logger.warning(f"[UPLOAD] Token invalid, proceeding anonymously")
except Exception as auth_err:
logger.warning(f"[UPLOAD] Auth error: {auth_err}, proceeding anonymously")
# For unauthenticated uploads, use a session ID
if not user_id:
user_id = f"anonymous_{uuid.uuid4().hex[:16]}"
logger.info(f"[UPLOAD] Anonymous session: {user_id}")
# Validate file type
allowed_extensions = [".jpg", ".jpeg", ".png", ".tiff", ".tif", ".svs"]
file_extension = file.filename.lower().split(".")[-1] if "." in file.filename else ""
if f".{file_extension}" not in allowed_extensions:
logger.error(f"[UPLOAD] Invalid file type: .{file_extension}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
)
try:
# Generate unique filename
file_id = str(uuid.uuid4())
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
unique_filename = f"{user_id}/{timestamp}_{file_id}.{file_extension}"
logger.info(f"[UPLOAD] Starting upload")
logger.info(f" Original filename: {file.filename}")
logger.info(f" Storage path: {unique_filename}")
logger.info(f" Content type: {file.content_type}")
# Read file content
file_content = await file.read()
logger.info(f" File size: {len(file_content)} bytes")
# Upload to Supabase Storage
logger.info(f"[UPLOAD] Uploading to Supabase...")
upload_response = storage.storage.from_(IMAGES_BUCKET).upload(
path=unique_filename,
file=file_content,
file_options={"content-type": file.content_type}
)
logger.info(f"[UPLOAD] Upload response: {upload_response}")
# Generate signed URL valid for 24 hours
logger.info(f"[UPLOAD] Creating signed URL...")
signed_url_response = None
last_sign_error = None
for attempt in range(3):
try:
signed_url_response = storage.storage.from_(IMAGES_BUCKET).create_signed_url(
path=unique_filename,
expires_in=86400 # 24 hours
)
break
except Exception as sign_err:
last_sign_error = sign_err
logger.warning(f"[UPLOAD] Signed URL attempt {attempt + 1}/3 failed: {sign_err}")
# Storage propagation can be eventually consistent right after upload.
await asyncio.sleep(0.6)
# Extract URL from response
signed_url = None
if isinstance(signed_url_response, dict):
signed_url = signed_url_response.get("signedURL") or signed_url_response.get("signedUrl")
elif isinstance(signed_url_response, str):
signed_url = signed_url_response
if not signed_url:
# Fallback: public object URL. Works if bucket/object policy allows read access.
signed_url = f"{settings.supabase_url.rstrip('/')}/storage/v1/object/public/{IMAGES_BUCKET}/{unique_filename}"
logger.warning(
f"[UPLOAD] Falling back to public URL after signed URL failure: {last_sign_error}"
)
# Normalize to absolute URL if needed
if signed_url.startswith("/"):
signed_url = f"{settings.supabase_url.rstrip('/')}{signed_url}"
logger.info(f"[UPLOAD] Upload complete!")
logger.info(f" File ID: {file_id}")
logger.info(f" Signed URL: {signed_url[:80]}...")
return ImageUploadResponse(
file_id=file_id,
file_name=file.filename,
file_path=unique_filename,
public_url=signed_url,
uploaded_at=datetime.utcnow().isoformat()
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[UPLOAD] Upload failed: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Upload failed: {str(e)}"
)
@router.delete("/image/{file_path:path}")
async def delete_image(
file_path: str,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
):
"""
Delete an image from Supabase Storage.
Args:
file_path: Path to file in storage
credentials: Bearer token
db: Database session
Returns:
Success message
Raises:
HTTPException 401: If not authenticated
HTTPException 404: If file not found
"""
supabase = get_supabase_client()
try:
token = credentials.credentials
logger.info(f"[DELETE] Attempting to delete: {file_path}")
# Verify token
user_response = supabase.auth.get_user(token)
if not user_response or not user_response.user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
# Delete file
storage = get_storage_client()
storage.storage.from_(IMAGES_BUCKET).remove([file_path])
logger.info(f"[DELETE] File deleted: {file_path}")
return {"message": "File deleted successfully", "file_path": file_path}
except HTTPException:
raise
except Exception as e:
logger.error(f"[DELETE] Error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Delete failed: {str(e)}"
)