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.REGISTER) |
|
||||||
(Setting.type == SettingsType.CAPTCHA) |
|
(Setting.type == SettingsType.CAPTCHA) |
|
||||||
(Setting.type == SettingsType.AUTH) |
|
(Setting.type == SettingsType.AUTH) |
|
||||||
(Setting.type == SettingsType.OAUTH),
|
(Setting.type == SettingsType.OAUTH) |
|
||||||
|
(Setting.type == SettingsType.AVATAR),
|
||||||
fetch_mode="all",
|
fetch_mode="all",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ async def router_site_config(session: SessionDep) -> SiteConfigResponse:
|
|||||||
password_required=s.get("auth_password_required") == "1",
|
password_required=s.get("auth_password_required") == "1",
|
||||||
phone_binding_required=s.get("auth_phone_binding_required") == "1",
|
phone_binding_required=s.get("auth_phone_binding_required") == "1",
|
||||||
email_binding_required=s.get("auth_email_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"),
|
footer_code=s.get("footer_code"),
|
||||||
tos_url=s.get("tos_url"),
|
tos_url=s.get("tos_url"),
|
||||||
privacy_url=s.get("privacy_url"),
|
privacy_url=s.get("privacy_url"),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from itsdangerous import URLSafeTimedSerializer
|
from itsdangerous import URLSafeTimedSerializer
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from webauthn import (
|
from webauthn import (
|
||||||
@@ -358,20 +359,78 @@ def router_user_profile(id: str) -> sqlmodels.ResponseBase:
|
|||||||
@user_router.get(
|
@user_router.get(
|
||||||
path='/avatar/{id}/{size}',
|
path='/avatar/{id}/{size}',
|
||||||
summary='获取用户头像',
|
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.
|
- id: 用户 UUID
|
||||||
size (int): The size of the avatar image.
|
- 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 typing import Annotated
|
||||||
from uuid import UUID
|
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
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
|
|
||||||
import sqlmodels
|
import sqlmodels
|
||||||
@@ -165,34 +165,121 @@ async def router_user_settings(
|
|||||||
@user_settings_router.post(
|
@user_settings_router.post(
|
||||||
path='/avatar',
|
path='/avatar',
|
||||||
summary='从文件上传头像',
|
summary='从文件上传头像',
|
||||||
description='Upload user avatar from file.',
|
status_code=204,
|
||||||
dependencies=[Depends(auth_required)],
|
|
||||||
)
|
)
|
||||||
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:
|
认证:JWT token
|
||||||
dict: A dictionary containing the result of the avatar upload.
|
请求体: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(
|
@user_settings_router.put(
|
||||||
path='/avatar',
|
path='/avatar',
|
||||||
summary='设定为 Gravatar 头像',
|
summary='设定为 Gravatar 头像',
|
||||||
description='Set user avatar to Gravatar.',
|
|
||||||
dependencies=[Depends(auth_required)],
|
|
||||||
status_code=204,
|
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:
|
认证:JWT token
|
||||||
dict: A dictionary containing the result of setting the Gravatar avatar.
|
|
||||||
|
流程:
|
||||||
|
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(
|
@user_settings_router.patch(
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ class SiteConfigResponse(SQLModelBase):
|
|||||||
email_binding_required: bool = True
|
email_binding_required: bool = True
|
||||||
"""是否强制绑定邮箱"""
|
"""是否强制绑定邮箱"""
|
||||||
|
|
||||||
|
avatar_max_size: int = 2097152
|
||||||
|
"""头像文件最大字节数(默认 2MB)"""
|
||||||
|
|
||||||
footer_code: str | None = None
|
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 main import app
|
||||||
from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, Policy, PolicyType, Setting, SettingsType, User
|
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.auth_identity import AuthIdentity, AuthProviderType
|
||||||
from sqlmodels.user import UserStatus
|
from sqlmodels.user import UserStatus
|
||||||
from utils import Password
|
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.AUTH, name="auth_email_binding_required", value="1"),
|
||||||
Setting(type=SettingsType.OAUTH, name="github_enabled", value="0"),
|
Setting(type=SettingsType.OAUTH, name="github_enabled", value="0"),
|
||||||
Setting(type=SettingsType.OAUTH, name="qq_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:
|
for setting in settings:
|
||||||
test_session.add(setting)
|
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(admin_group)
|
||||||
await test_session.refresh(default_policy)
|
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(
|
default_group_options = GroupOptions(
|
||||||
group_id=default_group.id,
|
group_id=default_group.id,
|
||||||
share_download=True,
|
share_download=True,
|
||||||
|
|||||||
Reference in New Issue
Block a user