225 lines
7.5 KiB
Python
225 lines
7.5 KiB
Python
|
|
"""Abstract storage backend — supports local filesystem and S3-compatible storage.
|
||
|
|
|
||
|
|
Configuration via environment variables:
|
||
|
|
- STORAGE_BACKEND: "local" (default) or "s3"
|
||
|
|
- STORAGE_PATH: Local storage base path (default: /data/uploads)
|
||
|
|
- S3_ENDPOINT: S3-compatible endpoint URL
|
||
|
|
- S3_BUCKET: Bucket name
|
||
|
|
- S3_ACCESS_KEY: Access key
|
||
|
|
- S3_SECRET_KEY: Secret key
|
||
|
|
- S3_REGION: Region (default: us-east-1)
|
||
|
|
- S3_SECURE: Use HTTPS (default: true)
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import io
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
from abc import ABC, abstractmethod
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class StorageBackend(ABC):
|
||
|
|
"""Abstract storage backend for file operations."""
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
async def save(self, path: str, data: bytes) -> str:
|
||
|
|
"""Save data to storage at the given path. Returns the full storage path."""
|
||
|
|
...
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
async def read(self, path: str) -> bytes:
|
||
|
|
"""Read data from storage at the given path."""
|
||
|
|
...
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
async def delete(self, path: str) -> bool:
|
||
|
|
"""Delete a file from storage. Returns True if deleted, False if not found."""
|
||
|
|
...
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
async def exists(self, path: str) -> bool:
|
||
|
|
"""Check if a file exists in storage."""
|
||
|
|
...
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||
|
|
"""Get a URL for accessing the file (presigned URL for S3, file path for local)."""
|
||
|
|
...
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
async def list_files(self, prefix: str) -> list[str]:
|
||
|
|
"""List all file paths under the given prefix."""
|
||
|
|
...
|
||
|
|
|
||
|
|
|
||
|
|
class LocalStorage(StorageBackend):
|
||
|
|
"""Local filesystem storage backend."""
|
||
|
|
|
||
|
|
def __init__(self, base_path: str | None = None) -> None:
|
||
|
|
self.base_path = base_path or os.environ.get("STORAGE_PATH", "/data/uploads")
|
||
|
|
os.makedirs(self.base_path, exist_ok=True)
|
||
|
|
|
||
|
|
def _full_path(self, path: str) -> str:
|
||
|
|
"""Get the full filesystem path."""
|
||
|
|
return os.path.join(self.base_path, path)
|
||
|
|
|
||
|
|
async def save(self, path: str, data: bytes) -> str:
|
||
|
|
full_path = self._full_path(path)
|
||
|
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||
|
|
with open(full_path, "wb") as f:
|
||
|
|
f.write(data)
|
||
|
|
logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data))
|
||
|
|
return path
|
||
|
|
|
||
|
|
async def read(self, path: str) -> bytes:
|
||
|
|
full_path = self._full_path(path)
|
||
|
|
with open(full_path, "rb") as f:
|
||
|
|
return f.read()
|
||
|
|
|
||
|
|
async def delete(self, path: str) -> bool:
|
||
|
|
full_path = self._full_path(path)
|
||
|
|
if os.path.exists(full_path):
|
||
|
|
os.remove(full_path)
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def exists(self, path: str) -> bool:
|
||
|
|
return os.path.exists(self._full_path(path))
|
||
|
|
|
||
|
|
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||
|
|
# Local storage returns the file path for direct access
|
||
|
|
return self._full_path(path)
|
||
|
|
|
||
|
|
async def list_files(self, prefix: str) -> list[str]:
|
||
|
|
full_prefix = self._full_path(prefix)
|
||
|
|
if not os.path.isdir(full_prefix):
|
||
|
|
return []
|
||
|
|
result: list[str] = []
|
||
|
|
for root, _dirs, files in os.walk(full_prefix):
|
||
|
|
for fname in files:
|
||
|
|
rel = os.path.relpath(os.path.join(root, fname), self.base_path)
|
||
|
|
result.append(rel)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
class S3Storage(StorageBackend):
|
||
|
|
"""S3-compatible storage backend (works with AWS S3, MinIO, etc.)."""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
endpoint: str | None = None,
|
||
|
|
bucket: str | None = None,
|
||
|
|
access_key: str | None = None,
|
||
|
|
secret_key: str | None = None,
|
||
|
|
region: str | None = None,
|
||
|
|
secure: bool | None = None,
|
||
|
|
) -> None:
|
||
|
|
self.endpoint = endpoint or os.environ.get("S3_ENDPOINT", "")
|
||
|
|
self.bucket = bucket or os.environ.get("S3_BUCKET", "")
|
||
|
|
self.access_key = access_key or os.environ.get("S3_ACCESS_KEY", "")
|
||
|
|
self.secret_key = secret_key or os.environ.get("S3_SECRET_KEY", "")
|
||
|
|
self.region = region or os.environ.get("S3_REGION", "us-east-1")
|
||
|
|
self.secure = secure if secure is not None else os.environ.get("S3_SECURE", "true").lower() == "true"
|
||
|
|
self._client: Any = None # lazy init
|
||
|
|
|
||
|
|
def _get_client(self) -> Any:
|
||
|
|
"""Lazy-initialize the S3 client (minio or boto3)."""
|
||
|
|
if self._client is not None:
|
||
|
|
return self._client
|
||
|
|
|
||
|
|
try:
|
||
|
|
from minio import Minio # type: ignore
|
||
|
|
|
||
|
|
self._client = Minio(
|
||
|
|
endpoint=self.endpoint.replace("https://", "").replace("http://", ""),
|
||
|
|
access_key=self.access_key,
|
||
|
|
secret_key=self.secret_key,
|
||
|
|
secure=self.secure,
|
||
|
|
region=self.region,
|
||
|
|
)
|
||
|
|
# Ensure bucket exists
|
||
|
|
if not self._client.bucket_exists(self.bucket):
|
||
|
|
self._client.make_bucket(self.bucket)
|
||
|
|
logger.info("S3Storage: connected to %s, bucket=%s", self.endpoint, self.bucket)
|
||
|
|
return self._client
|
||
|
|
except ImportError:
|
||
|
|
logger.error("S3Storage: minio package not installed. Install with: pip install minio")
|
||
|
|
raise
|
||
|
|
except Exception as e:
|
||
|
|
logger.error("S3Storage: failed to connect to %s: %s", self.endpoint, e)
|
||
|
|
raise
|
||
|
|
|
||
|
|
async def save(self, path: str, data: bytes) -> str:
|
||
|
|
from io import BytesIO
|
||
|
|
|
||
|
|
client = self._get_client()
|
||
|
|
client.put_object(
|
||
|
|
bucket_name=self.bucket,
|
||
|
|
object_name=path,
|
||
|
|
data=BytesIO(data),
|
||
|
|
length=len(data),
|
||
|
|
)
|
||
|
|
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
|
||
|
|
return path
|
||
|
|
|
||
|
|
async def read(self, path: str) -> bytes:
|
||
|
|
client = self._get_client()
|
||
|
|
response = client.get_object(self.bucket, path)
|
||
|
|
return response.read()
|
||
|
|
|
||
|
|
async def delete(self, path: str) -> bool:
|
||
|
|
client = self._get_client()
|
||
|
|
try:
|
||
|
|
client.remove_object(self.bucket, path)
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def exists(self, path: str) -> bool:
|
||
|
|
client = self._get_client()
|
||
|
|
try:
|
||
|
|
client.stat_object(self.bucket, path)
|
||
|
|
return True
|
||
|
|
except Exception:
|
||
|
|
return False
|
||
|
|
|
||
|
|
async def get_url(self, path: str, expires: int = 3600) -> str:
|
||
|
|
from datetime import timedelta
|
||
|
|
|
||
|
|
client = self._get_client()
|
||
|
|
return client.presigned_get_object(self.bucket, path, expires=timedelta(seconds=expires))
|
||
|
|
|
||
|
|
async def list_files(self, prefix: str) -> list[str]:
|
||
|
|
client = self._get_client()
|
||
|
|
objects = client.list_objects(self.bucket, prefix=prefix, recursive=True)
|
||
|
|
return [obj.object_name for obj in objects]
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Factory ───
|
||
|
|
|
||
|
|
_storage_backend: StorageBackend | None = None
|
||
|
|
|
||
|
|
|
||
|
|
def get_storage_backend() -> StorageBackend:
|
||
|
|
"""Get the configured storage backend singleton."""
|
||
|
|
global _storage_backend
|
||
|
|
if _storage_backend is None:
|
||
|
|
backend_type = os.environ.get("STORAGE_BACKEND", "local").lower()
|
||
|
|
if backend_type == "s3":
|
||
|
|
_storage_backend = S3Storage()
|
||
|
|
logger.info("Storage backend: S3 (%s)", os.environ.get("S3_ENDPOINT", ""))
|
||
|
|
else:
|
||
|
|
_storage_backend = LocalStorage()
|
||
|
|
logger.info("Storage backend: Local (%s)", os.environ.get("STORAGE_PATH", "/data/uploads"))
|
||
|
|
return _storage_backend
|
||
|
|
|
||
|
|
|
||
|
|
def reset_storage_backend() -> None:
|
||
|
|
"""Reset the storage backend singleton (for testing)."""
|
||
|
|
global _storage_backend
|
||
|
|
_storage_backend = None
|