"""大屏封面图:放本地磁盘,文件名就是封面 ID;每次上传换新 ID,旧图删掉 图片类 URL 要能被 匿名访问,靠不可猜的 ID 兜底 """ import re import secrets from pathlib import Path from app.core.exceptions import BizException MAX_BYTES = 1024 * 1024 # 按文件头判断类型,不信任客户端声明的 Content-Type MAGIC = {b"\x89PNG\r\n\x1a\n": "image/png", b"\xff\xd8\xff": "image/jpeg"} # ID 进了文件路径,读写前必须过这个格式检查,防路径穿越 ID_PATTERN = re.compile(r"th_[A-Za-z0-9_-]{16,64}") def sniff(head: bytes) -> str | None: for magic, media_type in MAGIC.items(): if head.startswith(magic): return media_type return None class ThumbnailStorage: def __init__(self, root: Path): self._root = root def _path(self, thumbnail_id: str) -> Path | None: if not ID_PATTERN.fullmatch(thumbnail_id): return None return self._root / thumbnail_id def save(self, data: bytes) -> str: if len(data) > MAX_BYTES: raise BizException(413, "封面图不能超过 1 MB") if sniff(data[:8]) is None: raise BizException(422, "封面图只支持 PNG 或 JPEG") thumbnail_id = f"th_{secrets.token_urlsafe(16)}" self._root.mkdir(parents=True, exist_ok=True) self._path(thumbnail_id).write_bytes(data) return thumbnail_id def open(self, thumbnail_id: str) -> tuple[Path, str] | None: """文件路径和图片类型;ID 不合法、文件不存在或内容不是图片时返回 None""" path = self._path(thumbnail_id) if path is None or not path.is_file(): return None with path.open("rb") as file: media_type = sniff(file.read(8)) return None if media_type is None else (path, media_type) def copy(self, thumbnail_id: str) -> str | None: path = self._path(thumbnail_id) if path is None or not path.is_file(): return None return self.save(path.read_bytes()) def delete(self, thumbnail_id: str | None) -> None: path = None if thumbnail_id is None else self._path(thumbnail_id) if path is not None: path.unlink(missing_ok=True)