|
@@ -1,14 +1,20 @@
|
|
|
"""文件存储:local | minio。upload_file 返回 {url, objectKey}。"""
|
|
"""文件存储:local | minio。upload_file 返回 {url, objectKey}。"""
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
+import json
|
|
|
|
|
+import mimetypes
|
|
|
import os
|
|
import os
|
|
|
import uuid
|
|
import uuid
|
|
|
from datetime import datetime, timezone
|
|
from datetime import datetime, timezone
|
|
|
from io import BytesIO
|
|
from io import BytesIO
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
-from typing import Any, Dict
|
|
|
|
|
|
|
+from typing import Any, BinaryIO, Dict, Tuple, Union
|
|
|
|
|
+from urllib.parse import urlparse
|
|
|
|
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.config import get_settings
|
|
|
|
|
+from app.core.upload_validate import IMAGE_EXTENSIONS, file_extension
|
|
|
|
|
+
|
|
|
|
|
+StreamLike = Union[BinaryIO, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
class StorageError(Exception):
|
|
class StorageError(Exception):
|
|
@@ -60,6 +66,23 @@ def _ensure_within_base(base_dir: Path, dest: Path) -> None:
|
|
|
raise StorageError("非法的文件夹路径") from exc
|
|
raise StorageError("非法的文件夹路径") from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _safe_content_type(filename: str) -> str:
|
|
|
|
|
+ guessed = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
|
|
|
+ banned = {
|
|
|
|
|
+ "application/x-msdownload",
|
|
|
|
|
+ "application/x-msdos-program",
|
|
|
|
|
+ "application/x-executable",
|
|
|
|
|
+ "application/x-sh",
|
|
|
|
|
+ }
|
|
|
|
|
+ if guessed in banned:
|
|
|
|
|
+ return "application/octet-stream"
|
|
|
|
|
+ return guessed
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _key_has_traversal(key: str) -> bool:
|
|
|
|
|
+ return any(part == ".." for part in key.split("/"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class LocalStorage:
|
|
class LocalStorage:
|
|
|
def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
|
|
def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
|
|
|
settings = get_settings()
|
|
settings = get_settings()
|
|
@@ -69,9 +92,22 @@ class LocalStorage:
|
|
|
_ensure_within_base(base_dir, dest)
|
|
_ensure_within_base(base_dir, dest)
|
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
|
dest.write_bytes(content)
|
|
dest.write_bytes(content)
|
|
|
|
|
+ os.chmod(dest, 0o644)
|
|
|
url = f"/uploads/{object_key}"
|
|
url = f"/uploads/{object_key}"
|
|
|
return {"url": url, "objectKey": object_key}
|
|
return {"url": url, "objectKey": object_key}
|
|
|
|
|
|
|
|
|
|
+ def open_file(self, object_key: str) -> Tuple[StreamLike, str, str, bool]:
|
|
|
|
|
+ settings = get_settings()
|
|
|
|
|
+ base_dir = Path(settings.LOCAL_STORAGE_DIR)
|
|
|
|
|
+ dest = base_dir / object_key
|
|
|
|
|
+ _ensure_within_base(base_dir, dest)
|
|
|
|
|
+ if not dest.is_file():
|
|
|
|
|
+ raise StorageError("文件不存在")
|
|
|
|
|
+ filename = dest.name
|
|
|
|
|
+ content_type = _safe_content_type(filename)
|
|
|
|
|
+ inline = file_extension(filename) in IMAGE_EXTENSIONS
|
|
|
|
|
+ return dest.open("rb"), content_type, filename, inline
|
|
|
|
|
+
|
|
|
|
|
|
|
|
class MinIOStorage:
|
|
class MinIOStorage:
|
|
|
def __init__(self) -> None:
|
|
def __init__(self) -> None:
|
|
@@ -96,18 +132,46 @@ class MinIOStorage:
|
|
|
def _ensure_bucket(self) -> None:
|
|
def _ensure_bucket(self) -> None:
|
|
|
if not self.client.bucket_exists(self.bucket_name):
|
|
if not self.client.bucket_exists(self.bucket_name):
|
|
|
self.client.make_bucket(self.bucket_name)
|
|
self.client.make_bucket(self.bucket_name)
|
|
|
|
|
+ # 仅二维码公示图匿名可读;反馈/建言附件私有
|
|
|
|
|
+ self._ensure_public_read_policy()
|
|
|
|
|
+
|
|
|
|
|
+ def _ensure_public_read_policy(self) -> None:
|
|
|
|
|
+ policy = {
|
|
|
|
|
+ "Version": "2012-10-17",
|
|
|
|
|
+ "Statement": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "Effect": "Allow",
|
|
|
|
|
+ "Principal": {"AWS": ["*"]},
|
|
|
|
|
+ "Action": ["s3:GetObject"],
|
|
|
|
|
+ "Resource": [f"arn:aws:s3:::{self.bucket_name}/qrcode/*"],
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
|
|
+ self.client.set_bucket_policy(self.bucket_name, json.dumps(policy))
|
|
|
|
|
|
|
|
def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
|
|
def upload_file(self, content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
|
|
|
object_key = _object_key(folder, filename)
|
|
object_key = _object_key(folder, filename)
|
|
|
|
|
+ content_type = _safe_content_type(filename)
|
|
|
self.client.put_object(
|
|
self.client.put_object(
|
|
|
bucket_name=self.bucket_name,
|
|
bucket_name=self.bucket_name,
|
|
|
object_name=object_key,
|
|
object_name=object_key,
|
|
|
data=BytesIO(content),
|
|
data=BytesIO(content),
|
|
|
length=len(content),
|
|
length=len(content),
|
|
|
|
|
+ content_type=content_type,
|
|
|
)
|
|
)
|
|
|
url = f"{self.base_url}/{object_key}"
|
|
url = f"{self.base_url}/{object_key}"
|
|
|
return {"url": url, "objectKey": object_key}
|
|
return {"url": url, "objectKey": object_key}
|
|
|
|
|
|
|
|
|
|
+ def open_file(self, object_key: str) -> Tuple[StreamLike, str, str, bool]:
|
|
|
|
|
+ filename = object_key.rsplit("/", 1)[-1] or "file"
|
|
|
|
|
+ content_type = _safe_content_type(filename)
|
|
|
|
|
+ inline = file_extension(filename) in IMAGE_EXTENSIONS
|
|
|
|
|
+ try:
|
|
|
|
|
+ resp = self.client.get_object(self.bucket_name, object_key)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ raise StorageError("文件不存在或无法读取") from exc
|
|
|
|
|
+ return resp, content_type, filename, inline
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def get_storage() -> Any:
|
|
def get_storage() -> Any:
|
|
|
settings = get_settings()
|
|
settings = get_settings()
|
|
@@ -119,3 +183,63 @@ def get_storage() -> Any:
|
|
|
def upload_file(content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
|
|
def upload_file(content: bytes, filename: str, folder: str = "") -> Dict[str, str]:
|
|
|
"""上传文件,返回 {url, objectKey}。"""
|
|
"""上传文件,返回 {url, objectKey}。"""
|
|
|
return get_storage().upload_file(content, filename, folder)
|
|
return get_storage().upload_file(content, filename, folder)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def resolve_object_key(url: str) -> str:
|
|
|
|
|
+ """从库内 url 解析 objectKey;非法则 StorageError。"""
|
|
|
|
|
+ raw = (url or "").strip()
|
|
|
|
|
+ if not raw:
|
|
|
|
|
+ raise StorageError("无效的文件路径")
|
|
|
|
|
+ if "?" in raw:
|
|
|
|
|
+ raw = raw.split("?", 1)[0]
|
|
|
|
|
+
|
|
|
|
|
+ if raw.startswith("/uploads/"):
|
|
|
|
|
+ key = raw[len("/uploads/") :].lstrip("/")
|
|
|
|
|
+ if not key or _key_has_traversal(key):
|
|
|
|
|
+ raise StorageError("非法的文件夹路径")
|
|
|
|
|
+ return key
|
|
|
|
|
+
|
|
|
|
|
+ if raw.startswith("http://") or raw.startswith("https://"):
|
|
|
|
|
+ settings = get_settings()
|
|
|
|
|
+ parsed = urlparse(raw)
|
|
|
|
|
+ path = (parsed.path or "").lstrip("/")
|
|
|
|
|
+ bucket = settings.MINIO_BUCKET
|
|
|
|
|
+ marker = f"{bucket}/"
|
|
|
|
|
+ idx = path.find(marker)
|
|
|
|
|
+ if idx < 0:
|
|
|
|
|
+ raise StorageError("无效的文件路径")
|
|
|
|
|
+ key = path[idx + len(marker) :].lstrip("/")
|
|
|
|
|
+ if not key or _key_has_traversal(key):
|
|
|
|
|
+ raise StorageError("非法的文件夹路径")
|
|
|
|
|
+
|
|
|
|
|
+ # 校验 host:须匹配 MINIO_BASE_URL 或 MINIO_ENDPOINT
|
|
|
|
|
+ allowed_hosts: set[str] = set()
|
|
|
|
|
+ if settings.MINIO_BASE_URL:
|
|
|
|
|
+ base = urlparse(
|
|
|
|
|
+ settings.MINIO_BASE_URL
|
|
|
|
|
+ if "://" in settings.MINIO_BASE_URL
|
|
|
|
|
+ else f"http://{settings.MINIO_BASE_URL}"
|
|
|
|
|
+ )
|
|
|
|
|
+ if base.hostname:
|
|
|
|
|
+ allowed_hosts.add(base.hostname.lower())
|
|
|
|
|
+ endpoint_host = settings.MINIO_ENDPOINT.split(":")[0].lower()
|
|
|
|
|
+ if endpoint_host:
|
|
|
|
|
+ allowed_hosts.add(endpoint_host)
|
|
|
|
|
+ host = (parsed.hostname or "").lower()
|
|
|
|
|
+ if host not in allowed_hosts:
|
|
|
|
|
+ raise StorageError("无效的文件路径")
|
|
|
|
|
+ return key
|
|
|
|
|
+
|
|
|
|
|
+ # 允许直接传 objectKey(无前缀)
|
|
|
|
|
+ key = raw.lstrip("/")
|
|
|
|
|
+ if not key or _key_has_traversal(key) or key.startswith(".."):
|
|
|
|
|
+ raise StorageError("非法的文件夹路径")
|
|
|
|
|
+ if "://" in key:
|
|
|
|
|
+ raise StorageError("无效的文件路径")
|
|
|
|
|
+ return key
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def open_file(url: str) -> Tuple[StreamLike, str, str, bool]:
|
|
|
|
|
+ """返回 (stream, content_type, download_name, inline)。"""
|
|
|
|
|
+ key = resolve_object_key(url)
|
|
|
|
|
+ return get_storage().open_file(key)
|