first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Application services."""
|
||||
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
|
||||
import aiosmtplib
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _send(subject: str, recipient: str, text: str) -> None:
|
||||
if settings.email_delivery_mode == "console":
|
||||
logger.warning("开发邮件\nTo: %s\nSubject: %s\n%s", recipient, subject, text)
|
||||
return
|
||||
|
||||
message = EmailMessage()
|
||||
message["From"] = settings.email_from
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
message.set_content(text)
|
||||
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username,
|
||||
password=settings.smtp_password,
|
||||
start_tls=settings.smtp_starttls,
|
||||
)
|
||||
|
||||
|
||||
async def send_confirmation_email(recipient: str, token: str) -> None:
|
||||
url = f"{settings.frontend_url.rstrip('/')}/auth/confirm?token={token}"
|
||||
await _send(
|
||||
"确认你的 OpenCloud 邮箱",
|
||||
recipient,
|
||||
f"请打开下面的链接完成邮箱确认:\n\n{url}\n\n链接将在指定时间后失效。",
|
||||
)
|
||||
|
||||
|
||||
async def send_password_reset_email(recipient: str, token: str) -> None:
|
||||
url = f"{settings.frontend_url.rstrip('/')}/auth/reset-password?token={token}"
|
||||
await _send(
|
||||
"重置你的 OpenCloud 密码",
|
||||
recipient,
|
||||
f"请打开下面的链接重置密码:\n\n{url}\n\n如果不是你发起的请求,请忽略此邮件。",
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user