nomadweb/backend/app/services/s3_storage.py
eric 96fa96c61e Migrate auth/community/social to PocketBase and wire SeaweedFS S3 uploads.
Keep FastAPI as the only backend; add media upload, ntfy hook, and PB collection bootstrap for production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 11:19:30 -05:00

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)