Files
opencloud-backend/app/services/storage.py
T
2026-07-18 20:32:51 +08:00

108 lines
4.0 KiB
Python

import asyncio
import io
import logging
import uuid
from pathlib import Path
from fastapi import HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from app.config import settings
logger = logging.getLogger(__name__)
ALLOWED_FORMATS = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
def _process_image(raw: bytes) -> tuple[bytes, str, bytes]:
Image.MAX_IMAGE_PIXELS = settings.max_image_pixels
try:
with Image.open(io.BytesIO(raw)) as source:
if source.width * source.height > settings.max_image_pixels:
raise ValueError("图片像素尺寸过大")
source.load()
image_format = source.format
if image_format not in ALLOWED_FORMATS:
raise ValueError("仅支持 JPEG、PNG 和 WebP 图片")
extension = ALLOWED_FORMATS[image_format]
normalized = io.BytesIO()
if image_format == "PNG":
source.save(normalized, format="PNG", optimize=True)
else:
rgb = source.convert("RGB")
output_format = "JPEG" if image_format == "JPEG" else "WEBP"
rgb.save(normalized, format=output_format, quality=92, optimize=True)
thumbnail = source.copy()
thumbnail.thumbnail(
(settings.thumbnail_max_edge, settings.thumbnail_max_edge),
Image.Resampling.LANCZOS,
)
thumbnail_bytes = io.BytesIO()
thumbnail.convert("RGB").save(
thumbnail_bytes,
format="JPEG",
quality=78,
optimize=True,
)
return normalized.getvalue(), extension, thumbnail_bytes.getvalue()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError, ValueError) as exc:
raise ValueError(str(exc) or "图片内容无效") from exc
def _write_files(
user_id: uuid.UUID,
original: bytes,
extension: str,
thumbnail: bytes,
) -> tuple[str, str]:
file_id = uuid.uuid4()
relative_dir = Path(str(user_id))
image_path = relative_dir / f"{file_id}.{extension}"
thumbnail_path = relative_dir / f"{file_id}-thumb.jpg"
target_dir = settings.upload_dir / relative_dir
target_dir.mkdir(parents=True, exist_ok=True)
(settings.upload_dir / image_path).write_bytes(original)
(settings.upload_dir / thumbnail_path).write_bytes(thumbnail)
return image_path.as_posix(), thumbnail_path.as_posix()
async def save_cloud_image(file: UploadFile, user_id: uuid.UUID) -> tuple[str, str]:
raw = await file.read(settings.max_upload_bytes + 1)
if not raw:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="图片不能为空")
if len(raw) > settings.max_upload_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="图片文件过大")
try:
original, extension, thumbnail = await asyncio.to_thread(_process_image, raw)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"图片无法处理:{exc}",
) from exc
return await asyncio.to_thread(_write_files, user_id, original, extension, thumbnail)
def _safe_file_path(relative_path: str) -> Path | None:
root = settings.upload_dir.resolve()
candidate = (root / relative_path).resolve()
if candidate == root or root not in candidate.parents:
return None
return candidate
async def delete_files(paths: list[str]) -> None:
def remove() -> None:
for relative_path in paths:
candidate = _safe_file_path(relative_path)
if not candidate:
logger.error("拒绝删除不安全的文件路径:%s", relative_path)
continue
try:
candidate.unlink(missing_ok=True)
except OSError:
logger.exception("删除图片失败:%s", relative_path)
await asyncio.to_thread(remove)