Files

455 lines
18 KiB
Python

"""
Upload service.
Upload service for assets.grok.com.
"""
import base64
import hashlib
import io
import mimetypes
import re
from pathlib import Path
from typing import AsyncIterator, Optional, Tuple
from urllib.parse import urlparse
import aiofiles
from curl_cffi.requests import AsyncSession
from app.core.config import get_config
from app.core.exceptions import AppException, UpstreamException, ValidationException
from app.core.logger import logger
from app.core.storage import DATA_DIR
from app.services.reverse.assets_upload import AssetsUploadReverse
from app.services.grok.utils.locks import _get_upload_semaphore, _file_lock
class UploadService:
"""Assets upload service."""
def __init__(self):
self._session: Optional[AsyncSession] = None
self._chunk_size = 64 * 1024
async def create(self) -> AsyncSession:
"""Create or reuse a session."""
if self._session is None:
self._session = AsyncSession()
return self._session
async def close(self):
"""Close the session."""
if self._session:
await self._session.close()
self._session = None
@staticmethod
def _normalize_image_to_jpeg(filename: str, b64: str, mime: str) -> Tuple[str, str, str]:
"""所有图片统一净化并重编码为 JPEG,消除源图指纹差异。"""
safe_mime = str(mime or "").lower().strip()
if not safe_mime.startswith("image/"):
return filename, b64, mime
try:
from PIL import Image, ImageOps
except Exception as e:
raise ValidationException(f"Pillow is required for image conversion: {e}")
try:
raw = base64.b64decode(re.sub(r"\s+", "", b64), validate=True)
except Exception:
raise ValidationException("Invalid image base64 content")
try:
with Image.open(io.BytesIO(raw)) as img:
img = ImageOps.exif_transpose(img)
width, height = img.size
if img.mode in ("RGBA", "LA"):
bg = Image.new("RGB", img.size, (255, 255, 255))
alpha = img.split()[-1]
bg.paste(img.convert("RGBA"), mask=alpha)
out_img = bg
else:
out_img = img.convert("RGB")
# 先做一次无元数据净化,避免源图中异常 profile/块影响上游解析
sanitized = Image.new("RGB", out_img.size)
sanitized.paste(out_img)
out = io.BytesIO()
# 使用更保守的 baseline JPEG 参数,减少兼容性问题
sanitized.save(
out,
format="JPEG",
quality=92,
optimize=False,
progressive=False,
subsampling=2,
)
jpeg_b64 = base64.b64encode(out.getvalue()).decode()
base_name = (filename or "file").rsplit(".", 1)[0]
jpeg_name = f"{base_name}.jpg"
logger.info(
"Upload image normalized to JPEG: "
f"from={safe_mime}, src_name={filename}, dst_name={jpeg_name}, "
f"size={width}x{height}, out_len={len(jpeg_b64)}, profile=baseline-jpeg"
)
return jpeg_name, jpeg_b64, "image/jpeg"
except Exception as e:
raise ValidationException(f"Image conversion to JPEG failed: {e}")
@staticmethod
def _reencode_jpeg_with_profile(
b64: str, *, quality: int, optimize: bool, progressive: bool, subsampling: int
) -> str:
"""按指定 profile 重编码 JPEG,用于上游 400/403 兜底。"""
try:
from PIL import Image, ImageOps
except Exception as e:
raise ValidationException(f"Pillow is required for image conversion: {e}")
try:
raw = base64.b64decode(re.sub(r"\s+", "", b64), validate=True)
except Exception:
raise ValidationException("Invalid image base64 content")
try:
with Image.open(io.BytesIO(raw)) as img:
img = ImageOps.exif_transpose(img).convert("RGB")
out = io.BytesIO()
img.save(
out,
format="JPEG",
quality=max(1, min(quality, 100)),
optimize=optimize,
progressive=progressive,
subsampling=subsampling,
)
return base64.b64encode(out.getvalue()).decode()
except Exception as e:
raise ValidationException(f"Image conversion to JPEG failed: {e}")
@staticmethod
def _inspect_image_payload(b64: str) -> Tuple[int, int, int]:
"""解析图片负载,返回 (宽, 高, 字节大小)。"""
try:
from PIL import Image
except Exception as e:
raise ValidationException(f"Pillow is required for image inspection: {e}")
try:
raw = base64.b64decode(re.sub(r"\s+", "", b64), validate=True)
except Exception:
raise ValidationException("Invalid image base64 content")
try:
with Image.open(io.BytesIO(raw)) as img:
width, height = img.size
return width, height, len(raw)
except Exception as e:
raise ValidationException(f"Image inspect failed: {e}")
@staticmethod
def _extract_upstream_rejection_hint(exc: Exception) -> str:
text_parts = [str(exc or "")]
details = getattr(exc, "details", None)
if isinstance(details, dict):
text_parts.extend(
[
str(details.get("hint") or ""),
str(details.get("body") or ""),
str(details.get("error") or ""),
]
)
merged = " | ".join(part for part in text_parts if part).lower()
if "content is moderated" in merged or "content-moderated" in merged:
return "Content is moderated [WKE=file:content-moderated]"
if "just a moment" in merged or "cf-challenge" in merged or "cloudflare" in merged:
return "Cloudflare challenge page"
return ""
@staticmethod
def _is_url(value: str) -> bool:
"""Check if the value is a URL."""
try:
parsed = urlparse(value)
return bool(
parsed.scheme and parsed.netloc and parsed.scheme in ["http", "https"]
)
except Exception:
return False
@staticmethod
def _infer_mime(filename: str, fallback: str = "application/octet-stream") -> str:
mime, _ = mimetypes.guess_type(filename)
return mime or fallback
@staticmethod
async def _encode_b64_stream(chunks: AsyncIterator[bytes]) -> str:
parts = []
remain = b""
async for chunk in chunks:
if not chunk:
continue
chunk = remain + chunk
keep = len(chunk) % 3
if keep:
remain = chunk[-keep:]
chunk = chunk[:-keep]
else:
remain = b""
if chunk:
parts.append(base64.b64encode(chunk).decode())
if remain:
parts.append(base64.b64encode(remain).decode())
return "".join(parts)
async def _read_local_file(self, local_type: str, name: str) -> Tuple[str, str, str]:
base_dir = DATA_DIR / "tmp"
if local_type == "video":
local_dir = base_dir / "video"
mime = "video/mp4"
else:
local_dir = base_dir / "image"
suffix = Path(name).suffix.lower()
if suffix == ".png":
mime = "image/png"
elif suffix == ".webp":
mime = "image/webp"
elif suffix == ".gif":
mime = "image/gif"
else:
mime = "image/jpeg"
local_path = local_dir / name
lock_name = f"ul_local_{hashlib.sha1(str(local_path).encode()).hexdigest()[:16]}"
lock_timeout = max(1, int(get_config("asset.upload_timeout")))
async with _file_lock(lock_name, timeout=lock_timeout):
if not local_path.exists():
raise ValidationException(f"Local file not found: {local_path}")
if not local_path.is_file():
raise ValidationException(f"Invalid local file: {local_path}")
async def _iter_file() -> AsyncIterator[bytes]:
async with aiofiles.open(local_path, "rb") as f:
while True:
chunk = await f.read(self._chunk_size)
if not chunk:
break
yield chunk
b64 = await self._encode_b64_stream(_iter_file())
filename = name or "file"
return filename, b64, mime
async def parse_b64(self, url: str) -> Tuple[str, str, str]:
"""Fetch URL content and return (filename, base64, mime)."""
try:
app_url = get_config("app.app_url") or ""
if app_url and self._is_url(url):
parsed = urlparse(url)
app_parsed = urlparse(app_url)
if (
parsed.scheme == app_parsed.scheme
and parsed.netloc == app_parsed.netloc
and parsed.path.startswith("/v1/files/")
):
parts = parsed.path.strip("/").split("/", 3)
if len(parts) >= 4:
local_type = parts[2]
name = parts[3].replace("/", "-")
return await self._read_local_file(local_type, name)
lock_name = f"ul_url_{hashlib.sha1(url.encode()).hexdigest()[:16]}"
timeout = float(get_config("asset.upload_timeout"))
proxy_url = get_config("proxy.base_proxy_url")
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
lock_timeout = max(1, int(get_config("asset.upload_timeout")))
async with _file_lock(lock_name, timeout=lock_timeout):
session = await self.create()
response = await session.get(
url,
timeout=timeout,
proxies=proxies,
stream=True,
)
if response.status_code >= 400:
raise UpstreamException(
message=f"Failed to fetch: {response.status_code}",
details={"url": url, "status": response.status_code},
)
filename = url.split("/")[-1].split("?")[0] or "download"
content_type = response.headers.get(
"content-type", ""
).split(";")[0].strip()
if not content_type:
content_type = self._infer_mime(filename)
if hasattr(response, "aiter_content"):
try:
b64 = await self._encode_b64_stream(response.aiter_content())
except Exception:
# 某些响应对象虽存在 aiter_content,但底层未进入 stream 模式;回退读取 content。
b64 = base64.b64encode(response.content).decode()
else:
b64 = base64.b64encode(response.content).decode()
logger.debug(f"Fetched: {url}")
return filename, b64, content_type
except Exception as e:
if isinstance(e, AppException):
raise
logger.error("Fetch failed: {} - {}", url, str(e))
raise UpstreamException(f"Fetch failed: {str(e)}", details={"url": url})
@staticmethod
def format_b64(data_uri: str) -> Tuple[str, str, str]:
"""Format data URI to (filename, base64, mime)."""
if not data_uri.startswith("data:"):
raise ValidationException("Invalid file input: not a data URI")
try:
header, b64 = data_uri.split(",", 1)
except ValueError:
raise ValidationException("Invalid data URI format")
if ";base64" not in header:
raise ValidationException("Invalid data URI: missing base64 marker")
mime = header[5:].split(";", 1)[0] or "application/octet-stream"
b64 = re.sub(r"\s+", "", b64)
if not mime or not b64:
raise ValidationException("Invalid data URI: empty content")
ext = mime.split("/")[-1] if "/" in mime else "bin"
return f"file.{ext}", b64, mime
async def check_format(self, file_input: str) -> Tuple[str, str, str]:
"""Check file input format and return (filename, base64, mime)."""
if not isinstance(file_input, str) or not file_input.strip():
raise ValidationException("Invalid file input: empty content")
if self._is_url(file_input):
return await self.parse_b64(file_input)
if file_input.startswith("data:"):
return self.format_b64(file_input)
raise ValidationException("Invalid file input: must be URL or base64")
async def upload_file(self, file_input: str, token: str) -> Tuple[str, str]:
"""
Upload file to Grok.
Args:
file_input: str, the file input.
token: str, the SSO token.
Returns:
Tuple[str, str]: The file ID and URI.
"""
async with _get_upload_semaphore():
filename, b64, mime = await self.check_format(file_input)
filename, b64, mime = self._normalize_image_to_jpeg(filename, b64, mime)
logger.debug(
f"Upload prepare: filename={filename}, type={mime}, size={len(b64)}"
)
if not b64:
raise ValidationException("Invalid file input: empty content")
if str(mime).lower().startswith("image/"):
width, height, raw_size = self._inspect_image_payload(b64)
logger.info(
"Upload image stats before request: "
f"filename={filename}, mime={mime}, resolution={width}x{height}, "
f"pixels={width * height}, bytes={raw_size}, b64_len={len(b64)}"
)
session = await self.create()
try:
response = await AssetsUploadReverse.request(
session,
token,
filename,
mime,
b64,
)
result = response.json()
file_id = result.get("fileMetadataId", "")
file_uri = result.get("fileUri", "")
logger.info(f"Upload success: {filename} -> {file_id}")
return file_id, file_uri
except UpstreamException as e:
status = None
if e.details and "status" in e.details:
status = e.details.get("status")
body = (e.details or {}).get("body", "")
hint = (e.details or {}).get("hint", "")
normalized_hint = self._extract_upstream_rejection_hint(e) or hint
logger.warning(
"Upload image upstream rejected: "
"filename={}, status={}, hint={}, body={}",
filename,
status,
normalized_hint or "-",
body or "-",
)
if normalized_hint == "Content is moderated [WKE=file:content-moderated]":
raise ValidationException(
message="图片内容触发审核限制,无法上传。请更换图片后重试。",
param="image",
code="content_moderated",
)
if mime != "image/jpeg" or status not in (400, 403):
raise
# 部分图片会被上游以 400/403 拒绝,切换 JPEG profile 再试一次。
fallback_profiles = [
{
"quality": 98,
"optimize": False,
"progressive": False,
"subsampling": 0,
},
{
"quality": 95,
"optimize": False,
"progressive": False,
"subsampling": 2,
},
]
last_error = e
for idx, profile in enumerate(fallback_profiles, start=1):
try:
b64_retry = self._reencode_jpeg_with_profile(b64, **profile)
logger.warning(
"Upload image fallback re-encode retry: "
"attempt={}, status={}, profile={}, out_len={}",
idx,
status,
profile,
len(b64_retry),
)
response = await AssetsUploadReverse.request(
session,
token,
filename,
mime,
b64_retry,
)
result = response.json()
file_id = result.get("fileMetadataId", "")
file_uri = result.get("fileUri", "")
logger.info(
f"Upload success after fallback retry: {filename} -> {file_id}"
)
return file_id, file_uri
except UpstreamException as retry_error:
last_error = retry_error
continue
raise last_error
__all__ = ["UploadService"]