storage.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """文件存储:local | minio。upload_file 返回 {url, objectKey}。"""
  2. from __future__ import annotations
  3. import json
  4. import mimetypes
  5. import os
  6. import uuid
  7. from datetime import datetime, timezone
  8. from io import BytesIO
  9. from pathlib import Path
  10. from typing import Any, BinaryIO, Dict, Tuple, Union
  11. from urllib.parse import urlparse
  12. from app.core.config import get_settings
  13. from app.core.upload_validate import IMAGE_EXTENSIONS, file_extension
  14. StreamLike = Union[BinaryIO, Any]
  15. class StorageError(Exception):
  16. """存储参数/写入错误(如路径穿越)。"""
  17. def __init__(self, message: str):
  18. self.message = message
  19. super().__init__(message)
  20. def _unique_name(filename: str) -> str:
  21. ext = os.path.splitext(filename)[1] or ""
  22. return f"{uuid.uuid4().hex}{ext}"
  23. def _normalize_folder(folder: str) -> str:
  24. """规范化 folder:拒绝空段以外的 '..' / 绝对路径痕迹。"""
  25. raw = (folder or "").strip().replace("\\", "/")
  26. if not raw:
  27. return ""
  28. # 拒绝绝对路径或盘符式前缀
  29. if raw.startswith("/") or (len(raw) >= 2 and raw[1] == ":"):
  30. raise StorageError("非法的文件夹路径")
  31. parts: list[str] = []
  32. for part in raw.split("/"):
  33. if part in ("", "."):
  34. continue
  35. if part == "..":
  36. raise StorageError("非法的文件夹路径")
  37. parts.append(part)
  38. return "/".join(parts)
  39. def _object_key(folder: str, filename: str) -> str:
  40. unique = _unique_name(filename)
  41. date_part = datetime.now(timezone.utc).strftime("%Y%m/%d")
  42. folder = _normalize_folder(folder)
  43. if folder:
  44. return f"{folder}/{date_part}/{unique}"
  45. return f"{date_part}/{unique}"
  46. def _ensure_within_base(base_dir: Path, dest: Path) -> None:
  47. base_resolved = base_dir.resolve()
  48. dest_resolved = dest.resolve()
  49. try:
  50. dest_resolved.relative_to(base_resolved)
  51. except ValueError as exc:
  52. raise StorageError("非法的文件夹路径") from exc
  53. def _safe_content_type(filename: str) -> str:
  54. guessed = mimetypes.guess_type(filename)[0] or "application/octet-stream"
  55. banned = {
  56. "application/x-msdownload",
  57. "application/x-msdos-program",
  58. "application/x-executable",
  59. "application/x-sh",
  60. }
  61. if guessed in banned:
  62. return "application/octet-stream"
  63. return guessed
  64. def _key_has_traversal(key: str) -> bool:
  65. return any(part == ".." for part in key.split("/"))
  66. class LocalStorage:
  67. def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
  68. settings = get_settings()
  69. base_dir = Path(settings.LOCAL_STORAGE_DIR)
  70. object_key = _object_key(folder, filename)
  71. dest = base_dir / object_key
  72. _ensure_within_base(base_dir, dest)
  73. dest.parent.mkdir(parents=True, exist_ok=True)
  74. dest.write_bytes(content)
  75. os.chmod(dest, 0o644)
  76. url = f"/uploads/{object_key}"
  77. return {"url": url, "objectKey": object_key}
  78. def open_file(self, object_key: str) -> Tuple[StreamLike, str, str, bool]:
  79. settings = get_settings()
  80. base_dir = Path(settings.LOCAL_STORAGE_DIR)
  81. dest = base_dir / object_key
  82. _ensure_within_base(base_dir, dest)
  83. if not dest.is_file():
  84. raise StorageError("文件不存在")
  85. filename = dest.name
  86. content_type = _safe_content_type(filename)
  87. inline = file_extension(filename) in IMAGE_EXTENSIONS
  88. return dest.open("rb"), content_type, filename, inline
  89. class MinIOStorage:
  90. def __init__(self) -> None:
  91. from minio import Minio
  92. settings = get_settings()
  93. self._settings = settings
  94. self.client = Minio(
  95. settings.MINIO_ENDPOINT,
  96. access_key=settings.MINIO_ACCESS_KEY,
  97. secret_key=settings.MINIO_SECRET_KEY,
  98. secure=settings.MINIO_SECURE,
  99. )
  100. self.bucket_name = settings.MINIO_BUCKET
  101. if settings.MINIO_BASE_URL:
  102. self.base_url = settings.MINIO_BASE_URL.rstrip("/")
  103. else:
  104. scheme = "https" if settings.MINIO_SECURE else "http"
  105. self.base_url = f"{scheme}://{settings.MINIO_ENDPOINT}/{self.bucket_name}"
  106. self._ensure_bucket()
  107. def _ensure_bucket(self) -> None:
  108. if not self.client.bucket_exists(self.bucket_name):
  109. self.client.make_bucket(self.bucket_name)
  110. # 仅二维码公示图匿名可读;反馈/建言附件私有
  111. self._ensure_public_read_policy()
  112. def _ensure_public_read_policy(self) -> None:
  113. policy = {
  114. "Version": "2012-10-17",
  115. "Statement": [
  116. {
  117. "Effect": "Allow",
  118. "Principal": {"AWS": ["*"]},
  119. "Action": ["s3:GetObject"],
  120. "Resource": [f"arn:aws:s3:::{self.bucket_name}/qrcode/*"],
  121. }
  122. ],
  123. }
  124. self.client.set_bucket_policy(self.bucket_name, json.dumps(policy))
  125. def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
  126. object_key = _object_key(folder, filename)
  127. content_type = _safe_content_type(filename)
  128. self.client.put_object(
  129. bucket_name=self.bucket_name,
  130. object_name=object_key,
  131. data=BytesIO(content),
  132. length=len(content),
  133. content_type=content_type,
  134. )
  135. url = f"{self.base_url}/{object_key}"
  136. return {"url": url, "objectKey": object_key}
  137. def open_file(self, object_key: str) -> Tuple[StreamLike, str, str, bool]:
  138. filename = object_key.rsplit("/", 1)[-1] or "file"
  139. content_type = _safe_content_type(filename)
  140. inline = file_extension(filename) in IMAGE_EXTENSIONS
  141. try:
  142. resp = self.client.get_object(self.bucket_name, object_key)
  143. except Exception as exc:
  144. raise StorageError("文件不存在或无法读取") from exc
  145. return resp, content_type, filename, inline
  146. def get_storage() -> Any:
  147. settings = get_settings()
  148. if settings.STORAGE_TYPE.lower() == "minio":
  149. return MinIOStorage()
  150. return LocalStorage()
  151. def upload_file(content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
  152. """上传文件,返回 {url, objectKey}。"""
  153. return get_storage().upload_file(content, filename, folder)
  154. def resolve_object_key(url: str) -> str:
  155. """从库内 url 解析 objectKey;非法则 StorageError。"""
  156. raw = (url or "").strip()
  157. if not raw:
  158. raise StorageError("无效的文件路径")
  159. if "?" in raw:
  160. raw = raw.split("?", 1)[0]
  161. if raw.startswith("/uploads/"):
  162. key = raw[len("/uploads/") :].lstrip("/")
  163. if not key or _key_has_traversal(key):
  164. raise StorageError("非法的文件夹路径")
  165. return key
  166. if raw.startswith("http://") or raw.startswith("https://"):
  167. settings = get_settings()
  168. parsed = urlparse(raw)
  169. path = (parsed.path or "").lstrip("/")
  170. bucket = settings.MINIO_BUCKET
  171. marker = f"{bucket}/"
  172. idx = path.find(marker)
  173. if idx < 0:
  174. raise StorageError("无效的文件路径")
  175. key = path[idx + len(marker) :].lstrip("/")
  176. if not key or _key_has_traversal(key):
  177. raise StorageError("非法的文件夹路径")
  178. # 校验 host:须匹配 MINIO_BASE_URL 或 MINIO_ENDPOINT
  179. allowed_hosts: set[str] = set()
  180. if settings.MINIO_BASE_URL:
  181. base = urlparse(
  182. settings.MINIO_BASE_URL
  183. if "://" in settings.MINIO_BASE_URL
  184. else f"http://{settings.MINIO_BASE_URL}"
  185. )
  186. if base.hostname:
  187. allowed_hosts.add(base.hostname.lower())
  188. endpoint_host = settings.MINIO_ENDPOINT.split(":")[0].lower()
  189. if endpoint_host:
  190. allowed_hosts.add(endpoint_host)
  191. host = (parsed.hostname or "").lower()
  192. if host not in allowed_hosts:
  193. raise StorageError("无效的文件路径")
  194. return key
  195. # 允许直接传 objectKey(无前缀)
  196. key = raw.lstrip("/")
  197. if not key or _key_has_traversal(key) or key.startswith(".."):
  198. raise StorageError("非法的文件夹路径")
  199. if "://" in key:
  200. raise StorageError("无效的文件路径")
  201. return key
  202. def open_file(url: str) -> Tuple[StreamLike, str, str, bool]:
  203. """返回 (stream, content_type, download_name, inline)。"""
  204. key = resolve_object_key(url)
  205. return get_storage().open_file(key)