第一次提交
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""对象存储(S3 兼容):上传/下载图片,含校验与去重。
|
||||
|
||||
boto3 是同步库,直接在 async 路径里调用会阻塞事件循环(传大图时整个服务卡住),
|
||||
所以对外暴露 async 版本(to_thread 包装),同步版本保留给测试和脚本用。
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import io
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Pillow 格式 → 扩展名
|
||||
_FORMAT_EXT = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
|
||||
# 解压炸弹防护:单边像素上限
|
||||
_MAX_DIMENSION = 12000
|
||||
|
||||
|
||||
class StorageError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ImageValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _s3_client():
|
||||
if not settings.s3_bucket:
|
||||
raise StorageError("S3 未配置(S3_BUCKET 为空)")
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint_url,
|
||||
region_name=settings.s3_region,
|
||||
aws_access_key_id=settings.s3_access_key,
|
||||
aws_secret_access_key=settings.s3_secret_key,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
|
||||
def validate_image(data: bytes) -> tuple[str, str]:
|
||||
"""校验是真实图片且格式在白名单内。返回 (mime, ext)。"""
|
||||
if len(data) > settings.max_upload_bytes:
|
||||
raise ImageValidationError("文件超过大小上限")
|
||||
try:
|
||||
img = PILImage.open(io.BytesIO(data))
|
||||
img.verify() # 校验完整性(魔数级)
|
||||
# verify 后需重新打开才能读属性
|
||||
img = PILImage.open(io.BytesIO(data))
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise ImageValidationError(f"不是有效的图片文件:{e}") from e
|
||||
|
||||
fmt = img.format or ""
|
||||
if fmt not in _FORMAT_EXT:
|
||||
raise ImageValidationError(f"不支持的图片格式:{fmt}")
|
||||
if img.width > _MAX_DIMENSION or img.height > _MAX_DIMENSION:
|
||||
raise ImageValidationError("图片尺寸过大")
|
||||
|
||||
mime = f"image/{'jpeg' if fmt == 'JPEG' else fmt.lower()}"
|
||||
if mime not in settings.allowed_image_mimes:
|
||||
raise ImageValidationError(f"不支持的 MIME:{mime}")
|
||||
return mime, _FORMAT_EXT[fmt]
|
||||
|
||||
|
||||
def upload_image(user_id: str, data: bytes, ext: str, mime: str) -> str:
|
||||
"""上传到 S3,返回 object_key。"""
|
||||
key = f"{user_id}/{uuid.uuid4().hex}.{ext}"
|
||||
client = _s3_client()
|
||||
client.put_object(
|
||||
Bucket=settings.s3_bucket,
|
||||
Key=key,
|
||||
Body=data,
|
||||
ContentType=mime,
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def download_image(object_key: str) -> bytes:
|
||||
client = _s3_client()
|
||||
resp = client.get_object(Bucket=settings.s3_bucket, Key=object_key)
|
||||
return resp["Body"].read()
|
||||
|
||||
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
# ---- async 包装:在 async 路径里请用这两个 ----
|
||||
|
||||
|
||||
async def upload_image_async(user_id: str, data: bytes, ext: str, mime: str) -> str:
|
||||
return await asyncio.to_thread(upload_image, user_id, data, ext, mime)
|
||||
|
||||
|
||||
async def download_image_async(object_key: str) -> bytes:
|
||||
return await asyncio.to_thread(download_image, object_key)
|
||||
Reference in New Issue
Block a user