diff --git a/routers/api/v1/site/__init__.py b/routers/api/v1/site/__init__.py index 484ec44..71c8540 100644 --- a/routers/api/v1/site/__init__.py +++ b/routers/api/v1/site/__init__.py @@ -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"), diff --git a/routers/api/v1/user/__init__.py b/routers/api/v1/user/__init__.py index 9e722f8..fa718ba 100644 --- a/routers/api/v1/user/__init__.py +++ b/routers/api/v1/user/__init__.py @@ -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) ##################### # 需要登录的接口 diff --git a/routers/api/v1/user/settings/__init__.py b/routers/api/v1/user/settings/__init__.py index cd7f342..e1076e4 100644 --- a/routers/api/v1/user/settings/__init__.py +++ b/routers/api/v1/user/settings/__init__.py @@ -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( diff --git a/sqlmodels/setting.py b/sqlmodels/setting.py index 035f8da..926549d 100644 --- a/sqlmodels/setting.py +++ b/sqlmodels/setting.py @@ -76,6 +76,9 @@ class SiteConfigResponse(SQLModelBase): email_binding_required: bool = True """是否强制绑定邮箱""" + avatar_max_size: int = 2097152 + """头像文件最大字节数(默认 2MB)""" + footer_code: str | None = None """自定义页脚代码""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d90c08a..06cd5d0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -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,