64 lines
2.2 KiB
Python
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)
|