c74e118b98
Migration 040: media_assets table with RLS (tenant_isolation + admin_bypass). domains/media/: MediaAsset model, schemas, service, router with ZIP-download. publish_asset Celery task in rendering/tasks.py. core/storage.py: download_bytes() method for MinIO + LocalStorage. frontend: MediaBrowser.tsx (grid/list, multi-select, zip-download, pagination) + api/media.ts. Route /media (AdminRoute) + sidebar link with Image icon for admin+pm. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
6.6 KiB
Python
184 lines
6.6 KiB
Python
"""Storage abstraction — MinIO (production) or LocalStorage (dev fallback).
|
|
|
|
Usage:
|
|
from app.core.storage import get_storage
|
|
storage = get_storage()
|
|
key = storage.upload(local_path, "uploads/step_files/my.step")
|
|
local_path = storage.download("uploads/step_files/my.step", tmp_dir / "my.step")
|
|
|
|
Environment variables (set in docker-compose.yml):
|
|
MINIO_URL — S3 endpoint URL (e.g. http://minio:9000)
|
|
MINIO_USER — Access key (default: minioadmin)
|
|
MINIO_PASSWORD — Secret key (default: minioadmin)
|
|
MINIO_BUCKET — Bucket name (default: uploads)
|
|
|
|
If MINIO_URL is not set, falls back to LocalStorage (reads/writes to UPLOAD_DIR).
|
|
"""
|
|
import io
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MinIOStorage:
|
|
"""S3-compatible object storage via boto3 (MinIO backend)."""
|
|
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
user: str,
|
|
password: str,
|
|
bucket: str = "uploads",
|
|
):
|
|
import boto3
|
|
from botocore.config import Config
|
|
|
|
self._bucket = bucket
|
|
self._client = boto3.client(
|
|
"s3",
|
|
endpoint_url=url,
|
|
aws_access_key_id=user,
|
|
aws_secret_access_key=password,
|
|
config=Config(signature_version="s3v4"),
|
|
)
|
|
self._ensure_bucket()
|
|
|
|
def _ensure_bucket(self):
|
|
"""Create the bucket if it does not exist."""
|
|
try:
|
|
self._client.head_bucket(Bucket=self._bucket)
|
|
except Exception:
|
|
try:
|
|
self._client.create_bucket(Bucket=self._bucket)
|
|
logger.info("Created MinIO bucket: %s", self._bucket)
|
|
except Exception as exc:
|
|
logger.warning("Could not create MinIO bucket %s: %s", self._bucket, exc)
|
|
|
|
def upload(self, local_path: Path, object_key: str) -> str:
|
|
"""Upload a local file to MinIO. Returns the object_key."""
|
|
self._client.upload_file(str(local_path), self._bucket, object_key)
|
|
logger.debug("Uploaded %s → minio://%s/%s", local_path.name, self._bucket, object_key)
|
|
return object_key
|
|
|
|
def download(self, object_key: str, local_path: Path) -> Path:
|
|
"""Download object from MinIO to local_path. Returns local_path."""
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._client.download_file(self._bucket, object_key, str(local_path))
|
|
logger.debug("Downloaded minio://%s/%s → %s", self._bucket, object_key, local_path.name)
|
|
return local_path
|
|
|
|
def exists(self, object_key: str) -> bool:
|
|
"""Return True if the object exists in MinIO."""
|
|
try:
|
|
self._client.head_object(Bucket=self._bucket, Key=object_key)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def delete(self, object_key: str) -> None:
|
|
"""Delete an object from MinIO (best-effort)."""
|
|
try:
|
|
self._client.delete_object(Bucket=self._bucket, Key=object_key)
|
|
except Exception as exc:
|
|
logger.warning("MinIO delete failed for %s: %s", object_key, exc)
|
|
|
|
def get_url(self, object_key: str, expires_in: int = 3600) -> str:
|
|
"""Generate a presigned URL for direct download (valid for expires_in seconds)."""
|
|
return self._client.generate_presigned_url(
|
|
"get_object",
|
|
Params={"Bucket": self._bucket, "Key": object_key},
|
|
ExpiresIn=expires_in,
|
|
)
|
|
|
|
def download_bytes(self, object_key: str) -> bytes:
|
|
"""Download file from MinIO and return as bytes."""
|
|
buf = io.BytesIO()
|
|
self._client.download_fileobj(self._bucket, object_key, buf)
|
|
return buf.getvalue()
|
|
|
|
@property
|
|
def backend(self) -> str:
|
|
return "minio"
|
|
|
|
|
|
class LocalStorage:
|
|
"""Fallback: reads/writes to the local UPLOAD_DIR filesystem.
|
|
|
|
Object keys are treated as relative paths under UPLOAD_DIR.
|
|
This preserves backward compatibility with the existing uploads/ volume.
|
|
"""
|
|
|
|
def __init__(self, upload_dir: str):
|
|
self._root = Path(upload_dir)
|
|
|
|
def _resolve(self, object_key: str) -> Path:
|
|
return self._root / object_key
|
|
|
|
def upload(self, local_path: Path, object_key: str) -> str:
|
|
"""Copy local_path to the local storage path for object_key."""
|
|
import shutil
|
|
dest = self._resolve(object_key)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if str(local_path) != str(dest):
|
|
shutil.copy2(str(local_path), str(dest))
|
|
return object_key
|
|
|
|
def download(self, object_key: str, local_path: Path) -> Path:
|
|
"""'Download' by returning the existing local path."""
|
|
src = self._resolve(object_key)
|
|
if src == local_path:
|
|
return local_path
|
|
import shutil
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(str(src), str(local_path))
|
|
return local_path
|
|
|
|
def exists(self, object_key: str) -> bool:
|
|
return self._resolve(object_key).exists()
|
|
|
|
def delete(self, object_key: str) -> None:
|
|
path = self._resolve(object_key)
|
|
path.unlink(missing_ok=True)
|
|
|
|
def get_url(self, object_key: str, expires_in: int = 3600) -> str:
|
|
return f"/api/files/{object_key}"
|
|
|
|
def download_bytes(self, object_key: str) -> bytes:
|
|
"""Read file from local storage and return as bytes."""
|
|
path = self._resolve(object_key)
|
|
return path.read_bytes()
|
|
|
|
@property
|
|
def backend(self) -> str:
|
|
return "local"
|
|
|
|
|
|
_storage_instance: MinIOStorage | LocalStorage | None = None
|
|
|
|
|
|
def get_storage() -> MinIOStorage | LocalStorage:
|
|
"""Return the configured storage backend (singleton)."""
|
|
global _storage_instance
|
|
if _storage_instance is not None:
|
|
return _storage_instance
|
|
|
|
minio_url = os.environ.get("MINIO_URL", "")
|
|
if minio_url:
|
|
user = os.environ.get("MINIO_USER", "minioadmin")
|
|
password = os.environ.get("MINIO_PASSWORD", "minioadmin")
|
|
bucket = os.environ.get("MINIO_BUCKET", "uploads")
|
|
try:
|
|
_storage_instance = MinIOStorage(url=minio_url, user=user, password=password, bucket=bucket)
|
|
logger.info("Storage backend: MinIO (%s, bucket=%s)", minio_url, bucket)
|
|
except Exception as exc:
|
|
logger.warning("MinIO init failed (%s) — falling back to LocalStorage: %s", minio_url, exc)
|
|
_storage_instance = LocalStorage(os.environ.get("UPLOAD_DIR", "/app/uploads"))
|
|
else:
|
|
upload_dir = os.environ.get("UPLOAD_DIR", "/app/uploads")
|
|
_storage_instance = LocalStorage(upload_dir)
|
|
logger.info("Storage backend: local filesystem (%s)", upload_dir)
|
|
|
|
return _storage_instance
|