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>
This commit is contained in:
Kevin
2026-05-06 13:51:43 +08:00
parent 59d4b19d7d
commit 7ad52fce89
27 changed files with 1271 additions and 270 deletions

View 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