"""服务端托管的预设头像(白名单文件名)。""" 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