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