feat(profile): avatar presets, upload, nickname editing
- FastAPI: preset assets 01–08, GET list/static, PUT /me/avatar/preset, safer uploaded-avatar path validation, preset_avatars + HTTP tests. - Expo: personal-info (library + presets), profile tab avatar, resolveApiMediaUrl, auth hooks cache sync, Web multipart helper, partial-save messaging + profile i18n. - Includes existing edits to conversation screen and voice use-player. Co-authored-by: Cursor <cursoragent@cursor.com>
BIN
api/app/features/auth/avatar_presets/01.png
Normal file
|
After Width: | Height: | Size: 135 B |
BIN
api/app/features/auth/avatar_presets/02.png
Normal file
|
After Width: | Height: | Size: 136 B |
BIN
api/app/features/auth/avatar_presets/03.png
Normal file
|
After Width: | Height: | Size: 136 B |
BIN
api/app/features/auth/avatar_presets/04.png
Normal file
|
After Width: | Height: | Size: 136 B |
BIN
api/app/features/auth/avatar_presets/05.png
Normal file
|
After Width: | Height: | Size: 136 B |
BIN
api/app/features/auth/avatar_presets/06.png
Normal file
|
After Width: | Height: | Size: 136 B |
BIN
api/app/features/auth/avatar_presets/07.png
Normal file
|
After Width: | Height: | Size: 136 B |
BIN
api/app/features/auth/avatar_presets/08.png
Normal file
|
After Width: | Height: | Size: 135 B |
68
api/app/features/auth/preset_avatars.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""服务端托管的预设头像(白名单文件名)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_PRESETS_DIR = Path(__file__).resolve().parent / "avatar_presets"
|
||||
|
||||
# 与仓库内静态文件一致:01.png … 08.png
|
||||
ALLOWED_PRESET_FILENAMES: frozenset[str] = frozenset(f"{i:02d}.png" for i in range(1, 9))
|
||||
|
||||
PRESET_IDS: tuple[str, ...] = tuple(f"{i:02d}" for i in range(1, 9))
|
||||
|
||||
|
||||
def preset_filename_for_id(preset_id: str) -> str | None:
|
||||
"""preset_id 形如 \"01\",返回 \"01.png\";非法则 None。"""
|
||||
stripped = preset_id.strip()
|
||||
name = f"{stripped}.png"
|
||||
if name in ALLOWED_PRESET_FILENAMES:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def avatar_url_for_preset_filename(filename: str) -> str:
|
||||
return f"/api/auth/avatar-presets/{filename}"
|
||||
|
||||
|
||||
def list_preset_items() -> list[tuple[str, str]]:
|
||||
"""(preset_id, avatar_url) 列表,供 GET /avatar-presets。"""
|
||||
return [
|
||||
(pid, avatar_url_for_preset_filename(f"{pid}.png")) for pid in PRESET_IDS
|
||||
]
|
||||
|
||||
|
||||
def preset_file_path(filename: str) -> Path | None:
|
||||
if filename not in ALLOWED_PRESET_FILENAMES:
|
||||
return None
|
||||
path = (_PRESETS_DIR / filename).resolve()
|
||||
try:
|
||||
path.relative_to(_PRESETS_DIR.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _avatar_upload_stem_allowed(stem: str) -> bool:
|
||||
"""允许 UUID、数字 ID、含连字符/下划线的安全文件名主干。"""
|
||||
if not stem or len(stem) > 128:
|
||||
return False
|
||||
return all(c.isalnum() or c in "-_" for c in stem)
|
||||
|
||||
|
||||
def safe_avatar_upload_path(filename: str, avatar_dir: Path) -> Path | None:
|
||||
"""用户上传头像文件名形如 {user_id}.jpg,防路径穿越。"""
|
||||
if "/" in filename or "\\" in filename or ".." in filename:
|
||||
return None
|
||||
if not filename.endswith(".jpg"):
|
||||
return None
|
||||
stem = filename[:-4]
|
||||
if not _avatar_upload_stem_allowed(stem):
|
||||
return None
|
||||
base = avatar_dir.resolve()
|
||||
path = (avatar_dir / filename).resolve()
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return None
|
||||
return path
|
||||
@@ -9,7 +9,15 @@ from app.core.config import settings
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.logging import get_logger
|
||||
from app.features.auth.deps import get_auth_service
|
||||
from app.features.auth.preset_avatars import (
|
||||
avatar_url_for_preset_filename,
|
||||
list_preset_items,
|
||||
preset_filename_for_id,
|
||||
preset_file_path,
|
||||
safe_avatar_upload_path,
|
||||
)
|
||||
from app.features.auth.schemas import (
|
||||
AvatarPresetItem,
|
||||
ChangePasswordRequest,
|
||||
ChangePhoneRequest,
|
||||
LoginRequest,
|
||||
@@ -18,6 +26,7 @@ from app.features.auth.schemas import (
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
SendSmsRequest,
|
||||
SetAvatarPresetRequest,
|
||||
SmsLoginRequest,
|
||||
SmsRegisterRequest,
|
||||
TokenResponse,
|
||||
@@ -329,14 +338,72 @@ async def upload_avatar(
|
||||
) from e
|
||||
|
||||
|
||||
@router.get(
|
||||
"/avatar-presets",
|
||||
response_model=list[AvatarPresetItem],
|
||||
summary="预设头像列表",
|
||||
)
|
||||
async def list_avatar_presets():
|
||||
return [
|
||||
AvatarPresetItem(id=item_id, url=item_url)
|
||||
for item_id, item_url in list_preset_items()
|
||||
]
|
||||
|
||||
|
||||
@router.put(
|
||||
"/me/avatar/preset",
|
||||
response_model=UserResponse,
|
||||
summary="使用预设头像",
|
||||
responses={400: {"description": "无效的预设编号"}},
|
||||
)
|
||||
async def set_avatar_preset(
|
||||
request: SetAvatarPresetRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
filename = preset_filename_for_id(request.preset_id)
|
||||
if filename is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="无效的预设头像编号",
|
||||
)
|
||||
path = preset_file_path(filename)
|
||||
if path is None or not path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="预设头像不可用",
|
||||
)
|
||||
avatar_url = avatar_url_for_preset_filename(filename)
|
||||
try:
|
||||
user = await service.update_avatar_url(current_user.id, avatar_url)
|
||||
except AuthError as e:
|
||||
raise _map_auth_error(e)
|
||||
return _user_response(user)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/avatar-presets/{filename}",
|
||||
summary="获取预设头像图片",
|
||||
responses={404: {"description": "预设不存在"}},
|
||||
)
|
||||
async def get_avatar_preset(filename: str):
|
||||
path = preset_file_path(filename)
|
||||
if path is None or not path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="预设头像不存在",
|
||||
)
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/avatars/{filename}",
|
||||
summary="获取头像图片",
|
||||
responses={404: {"description": "头像不存在"}},
|
||||
)
|
||||
async def get_avatar(filename: str):
|
||||
file_path = AVATAR_DIR / filename
|
||||
if not file_path.exists():
|
||||
file_path = safe_avatar_upload_path(filename, AVATAR_DIR)
|
||||
if file_path is None or not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="头像不存在",
|
||||
|
||||
@@ -104,3 +104,18 @@ class UpdateNicknameRequest(BaseModel):
|
||||
|
||||
class AvatarUploadResponse(BaseModel):
|
||||
avatar_url: str
|
||||
|
||||
|
||||
class SetAvatarPresetRequest(BaseModel):
|
||||
preset_id: str = Field(
|
||||
...,
|
||||
min_length=2,
|
||||
max_length=2,
|
||||
pattern=r"^\d{2}$",
|
||||
description="预设编号,如 01–08",
|
||||
)
|
||||
|
||||
|
||||
class AvatarPresetItem(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
|
||||
170
api/tests/test_avatar_preset_http.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""预设头像 HTTP 契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.features.auth.deps import get_auth_service
|
||||
from app.features.auth.router import router as auth_router
|
||||
from app.features.auth.service import AuthService
|
||||
from app.features.user.models import User
|
||||
|
||||
|
||||
def _mock_current_user() -> User:
|
||||
u = MagicMock(spec=User)
|
||||
u.id = str(uuid.uuid4())
|
||||
u.phone = "13800000000"
|
||||
u.email = None
|
||||
u.nickname = "测试用户"
|
||||
u.avatar_url = None
|
||||
u.subscription_type = "free"
|
||||
u.created_at = datetime.now(timezone.utc)
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def preset_auth_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
|
||||
fixed_user = _mock_current_user()
|
||||
|
||||
async def _fake_update_avatar(uid: str, url: str):
|
||||
fixed_user.avatar_url = url
|
||||
return fixed_user
|
||||
|
||||
mock_service = MagicMock(spec=AuthService)
|
||||
mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar)
|
||||
|
||||
app.dependency_overrides[get_auth_service] = lambda: mock_service
|
||||
app.dependency_overrides[get_current_user] = lambda: fixed_user
|
||||
|
||||
app.state._mock_auth_service = mock_service
|
||||
app.state._fixed_user = fixed_user
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_avatar_presets(preset_auth_app: FastAPI) -> None:
|
||||
transport = ASGITransport(app=preset_auth_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.get("/api/auth/avatar-presets")
|
||||
assert r.status_code == 200
|
||||
items = r.json()
|
||||
assert len(items) == 8
|
||||
assert items[0]["id"] == "01"
|
||||
assert items[0]["url"] == "/api/auth/avatar-presets/01.png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_avatar_preset_ok(preset_auth_app: FastAPI) -> None:
|
||||
transport = ASGITransport(app=preset_auth_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.get("/api/auth/avatar-presets/01.png")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("content-type", "").startswith("image/png")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_avatar_preset_unknown(preset_auth_app: FastAPI) -> None:
|
||||
transport = ASGITransport(app=preset_auth_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.get("/api/auth/avatar-presets/99.png")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_avatar_preset_path_traversal(preset_auth_app: FastAPI) -> None:
|
||||
transport = ASGITransport(app=preset_auth_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.get("/api/auth/avatar-presets/../secrets.env")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None:
|
||||
uid = preset_auth_app.state._fixed_user.id
|
||||
transport = ASGITransport(app=preset_auth_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.put(
|
||||
"/api/auth/me/avatar/preset",
|
||||
json={"preset_id": "02"},
|
||||
headers={"Authorization": "Bearer x"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["avatar_url"] == "/api/auth/avatar-presets/02.png"
|
||||
svc: MagicMock = preset_auth_app.state._mock_auth_service
|
||||
svc.update_avatar_url.assert_awaited_once_with(
|
||||
uid, "/api/auth/avatar-presets/02.png"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_avatar_preset_invalid(preset_auth_app: FastAPI) -> None:
|
||||
transport = ASGITransport(app=preset_auth_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.put(
|
||||
"/api/auth/me/avatar/preset",
|
||||
json={"preset_id": "99"},
|
||||
headers={"Authorization": "Bearer x"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_uploaded_avatar_rejects_traversal() -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.get("/api/auth/avatars/../../../etc/passwd")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_uploaded_avatar_ok_with_safe_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import app.features.auth.router as auth_router_mod
|
||||
|
||||
avatar_dir = tmp_path / "avatars"
|
||||
avatar_dir.mkdir()
|
||||
(avatar_dir / "abc-def-123.jpg").write_bytes(b"x")
|
||||
|
||||
monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.get("/api/auth/avatars/abc-def-123.jpg")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_uploaded_avatar_ok_when_stem_has_underscore(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import app.features.auth.router as auth_router_mod
|
||||
|
||||
avatar_dir = tmp_path / "avatars"
|
||||
avatar_dir.mkdir()
|
||||
(avatar_dir / "user_abc_01.jpg").write_bytes(b"x")
|
||||
|
||||
monkeypatch.setattr(auth_router_mod, "AVATAR_DIR", avatar_dir)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
r = await ac.get("/api/auth/avatars/user_abc_01.jpg")
|
||||
assert r.status_code == 200
|
||||