Files
crawler-plugin/backend/ali_oss.py
T

216 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""对象存储上传工具(MinIO,S3 协议兼容)
原实现基于阿里云 OSS SDKalibabacloud_oss_v2),现改为 boto3 对接 MinIO。
对外函数名与返回值保持不变,业务代码无需修改。
"""
import base64
import io
import mimetypes
import os
import re
import tempfile
import threading
import time
import boto3
import requests
from botocore.config import Config
from config import (
region,
endpoint,
bucket,
file_url_pre,
bucket_path,
accessKeyId,
accessKeySecret,
)
from utils.ssrf import is_internal_url
_client = None
_client_lock = threading.Lock()
_REMOTE_IMAGE_MAX_BYTES = int(os.getenv("OSS_UPLOAD_IMAGE_MAX_BYTES", str(10 * 1024 * 1024)))
_REMOTE_IMAGE_TIMEOUT = (5, 30)
def get_client():
"""获取(懒加载)S3 客户端,MinIO 必须使用 path-style 寻址"""
global _client
if _client is None:
with _client_lock:
if _client is None:
_client = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=accessKeyId,
aws_secret_access_key=accessKeySecret,
region_name=region,
config=Config(
signature_version="s3v4",
s3={"addressing_style": "path"}, # MinIO 必须
retries={"max_attempts": 3, "mode": "standard"},
),
)
return _client
def _guess_content_type(key: str) -> str:
content_type, _ = mimetypes.guess_type(key)
return content_type or "application/octet-stream"
def get_presigned_url(key: str, expires: int = 7 * 24 * 3600) -> str:
"""生成临时访问链接(存储桶未开放匿名读时使用),默认有效 7 天"""
return get_client().generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key.lstrip("/")},
ExpiresIn=expires,
)
class _LimitedReader:
"""Keep a streamed upload from accepting an unexpectedly huge object."""
def __init__(self, source, max_bytes: int):
self._source = source
self._max_bytes = max_bytes
self._read_bytes = 0
def read(self, size=-1):
remaining = self._max_bytes - self._read_bytes
if remaining < 0:
raise ValueError("upload exceeds configured size limit")
read_size = remaining + 1 if size is None or size < 0 else min(size, remaining + 1)
data = self._source.read(read_size)
if not data:
return data
self._read_bytes += len(data)
if self._read_bytes > self._max_bytes:
raise ValueError("upload exceeds configured size limit")
return data
def seek(self, offset, whence=0):
position = self._source.seek(offset, whence)
self._read_bytes = max(0, position)
return position
def tell(self):
return self._source.tell()
def upload_fileobj(file_obj, key: str, max_bytes: int = 0, content_type: str = ""):
"""Stream a file-like object to S3/MinIO without materializing it."""
key = key.lstrip("/")
if isinstance(file_obj, (bytes, bytearray)):
file_obj = io.BytesIO(file_obj)
if not hasattr(file_obj, "read"):
raise TypeError("file_obj must be bytes or a readable file-like object")
stream_size = None
try:
file_obj.seek(0)
file_obj.seek(0, os.SEEK_END)
stream_size = file_obj.tell()
file_obj.seek(0)
except (AttributeError, OSError, TypeError, ValueError):
try:
file_obj.seek(0)
except (AttributeError, OSError, TypeError, ValueError):
pass
if max_bytes and max_bytes > 0 and stream_size is not None and stream_size > max_bytes:
raise ValueError("upload exceeds configured size limit")
client = get_client()
body = _LimitedReader(file_obj, max_bytes) if max_bytes and max_bytes > 0 else file_obj
client.upload_fileobj(
body,
bucket,
key,
ExtraArgs={"ContentType": content_type or _guess_content_type(key)},
)
return file_url_pre + key
def upload_file(file_content: bytes, key: str):
"""上传字节内容到 MinIO,返回可访问链接"""
return upload_fileobj(file_content, key)
def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str:
"""
将 base64 data URL 上传到对象存储,返回图片链接
data_url: data:image/png;base64,xxxx 或 data:image/jpeg;base64,xxxx
prefix: 对象 key 前缀
key_hint: 可选后缀避免重名,如 "_0", "_1"
"""
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
raise ValueError('无效的 data URL 格式')
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
encoded_payload = match.group(2)
if len(encoded_payload) > ((max(_REMOTE_IMAGE_MAX_BYTES, 0) + 2) // 3) * 4:
raise ValueError("image exceeds configured upload size limit")
file_content = base64.b64decode(encoded_payload, validate=True)
if len(file_content) > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
ts = int(time.time() * 1000)
key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}"
return upload_fileobj(io.BytesIO(file_content), key, _REMOTE_IMAGE_MAX_BYTES)
def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
"""批量上传图片;每张图完成上传后立即释放其缓冲区。"""
urls = []
ts = int(time.time() * 1000)
for i, data_url in enumerate(data_urls or []):
if not data_url or not isinstance(data_url, str):
continue
if not data_url.startswith("http"):
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
continue
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
encoded_payload = match.group(2)
if len(encoded_payload) > ((max(_REMOTE_IMAGE_MAX_BYTES, 0) + 2) // 3) * 4:
raise ValueError("image exceeds configured upload size limit")
file_content = base64.b64decode(encoded_payload, validate=True)
if len(file_content) > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
urls.append(upload_fileobj(io.BytesIO(file_content), key, _REMOTE_IMAGE_MAX_BYTES))
del file_content
continue
if is_internal_url(data_url):
raise ValueError("拒绝下载内网/本机地址的图片")
with requests.get(data_url, stream=True, timeout=_REMOTE_IMAGE_TIMEOUT) as response:
response.raise_for_status()
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
extension = mimetypes.guess_extension(content_type) or ".png"
ext = extension.lstrip(".") or "png"
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
with tempfile.SpooledTemporaryFile(max_size=2 * 1024 * 1024, mode="w+b") as image_file:
total = 0
for chunk in response.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
total += len(chunk)
if total > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
image_file.write(chunk)
image_file.seek(0)
urls.append(upload_fileobj(image_file, key, _REMOTE_IMAGE_MAX_BYTES, content_type))
return urls
# 脚本入口,当文件被直接运行时调用main函数
if __name__ == "__main__":
with open("D:\\pack\\nanri\\main.dist\\SHUFU.zip", "rb") as f:
file_content = f.read()
res = upload_file(file_content, key=bucket_path + "versions/1.0.46.zip")
print(res)