176 lines
5.1 KiB
Python
176 lines
5.1 KiB
Python
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from io import BytesIO
|
|
from urllib.parse import quote
|
|
|
|
import urllib3
|
|
from fastapi import HTTPException, status
|
|
from minio import Minio
|
|
from minio.error import S3Error
|
|
|
|
from app.core.config import get_settings
|
|
|
|
_EXT = re.compile(r"^\.[a-z0-9]{1,8}$")
|
|
_MISSING = {"NoSuchKey", "NoSuchBucket", "NoSuchObject", "NotFound"}
|
|
_EXISTS = {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}
|
|
_NO_POLICY = {"NoSuchBucketPolicy", "NoSuchBucket"}
|
|
|
|
|
|
def _unavailable() -> None:
|
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="对象存储不可用")
|
|
|
|
|
|
def _unconfigured() -> None:
|
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="对象存储未配置")
|
|
|
|
|
|
def _endpoint() -> tuple[str, bool]:
|
|
settings = get_settings()
|
|
endpoint = settings.minio_endpoint.strip()
|
|
secure = bool(settings.minio_secure)
|
|
if endpoint.startswith("https://"):
|
|
endpoint = endpoint[len("https://") :]
|
|
secure = True
|
|
elif endpoint.startswith("http://"):
|
|
endpoint = endpoint[len("http://") :]
|
|
secure = False
|
|
return endpoint.rstrip("/"), secure
|
|
|
|
|
|
def _client() -> Minio:
|
|
settings = get_settings()
|
|
endpoint, secure = _endpoint()
|
|
if not endpoint or not settings.minio_access_key or not settings.minio_secret_key:
|
|
_unconfigured()
|
|
http_client = urllib3.PoolManager(
|
|
timeout=urllib3.Timeout(connect=3.0, read=60.0),
|
|
retries=False,
|
|
)
|
|
return Minio(
|
|
endpoint,
|
|
access_key=settings.minio_access_key,
|
|
secret_key=settings.minio_secret_key,
|
|
secure=secure,
|
|
http_client=http_client,
|
|
)
|
|
|
|
|
|
def bucket_name(kind: str) -> str:
|
|
settings = get_settings()
|
|
if kind == "public":
|
|
name = settings.minio_bucket_public.strip()
|
|
elif kind == "private":
|
|
name = settings.minio_bucket_private.strip()
|
|
else:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="bucket 只能是 public 或 private")
|
|
if not name:
|
|
_unconfigured()
|
|
return name
|
|
|
|
|
|
def _public_policy(bucket: str) -> str:
|
|
return json.dumps(
|
|
{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Sid": "PublicRead",
|
|
"Effect": "Allow",
|
|
"Principal": {"AWS": ["*"]},
|
|
"Action": ["s3:GetObject"],
|
|
"Resource": [f"arn:aws:s3:::{bucket}/*"],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
def _ensure_bucket(client: Minio, name: str) -> None:
|
|
if client.bucket_exists(name):
|
|
return
|
|
try:
|
|
client.make_bucket(name)
|
|
except S3Error as exc:
|
|
if exc.code not in _EXISTS:
|
|
raise
|
|
|
|
|
|
def ensure_buckets() -> None:
|
|
client = _client()
|
|
public = bucket_name("public")
|
|
private = bucket_name("private")
|
|
_ensure_bucket(client, public)
|
|
_ensure_bucket(client, private)
|
|
client.set_bucket_policy(public, _public_policy(public))
|
|
try:
|
|
client.delete_bucket_policy(private)
|
|
except S3Error as exc:
|
|
if exc.code not in _NO_POLICY:
|
|
raise
|
|
|
|
|
|
def safe_filename(filename: str | None) -> str:
|
|
name = (filename or "").replace("\\", "/").split("/")[-1].replace("\x00", "").strip()
|
|
if not name or name in {".", ".."}:
|
|
return "file"
|
|
return name[:255]
|
|
|
|
|
|
def make_object_key(filename: str) -> str:
|
|
suffix = os.path.splitext(safe_filename(filename))[1].lower()
|
|
ext = suffix if _EXT.fullmatch(suffix) else ""
|
|
day = datetime.now(timezone.utc).strftime("%Y/%m/%d")
|
|
return f"{day}/{uuid.uuid4().hex}{ext}"
|
|
|
|
|
|
def public_object_url(object_key: str) -> str:
|
|
base = get_settings().minio_base_url.strip().rstrip("/")
|
|
if not base:
|
|
_unconfigured()
|
|
parts = [quote(part, safe="") for part in object_key.split("/") if part]
|
|
return f"{base}/{'/'.join(parts)}"
|
|
|
|
|
|
def private_object_url(object_key: str, expires_seconds: int = 60) -> tuple[str, datetime]:
|
|
if expires_seconds < 1 or expires_seconds > 604800:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="链接有效期无效")
|
|
expires = timedelta(seconds=expires_seconds)
|
|
try:
|
|
url = _client().presigned_get_object(bucket_name("private"), object_key, expires=expires)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
_unavailable()
|
|
return url, datetime.now(timezone.utc) + expires
|
|
|
|
|
|
def upload_object(kind: str, object_key: str, data: bytes, content_type: str) -> None:
|
|
try:
|
|
_client().put_object(
|
|
bucket_name(kind),
|
|
object_key,
|
|
BytesIO(data),
|
|
length=len(data),
|
|
content_type=content_type or "application/octet-stream",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
_unavailable()
|
|
|
|
|
|
def remove_stored_object(kind: str, object_key: str) -> None:
|
|
try:
|
|
_client().remove_object(bucket_name(kind), object_key)
|
|
except S3Error as exc:
|
|
if exc.code in _MISSING:
|
|
return
|
|
_unavailable()
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
_unavailable()
|