storage.py 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. """文件存储:local | minio。upload_file 返回 {url, objectKey}。"""
  2. from __future__ import annotations
  3. import os
  4. import uuid
  5. from datetime import datetime, timezone
  6. from io import BytesIO
  7. from pathlib import Path
  8. from typing import Any, Dict
  9. from app.core.config import get_settings
  10. class StorageError(Exception):
  11. """存储参数/写入错误(如路径穿越)。"""
  12. def __init__(self, message: str):
  13. self.message = message
  14. super().__init__(message)
  15. def _unique_name(filename: str) -> str:
  16. ext = os.path.splitext(filename)[1] or ""
  17. return f"{uuid.uuid4().hex}{ext}"
  18. def _normalize_folder(folder: str) -> str:
  19. """规范化 folder:拒绝空段以外的 '..' / 绝对路径痕迹。"""
  20. raw = (folder or "").strip().replace("\\", "/")
  21. if not raw:
  22. return ""
  23. # 拒绝绝对路径或盘符式前缀
  24. if raw.startswith("/") or (len(raw) >= 2 and raw[1] == ":"):
  25. raise StorageError("非法的文件夹路径")
  26. parts: list[str] = []
  27. for part in raw.split("/"):
  28. if part in ("", "."):
  29. continue
  30. if part == "..":
  31. raise StorageError("非法的文件夹路径")
  32. parts.append(part)
  33. return "/".join(parts)
  34. def _object_key(folder: str, filename: str) -> str:
  35. unique = _unique_name(filename)
  36. date_part = datetime.now(timezone.utc).strftime("%Y%m/%d")
  37. folder = _normalize_folder(folder)
  38. if folder:
  39. return f"{folder}/{date_part}/{unique}"
  40. return f"{date_part}/{unique}"
  41. def _ensure_within_base(base_dir: Path, dest: Path) -> None:
  42. base_resolved = base_dir.resolve()
  43. dest_resolved = dest.resolve()
  44. try:
  45. dest_resolved.relative_to(base_resolved)
  46. except ValueError as exc:
  47. raise StorageError("非法的文件夹路径") from exc
  48. class LocalStorage:
  49. def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
  50. settings = get_settings()
  51. base_dir = Path(settings.LOCAL_STORAGE_DIR)
  52. object_key = _object_key(folder, filename)
  53. dest = base_dir / object_key
  54. _ensure_within_base(base_dir, dest)
  55. dest.parent.mkdir(parents=True, exist_ok=True)
  56. dest.write_bytes(content)
  57. url = f"/uploads/{object_key}"
  58. return {"url": url, "objectKey": object_key}
  59. class MinIOStorage:
  60. def __init__(self) -> None:
  61. from minio import Minio
  62. settings = get_settings()
  63. self._settings = settings
  64. self.client = Minio(
  65. settings.MINIO_ENDPOINT,
  66. access_key=settings.MINIO_ACCESS_KEY,
  67. secret_key=settings.MINIO_SECRET_KEY,
  68. secure=settings.MINIO_SECURE,
  69. )
  70. self.bucket_name = settings.MINIO_BUCKET
  71. if settings.MINIO_BASE_URL:
  72. self.base_url = settings.MINIO_BASE_URL.rstrip("/")
  73. else:
  74. scheme = "https" if settings.MINIO_SECURE else "http"
  75. self.base_url = f"{scheme}://{settings.MINIO_ENDPOINT}/{self.bucket_name}"
  76. self._ensure_bucket()
  77. def _ensure_bucket(self) -> None:
  78. if not self.client.bucket_exists(self.bucket_name):
  79. self.client.make_bucket(self.bucket_name)
  80. def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
  81. object_key = _object_key(folder, filename)
  82. self.client.put_object(
  83. bucket_name=self.bucket_name,
  84. object_name=object_key,
  85. data=BytesIO(content),
  86. length=len(content),
  87. )
  88. url = f"{self.base_url}/{object_key}"
  89. return {"url": url, "objectKey": object_key}
  90. def get_storage() -> Any:
  91. settings = get_settings()
  92. if settings.STORAGE_TYPE.lower() == "minio":
  93. return MinIOStorage()
  94. return LocalStorage()
  95. def upload_file(content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
  96. """上传文件,返回 {url, objectKey}。"""
  97. return get_storage().upload_file(content, filename, folder)