Keep FastAPI as the only backend; add media upload, ntfy hook, and PB collection bootstrap for production. Co-authored-by: Cursor <cursoragent@cursor.com>
97 lines
2.7 KiB
Python
97 lines
2.7 KiB
Python
"""SeaweedFS S3-compatible uploads (server: s3.nomadro.com → 127.0.0.1:8333)."""
|
|
from __future__ import annotations
|
|
|
|
import mimetypes
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import HTTPException, UploadFile
|
|
|
|
from app.config import settings
|
|
|
|
|
|
@dataclass
|
|
class UploadResult:
|
|
url: str
|
|
object_key: str
|
|
size: int
|
|
content_type: str
|
|
|
|
|
|
def s3_enabled() -> bool:
|
|
return bool(settings.s3_enabled and settings.s3_endpoint and settings.s3_bucket)
|
|
|
|
|
|
def _client():
|
|
import boto3
|
|
from botocore.client import Config
|
|
|
|
return boto3.client(
|
|
"s3",
|
|
endpoint_url=settings.s3_endpoint.rstrip("/"),
|
|
aws_access_key_id=settings.s3_access_key,
|
|
aws_secret_access_key=settings.s3_secret_key,
|
|
region_name=settings.s3_region,
|
|
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
|
)
|
|
|
|
|
|
def ensure_bucket() -> None:
|
|
if not s3_enabled():
|
|
return
|
|
client = _client()
|
|
bucket = settings.s3_bucket
|
|
try:
|
|
client.head_bucket(Bucket=bucket)
|
|
except Exception:
|
|
try:
|
|
client.create_bucket(Bucket=bucket)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def public_url_for_key(key: str) -> str:
|
|
base = (settings.s3_public_url or settings.s3_endpoint).rstrip("/")
|
|
bucket = settings.s3_bucket.strip("/")
|
|
return f"{base}/{bucket}/{quote(key, safe='/')}"
|
|
|
|
|
|
def _object_key(filename: str, purpose: str) -> str:
|
|
suffix = Path(filename or "upload.bin").suffix.lower() or ".bin"
|
|
day = datetime.now(timezone.utc).strftime("%Y/%m/%d")
|
|
prefix = (settings.s3_upload_prefix or "nomadweb").strip("/")
|
|
purpose_part = (purpose or "uploads").replace("/", "-")[:40]
|
|
return f"{prefix}/{purpose_part}/{day}/{uuid.uuid4().hex}{suffix}"
|
|
|
|
|
|
async def upload_file(file: UploadFile, *, purpose: str, max_size: int) -> UploadResult:
|
|
if not s3_enabled():
|
|
raise HTTPException(status_code=503, detail="S3 存储未配置")
|
|
|
|
data = await file.read()
|
|
if len(data) > max_size:
|
|
raise HTTPException(status_code=400, detail=f"文件不能超过 {max_size // 1024 // 1024}MB")
|
|
if not data:
|
|
raise HTTPException(status_code=400, detail="空文件")
|
|
|
|
ensure_bucket()
|
|
key = _object_key(file.filename or "upload.bin", purpose)
|
|
ctype = file.content_type or mimetypes.guess_type(file.filename or "")[0] or "application/octet-stream"
|
|
client = _client()
|
|
try:
|
|
client.put_object(
|
|
Bucket=settings.s3_bucket,
|
|
Key=key,
|
|
Body=data,
|
|
ContentType=ctype,
|
|
ACL="public-read",
|
|
)
|
|
except Exception:
|
|
client.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=ctype)
|
|
|
|
url = public_url_for_key(key)
|
|
return UploadResult(url=url, object_key=key, size=len(data), content_type=ctype)
|