Files
gpt-image-panel/backend/app/integrations/nodeimage/client.py
T

222 lines
7.5 KiB
Python

"""Server-side NodeImage API client."""
from __future__ import annotations
import asyncio
import json
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import aiohttp
from ...core import secrets
from ...core.redaction import redact_sensitive_text
from ...core.validators import get_env_var_ref_name, resolve_env_var_ref
from ..session_pool import TIMEOUT_NODEIMAGE, get_pool
NODEIMAGE_API_URL = "https://api.nodeimage.com"
NODEIMAGE_UPLOAD_URL = f"{NODEIMAGE_API_URL}/api/upload"
NODEIMAGE_HOST_ALLOWLIST = "api.nodeimage.com"
MAX_NODEIMAGE_RESPONSE_BYTES = 2 * 1024 * 1024
RETRYABLE_CONNECT_ERRORS = (
aiohttp.ClientConnectorError,
aiohttp.ClientProxyConnectionError,
)
class NodeImageConfigurationError(ValueError):
pass
class NodeImageAuthError(RuntimeError):
pass
class NodeImageUploadError(RuntimeError):
pass
@dataclass(frozen=True)
class NodeImageEffectiveSettings:
enabled: bool
api_key: str
@dataclass(frozen=True)
class NodeImageUploadResult:
url: str
markdown: str
def _resolve_secret(value: Any) -> str:
raw = str(value or "").strip()
if not raw:
return ""
env_var = get_env_var_ref_name(raw)
if env_var:
resolved = resolve_env_var_ref(raw)
if resolved:
return resolved
raise NodeImageConfigurationError(
f"NodeImage API key environment variable {env_var} is not set or empty."
)
if raw not in secrets.configured_secret_ids():
return raw
try:
return secrets.resolve_secret(
raw,
purpose="nodeimage_api_key",
target_url=NODEIMAGE_API_URL,
host_allowlist=NODEIMAGE_HOST_ALLOWLIST,
)
except secrets.SecretRegistryError as exc:
raise NodeImageConfigurationError(f"NodeImage API key: {exc}") from exc
def resolve_nodeimage_settings(
settings: dict[str, Any] | None,
*,
require_enabled: bool = True,
) -> NodeImageEffectiveSettings:
raw = settings or {}
enabled = bool(raw.get("enabled", False))
if require_enabled and not enabled:
raise NodeImageConfigurationError("NodeImage upload is disabled.")
api_key = _resolve_secret(raw.get("api_key"))
if not api_key:
raise NodeImageConfigurationError("NodeImage API key is not configured.")
return NodeImageEffectiveSettings(enabled=enabled, api_key=api_key)
def _is_auth_error(message: str) -> bool:
normalized = message.lower()
return "unauthorized" in normalized or "invalid api key" in normalized
async def _read_response_json(response: aiohttp.ClientResponse) -> dict[str, Any]:
chunks: list[bytes] = []
total = 0
try:
async for chunk in response.content.iter_chunked(64 * 1024):
total += len(chunk)
if total > MAX_NODEIMAGE_RESPONSE_BYTES:
raise NodeImageUploadError("NodeImage response was too large.")
chunks.append(chunk)
except NodeImageUploadError:
raise
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise NodeImageUploadError("NodeImage returned an unreadable response.") from exc
try:
parsed = json.loads(b"".join(chunks).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise NodeImageUploadError("NodeImage returned an invalid JSON response.") from exc
if not isinstance(parsed, dict):
raise NodeImageUploadError("NodeImage returned an invalid response.")
return parsed
def _error_text(payload: dict[str, Any], status: int, api_key: str) -> str:
error = payload.get("error")
if isinstance(error, dict):
error = error.get("message") or error.get("detail")
message = str(
error or payload.get("message") or f"NodeImage returned HTTP {status}"
).strip()
if api_key:
message = message.replace(api_key, "[REDACTED]")
return redact_sensitive_text(message)[:500]
def _validate_direct_url(value: Any) -> str:
direct = str(value or "").strip()
try:
parsed = urlsplit(direct)
except ValueError as exc:
raise NodeImageUploadError(
"NodeImage returned an invalid direct link."
) from exc
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise NodeImageUploadError("NodeImage returned an invalid direct link.")
return direct
async def upload_image_bytes(
image_bytes: bytes,
filename: str,
effective: NodeImageEffectiveSettings,
) -> NodeImageUploadResult:
if not effective.enabled:
raise NodeImageConfigurationError("NodeImage upload is disabled.")
if not image_bytes:
raise NodeImageUploadError("Image file is empty.")
safe_filename = Path(str(filename or "image.png")).name or "image.png"
content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream"
headers = {"X-API-Key": effective.api_key}
for attempt in range(2):
form = aiohttp.FormData()
form.add_field(
"image",
image_bytes,
filename=safe_filename,
content_type=content_type,
)
try:
session = get_pool().get(timeout_kind=TIMEOUT_NODEIMAGE)
async with session.post(
NODEIMAGE_UPLOAD_URL,
data=form,
headers=headers,
allow_redirects=False,
) as response:
if response.status in {401, 403}:
raise NodeImageAuthError("NodeImage API key was rejected.")
try:
payload = await _read_response_json(response)
except NodeImageUploadError as exc:
if response.status >= 500 and attempt == 0:
await asyncio.sleep(0.2)
continue
raise
message = _error_text(payload, response.status, effective.api_key)
if response.status >= 500:
if attempt == 0:
await asyncio.sleep(0.2)
continue
raise NodeImageUploadError(message)
if _is_auth_error(message):
raise NodeImageAuthError("NodeImage API key was rejected.")
if response.status >= 400 or payload.get("success") is False:
raise NodeImageUploadError(message)
links = payload.get("links")
if not isinstance(links, dict):
raise NodeImageUploadError(
"NodeImage response did not include upload links."
)
direct = _validate_direct_url(links.get("direct"))
markdown = str(links.get("markdown") or "").strip()
if not markdown:
raise NodeImageUploadError(
"NodeImage response did not include upload links."
)
return NodeImageUploadResult(url=direct, markdown=markdown)
except NodeImageAuthError:
raise
except NodeImageUploadError:
raise
except RETRYABLE_CONNECT_ERRORS as exc:
if attempt == 0:
await asyncio.sleep(0.2)
continue
raise NodeImageUploadError("NodeImage upload request failed.") from exc
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
raise NodeImageUploadError("NodeImage upload request failed.") from exc
raise NodeImageUploadError("NodeImage upload request failed.")