Files
my-v0-server/app/services/thumbnails.py
T

64 lines
2.2 KiB
Python

"""大屏封面图:放本地磁盘,文件名就是封面 ID;每次上传换新 ID,旧图删掉
图片类 URL 要能被 <img src> 匿名访问,靠不可猜的 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)