# app/services/storage_service.py """ Storage abstraction service for file uploads. Provides a unified interface for file storage with support for: - Local filesystem (default, development) - Cloudflare R2 (production, S3-compatible) Usage: from app.services.storage_service import get_storage_backend storage = get_storage_backend() url = await storage.upload("path/to/file.jpg", file_bytes, "image/jpeg") await storage.delete("path/to/file.jpg") """ import logging from abc import ABC, abstractmethod from pathlib import Path from app.core.config import settings logger = logging.getLogger(__name__) class StorageBackend(ABC): """Abstract base class for storage backends.""" @abstractmethod async def upload(self, file_path: str, content: bytes, content_type: str) -> str: """ Upload a file to storage. Args: file_path: Relative path where file should be stored content: File content as bytes content_type: MIME type of the file Returns: Public URL to access the file """ pass @abstractmethod async def delete(self, file_path: str) -> bool: """ Delete a file from storage. Args: file_path: Relative path of file to delete Returns: True if file was deleted, False if not found """ pass @abstractmethod def get_url(self, file_path: str) -> str: """ Get the public URL for a file. Args: file_path: Relative path of the file Returns: Public URL to access the file """ pass @abstractmethod async def exists(self, file_path: str) -> bool: """ Check if a file exists in storage. Args: file_path: Relative path of the file Returns: True if file exists """ pass class LocalStorageBackend(StorageBackend): """Local filesystem storage backend.""" def __init__(self, base_dir: str = "uploads"): """ Initialize local storage backend. Args: base_dir: Base directory for file storage (relative to project root) """ self.base_dir = Path(base_dir) self.base_dir.mkdir(parents=True, exist_ok=True) logger.info(f"LocalStorageBackend initialized with base_dir: {self.base_dir}") async def upload(self, file_path: str, content: bytes, content_type: str) -> str: """Upload file to local filesystem.""" full_path = self.base_dir / file_path # Ensure parent directory exists full_path.parent.mkdir(parents=True, exist_ok=True) # Write file full_path.write_bytes(content) logger.debug(f"Uploaded to local: {file_path} ({len(content)} bytes)") return self.get_url(file_path) async def delete(self, file_path: str) -> bool: """Delete file from local filesystem.""" full_path = self.base_dir / file_path if full_path.exists(): full_path.unlink() logger.debug(f"Deleted from local: {file_path}") # Clean up empty parent directories self._cleanup_empty_dirs(full_path.parent) return True return False def get_url(self, file_path: str) -> str: """Get URL for local file (served via /uploads mount).""" return f"/uploads/{file_path}" async def exists(self, file_path: str) -> bool: """Check if file exists locally.""" return (self.base_dir / file_path).exists() def _cleanup_empty_dirs(self, dir_path: Path) -> None: """Remove empty directories up to base_dir.""" try: while dir_path != self.base_dir and dir_path.exists(): if not any(dir_path.iterdir()): dir_path.rmdir() dir_path = dir_path.parent else: break except OSError: pass class R2StorageBackend(StorageBackend): """Cloudflare R2 storage backend (S3-compatible).""" def __init__(self): """Initialize R2 storage backend.""" import boto3 from botocore.config import Config if not all([ settings.r2_account_id, settings.r2_access_key_id, settings.r2_secret_access_key, ]): raise ValueError( "R2 storage requires R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, " "and R2_SECRET_ACCESS_KEY environment variables" ) # R2 endpoint URL endpoint_url = f"https://{settings.r2_account_id}.r2.cloudflarestorage.com" # Configure boto3 client for R2 self.client = boto3.client( "s3", endpoint_url=endpoint_url, aws_access_key_id=settings.r2_access_key_id, aws_secret_access_key=settings.r2_secret_access_key, config=Config( signature_version="s3v4", retries={"max_attempts": 3, "mode": "adaptive"}, ), ) self.bucket_name = settings.r2_bucket_name self.public_url = settings.r2_public_url logger.info( f"R2StorageBackend initialized: bucket={self.bucket_name}, " f"public_url={self.public_url or 'default'}" ) async def upload(self, file_path: str, content: bytes, content_type: str) -> str: """Upload file to R2.""" try: self.client.put_object( Bucket=self.bucket_name, Key=file_path, Body=content, ContentType=content_type, ) logger.debug(f"Uploaded to R2: {file_path} ({len(content)} bytes)") return self.get_url(file_path) except Exception as e: logger.error(f"R2 upload failed for {file_path}: {e}") raise async def delete(self, file_path: str) -> bool: """Delete file from R2.""" try: # Check if file exists first if not await self.exists(file_path): return False self.client.delete_object( Bucket=self.bucket_name, Key=file_path, ) logger.debug(f"Deleted from R2: {file_path}") return True except Exception as e: logger.error(f"R2 delete failed for {file_path}: {e}") return False def get_url(self, file_path: str) -> str: """Get public URL for R2 file.""" if self.public_url: # Use custom domain return f"{self.public_url.rstrip('/')}/{file_path}" else: # Use default R2 public URL pattern # Note: Bucket must have public access enabled return f"https://{self.bucket_name}.{settings.r2_account_id}.r2.dev/{file_path}" async def exists(self, file_path: str) -> bool: """Check if file exists in R2.""" try: self.client.head_object(Bucket=self.bucket_name, Key=file_path) return True except self.client.exceptions.ClientError as e: if e.response.get("Error", {}).get("Code") == "404": return False raise # ============================================================================= # STORAGE BACKEND FACTORY # ============================================================================= _storage_backend: StorageBackend | None = None def get_storage_backend() -> StorageBackend: """ Get the configured storage backend instance. Returns: Storage backend based on STORAGE_BACKEND setting Raises: ValueError: If storage backend is misconfigured """ global _storage_backend if _storage_backend is not None: return _storage_backend backend_type = settings.storage_backend.lower() if backend_type == "r2": _storage_backend = R2StorageBackend() elif backend_type == "local": _storage_backend = LocalStorageBackend() else: raise ValueError(f"Unknown storage backend: {backend_type}") return _storage_backend def reset_storage_backend() -> None: """Reset the storage backend (useful for testing).""" global _storage_backend _storage_backend = None # ============================================================================= # PUBLIC API # ============================================================================= __all__ = [ "StorageBackend", "LocalStorageBackend", "R2StorageBackend", "get_storage_backend", "reset_storage_backend", ]