Files

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()