131 lines
5.2 KiB
Python
131 lines
5.2 KiB
Python
"""私有 MinIO 对象访问;不签发公开 URL,不读取整个文件到内存。"""
|
|
import hashlib
|
|
import logging
|
|
from urllib.parse import quote
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException, UploadFile
|
|
from minio import Minio
|
|
from minio.error import S3Error
|
|
from starlette.background import BackgroundTask
|
|
from starlette.responses import StreamingResponse
|
|
from urllib3 import PoolManager, Timeout
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger('sqxp')
|
|
|
|
|
|
class PrivateStorage:
|
|
def __init__(self):
|
|
self.bucket = settings.DOCLIB_MINIO_BUCKET.strip()
|
|
if not settings.MINIO_ENDPOINT or not self.bucket or self.bucket == settings.MINIO_BUCKET:
|
|
raise HTTPException(503, '请配置文档库专用私有 MinIO 桶')
|
|
self.client = Minio(settings.MINIO_ENDPOINT, access_key=settings.MINIO_ACCESS_KEY,
|
|
secret_key=settings.MINIO_SECRET_KEY, secure=settings.MINIO_SECURE,
|
|
http_client=PoolManager(timeout=Timeout(connect=5, read=30), retries=1))
|
|
try:
|
|
if not self.client.bucket_exists(self.bucket):
|
|
try:
|
|
self.client.make_bucket(self.bucket)
|
|
except S3Error as exc:
|
|
if exc.code not in ('BucketAlreadyOwnedByYou', 'BucketAlreadyExists'):
|
|
raise
|
|
# 专用桶禁止任何匿名策略,不能因部署误配而让文档绕过 ACL。
|
|
try:
|
|
policy = self.client.get_bucket_policy(self.bucket)
|
|
except S3Error as exc:
|
|
if exc.code != 'NoSuchBucketPolicy':
|
|
raise
|
|
policy = None
|
|
if policy:
|
|
raise HTTPException(503, '文档库桶必须保持私有,请移除该专用桶的匿名访问策略')
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
logger.exception('doclib private bucket unavailable')
|
|
raise HTTPException(503, '文档存储服务暂不可用')
|
|
|
|
def upload(self, key, file, length):
|
|
self.client.put_object(self.bucket, key, file, length=length, content_type='application/octet-stream')
|
|
|
|
def open(self, key):
|
|
return self.client.get_object(self.bucket, key)
|
|
|
|
def discard(self, key):
|
|
# 仅用于尚未成功建立数据库记录的上传;业务删除保留原对象。
|
|
self.client.remove_object(self.bucket, key)
|
|
|
|
|
|
def private_storage():
|
|
return PrivateStorage()
|
|
|
|
|
|
def upload_document(service, file: UploadFile, folder_id: str):
|
|
name = (file.filename or '').replace('\\', '/').rsplit('/', 1)[-1].strip()
|
|
if not name or len(name) > 255 or any(ord(c) < 32 or ord(c) == 127 for c in name):
|
|
raise HTTPException(400, '文件名称无效或超过255个字符')
|
|
if len(folder_id) > 64:
|
|
raise HTTPException(400, '文件夹参数无效')
|
|
if folder_id:
|
|
service._folder(folder_id, own=True)
|
|
file.file.seek(0, 2)
|
|
length = file.file.tell()
|
|
file.file.seek(0)
|
|
if length > settings.DOCLIB_MAX_UPLOAD_BYTES:
|
|
raise HTTPException(413, f'单个文档不能超过 {settings.DOCLIB_MAX_UPLOAD_BYTES // 1024 // 1024} MB')
|
|
if length == 0:
|
|
raise HTTPException(400, '不能上传空文件')
|
|
storage = private_storage()
|
|
key = hashlib.sha256(service.tenant.encode()).hexdigest()[:20] + '/' + uuid4().hex
|
|
try:
|
|
storage.upload(key, file.file, length)
|
|
except Exception:
|
|
logger.exception('doclib upload failed')
|
|
try:
|
|
storage.discard(key)
|
|
except Exception:
|
|
logger.exception('doclib failed to discard ambiguous upload')
|
|
raise HTTPException(503, '文档上传失败,请稍后重试')
|
|
try:
|
|
return service.create_file(name=name, folder_id=folder_id, size=length,
|
|
content_type=(file.content_type or 'application/octet-stream')[:255], object_key=key)
|
|
except Exception:
|
|
service.db.rollback()
|
|
try:
|
|
storage.discard(key)
|
|
except Exception:
|
|
logger.exception('doclib failed to discard unregistered upload')
|
|
raise
|
|
|
|
|
|
def download_document(service, file_id):
|
|
row = service.download_record(file_id)
|
|
name, key, size = row.name, row.object_key, row.size
|
|
try:
|
|
response = private_storage().open(key)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
logger.exception('doclib download failed')
|
|
raise HTTPException(503, '文档暂时无法下载,请稍后重试')
|
|
closed = False
|
|
|
|
def close():
|
|
nonlocal closed
|
|
if not closed:
|
|
closed = True
|
|
response.close()
|
|
response.release_conn()
|
|
|
|
def chunks():
|
|
try:
|
|
yield from response.stream(amt=64 * 1024)
|
|
finally:
|
|
close()
|
|
|
|
return StreamingResponse(chunks(), media_type='application/octet-stream', background=BackgroundTask(close),
|
|
headers={'Content-Disposition': "attachment; filename*=UTF-8''" + quote(name, safe=''),
|
|
'Content-Length': str(size), 'Cache-Control': 'no-store',
|
|
'X-Content-Type-Options': 'nosniff'})
|