first commit

This commit is contained in:
2026-07-18 20:32:51 +08:00
commit 77bf076ad0
34 changed files with 4068 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Application services."""
+48
View File
@@ -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如果不是你发起的请求,请忽略此邮件。",
)
+107
View File
@@ -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)