feat: implement avatar upload, Gravatar support, and avatar settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -82,7 +82,8 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
|
||||
(Setting.type == SettingsType.REGISTER) |
|
||||
(Setting.type == SettingsType.CAPTCHA) |
|
||||
(Setting.type == SettingsType.AUTH) |
|
||||
(Setting.type == SettingsType.OAUTH),
|
||||
(Setting.type == SettingsType.OAUTH) |
|
||||
(Setting.type == SettingsType.AVATAR),
|
||||
fetch_mode="all",
|
||||
)
|
||||
|
||||
@@ -122,6 +123,7 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
|
||||
password_required=s.get("auth_password_required") == "1",
|
||||
phone_binding_required=s.get("auth_phone_binding_required") == "1",
|
||||
email_binding_required=s.get("auth_email_binding_required") == "1",
|
||||
avatar_max_size=int(s["avatar_size"]),
|
||||
footer_code=s.get("footer_code"),
|
||||
tos_url=s.get("tos_url"),
|
||||
privacy_url=s.get("privacy_url"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from itsdangerous import URLSafeTimedSerializer
|
||||
from loguru import logger
|
||||
from webauthn import (
|
||||
@@ -358,20 +359,78 @@ def router_user_profile(id: str) -> sqlmodels.ResponseBase:
|
||||
@user_router.get(
|
||||
path='/avatar/{id}/{size}',
|
||||
summary='获取用户头像',
|
||||
description='Get user avatar by ID and size.',
|
||||
response_model=None,
|
||||
)
|
||||
def router_user_avatar(id: str, size: int = 128) -> sqlmodels.ResponseBase:
|
||||
async def router_user_avatar(
|
||||
session: SessionDep,
|
||||
id: UUID,
|
||||
size: int = 128,
|
||||
) -> FileResponse | RedirectResponse:
|
||||
"""
|
||||
Get user avatar by ID and size.
|
||||
获取指定用户指定尺寸的头像(公开端点,无需认证)
|
||||
|
||||
Args:
|
||||
id (str): The user ID.
|
||||
size (int): The size of the avatar image.
|
||||
路径参数:
|
||||
- id: 用户 UUID
|
||||
- size: 请求的头像尺寸(px),默认 128
|
||||
|
||||
Returns:
|
||||
str: A Base64 encoded string of the user avatar image.
|
||||
行为:
|
||||
- default: 302 重定向到 Gravatar identicon
|
||||
- gravatar: 302 重定向到 Gravatar(使用用户邮箱 MD5)
|
||||
- file: 返回本地 WebP 文件
|
||||
|
||||
响应:
|
||||
- 200: image/webp(file 模式)
|
||||
- 302: 重定向到外部 URL(default/gravatar 模式)
|
||||
- 404: 用户不存在
|
||||
|
||||
缓存:Cache-Control: public, max-age=3600
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
import aiofiles.os
|
||||
|
||||
from service.avatar import (
|
||||
get_avatar_file_path,
|
||||
get_avatar_settings,
|
||||
gravatar_url,
|
||||
resolve_avatar_size,
|
||||
)
|
||||
|
||||
user = await sqlmodels.User.get(session, sqlmodels.User.id == id)
|
||||
if not user:
|
||||
http_exceptions.raise_not_found("用户不存在")
|
||||
|
||||
avatar_path, _, size_l, size_m, size_s = await get_avatar_settings(session)
|
||||
|
||||
if user.avatar == "file":
|
||||
size_label = resolve_avatar_size(size, size_l, size_m, size_s)
|
||||
file_path = get_avatar_file_path(avatar_path, user.id, size_label)
|
||||
|
||||
if not await aiofiles.os.path.exists(file_path):
|
||||
# 文件丢失,降级为 identicon
|
||||
fallback_url = gravatar_url(str(user.id), size, "https://www.gravatar.com/")
|
||||
return RedirectResponse(url=fallback_url, status_code=302)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
elif user.avatar == "gravatar":
|
||||
gravatar_setting = await sqlmodels.Setting.get(
|
||||
session,
|
||||
(sqlmodels.Setting.type == sqlmodels.SettingsType.AVATAR)
|
||||
& (sqlmodels.Setting.name == "gravatar_server"),
|
||||
)
|
||||
server = gravatar_setting.value if gravatar_setting else "https://www.gravatar.com/"
|
||||
email = user.email or str(user.id)
|
||||
url = gravatar_url(email, size, server)
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
else:
|
||||
# default: identicon
|
||||
email_or_id = user.email or str(user.id)
|
||||
url = gravatar_url(email_or_id, size, "https://www.gravatar.com/")
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
#####################
|
||||
# 需要登录的接口
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
|
||||
import sqlmodels
|
||||
@@ -165,34 +165,121 @@ async def router_user_settings(
|
||||
@user_settings_router.post(
|
||||
path='/avatar',
|
||||
summary='从文件上传头像',
|
||||
description='Upload user avatar from file.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
status_code=204,
|
||||
)
|
||||
def router_user_settings_avatar() -> sqlmodels.ResponseBase:
|
||||
async def router_user_settings_avatar(
|
||||
session: SessionDep,
|
||||
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
|
||||
file: UploadFile = File(...),
|
||||
) -> None:
|
||||
"""
|
||||
Upload user avatar from file.
|
||||
上传头像文件
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the result of the avatar upload.
|
||||
认证:JWT token
|
||||
请求体:multipart/form-data,file 字段
|
||||
|
||||
流程:
|
||||
1. 验证文件 MIME 类型(JPEG/PNG/GIF/WebP)
|
||||
2. 验证文件大小 <= avatar_size 设置(默认 2MB)
|
||||
3. 调用 Pillow 验证图片有效性并处理(居中裁剪、缩放 L/M/S)
|
||||
4. 保存三种尺寸的 WebP 文件
|
||||
5. 更新 User.avatar = "file"
|
||||
|
||||
错误处理:
|
||||
- 400: 文件类型不支持 / 图片无法解析
|
||||
- 413: 文件过大
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
from service.avatar import (
|
||||
ALLOWED_CONTENT_TYPES,
|
||||
get_avatar_settings,
|
||||
process_and_save_avatar,
|
||||
)
|
||||
|
||||
# 验证 MIME 类型
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
http_exceptions.raise_bad_request(
|
||||
f"不支持的图片格式,允许: {', '.join(ALLOWED_CONTENT_TYPES)}"
|
||||
)
|
||||
|
||||
# 读取并验证大小
|
||||
_, max_upload_size, _, _, _ = await get_avatar_settings(session)
|
||||
raw_bytes = await file.read()
|
||||
if len(raw_bytes) > max_upload_size:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件过大,最大允许 {max_upload_size} 字节",
|
||||
)
|
||||
|
||||
# 处理并保存(内部会验证图片有效性,无效抛出 ValueError)
|
||||
try:
|
||||
await process_and_save_avatar(session, user.id, raw_bytes)
|
||||
except ValueError as e:
|
||||
http_exceptions.raise_bad_request(str(e))
|
||||
|
||||
# 更新用户头像字段
|
||||
user.avatar = "file"
|
||||
await user.save(session)
|
||||
|
||||
|
||||
@user_settings_router.put(
|
||||
path='/avatar',
|
||||
summary='设定为Gravatar头像',
|
||||
description='Set user avatar to Gravatar.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
summary='设定为 Gravatar 头像',
|
||||
status_code=204,
|
||||
)
|
||||
def router_user_settings_avatar_gravatar() -> None:
|
||||
async def router_user_settings_avatar_gravatar(
|
||||
session: SessionDep,
|
||||
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
|
||||
) -> None:
|
||||
"""
|
||||
Set user avatar to Gravatar.
|
||||
将头像切换为 Gravatar
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing the result of setting the Gravatar avatar.
|
||||
认证:JWT token
|
||||
|
||||
流程:
|
||||
1. 验证用户有邮箱(Gravatar 基于邮箱 MD5)
|
||||
2. 如果当前是 FILE 头像,删除本地文件
|
||||
3. 更新 User.avatar = "gravatar"
|
||||
|
||||
错误处理:
|
||||
- 400: 用户没有邮箱
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
from service.avatar import delete_avatar_files
|
||||
|
||||
if not user.email:
|
||||
http_exceptions.raise_bad_request("Gravatar 需要邮箱,请先绑定邮箱")
|
||||
|
||||
if user.avatar == "file":
|
||||
await delete_avatar_files(session, user.id)
|
||||
|
||||
user.avatar = "gravatar"
|
||||
await user.save(session)
|
||||
|
||||
|
||||
@user_settings_router.delete(
|
||||
path='/avatar',
|
||||
summary='重置头像为默认',
|
||||
status_code=204,
|
||||
)
|
||||
async def router_user_settings_avatar_delete(
|
||||
session: SessionDep,
|
||||
user: Annotated[sqlmodels.user.User, Depends(auth_required)],
|
||||
) -> None:
|
||||
"""
|
||||
重置头像为默认
|
||||
|
||||
认证:JWT token
|
||||
|
||||
流程:
|
||||
1. 如果当前是 FILE 头像,删除本地文件
|
||||
2. 更新 User.avatar = "default"
|
||||
"""
|
||||
from service.avatar import delete_avatar_files
|
||||
|
||||
if user.avatar == "file":
|
||||
await delete_avatar_files(session, user.id)
|
||||
|
||||
user.avatar = "default"
|
||||
await user.save(session)
|
||||
|
||||
|
||||
@user_settings_router.patch(
|
||||
|
||||
@@ -76,6 +76,9 @@ class SiteConfigResponse(SQLModelBase):
|
||||
email_binding_required: bool = True
|
||||
"""是否强制绑定邮箱"""
|
||||
|
||||
avatar_max_size: int = 2097152
|
||||
"""头像文件最大字节数(默认 2MB)"""
|
||||
|
||||
footer_code: str | None = None
|
||||
"""自定义页脚代码"""
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../.
|
||||
|
||||
from main import app
|
||||
from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, Policy, PolicyType, Setting, SettingsType, User
|
||||
from sqlmodels.policy import GroupPolicyLink
|
||||
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
|
||||
from sqlmodels.user import UserStatus
|
||||
from utils import Password
|
||||
@@ -108,6 +109,12 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
|
||||
Setting(type=SettingsType.AUTH, name="auth_email_binding_required", value="1"),
|
||||
Setting(type=SettingsType.OAUTH, name="github_enabled", value="0"),
|
||||
Setting(type=SettingsType.OAUTH, name="qq_enabled", value="0"),
|
||||
Setting(type=SettingsType.AVATAR, name="gravatar_server", value="https://www.gravatar.com/"),
|
||||
Setting(type=SettingsType.AVATAR, name="avatar_size", value="2097152"),
|
||||
Setting(type=SettingsType.AVATAR, name="avatar_size_l", value="200"),
|
||||
Setting(type=SettingsType.AVATAR, name="avatar_size_m", value="130"),
|
||||
Setting(type=SettingsType.AVATAR, name="avatar_size_s", value="50"),
|
||||
Setting(type=SettingsType.PATH, name="avatar_path", value="avatar"),
|
||||
]
|
||||
for setting in settings:
|
||||
test_session.add(setting)
|
||||
@@ -156,7 +163,11 @@ async def initialized_db(test_session: AsyncSession) -> AsyncSession:
|
||||
await test_session.refresh(admin_group)
|
||||
await test_session.refresh(default_policy)
|
||||
|
||||
# 4. 创建用户组选项
|
||||
# 4. 关联用户组与存储策略
|
||||
test_session.add(GroupPolicyLink(group_id=default_group.id, policy_id=default_policy.id))
|
||||
test_session.add(GroupPolicyLink(group_id=admin_group.id, policy_id=default_policy.id))
|
||||
|
||||
# 5. 创建用户组选项
|
||||
default_group_options = GroupOptions(
|
||||
group_id=default_group.id,
|
||||
share_download=True,
|
||||
|
||||
Reference in New Issue
Block a user