import { createHash, timingSafeEqual } from "node:crypto"; import { sql } from "drizzle-orm"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification"; import { db } from "@/lib/db"; import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries"; import { user } from "@/lib/db/schema"; import { generateDummyPassword } from "@/lib/db/utils"; import { DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL, EXTERNAL_IMAGE_API_CONFIG_KEY, normalizeExternalImageApiConfig, type ExternalImageApiAccessKey, } from "@/lib/external-image-api/config"; import { extractImageUrlsFromText } from "@/lib/image-url"; import { uploadFile } from "@/lib/storage/service"; type ImageModelEntry = Awaited>[number]; function normalizeText(value: unknown) { return String(value || "").trim(); } function mimeTypeToExtension(mimeType: string) { const normalized = mimeType.toLowerCase(); if (normalized.includes("png")) return "png"; if (normalized.includes("webp")) return "webp"; if (normalized.includes("gif")) return "gif"; if (normalized.includes("bmp")) return "bmp"; return "jpg"; } function getExtensionFromUrl(url: string): string { try { const pathname = new URL(url).pathname; const ext = pathname.split(".").pop()?.toLowerCase(); if (!ext) return "jpg"; if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].includes(ext)) { return ext; } } catch { // ignore } return "jpg"; } function safeTokenCompare(left: string, right: string) { const a = Buffer.from(left); const b = Buffer.from(right); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } function extractBearerToken(request: Request) { const authorization = normalizeText(request.headers.get("authorization")); if (/^bearer\s+/i.test(authorization)) { return authorization.replace(/^bearer\s+/i, "").trim(); } return normalizeText(request.headers.get("x-api-key")); } async function uploadRemoteImageToStorage(imageUrl: string): Promise { const response = await fetch(imageUrl, { method: "GET", signal: AbortSignal.timeout(30_000), }); if (!response.ok) { throw new Error( `Remote image fetch failed: ${response.status} ${response.statusText}`, ); } const contentType = response.headers.get("content-type")?.toLowerCase() || ""; if (contentType && !contentType.startsWith("image/")) { throw new Error(`Remote url is not an image: ${contentType}`); } const arrayBuffer = await response.arrayBuffer(); if (!arrayBuffer.byteLength) { throw new Error("Remote image content is empty"); } const ext = contentType ? mimeTypeToExtension(contentType) : getExtensionFromUrl(imageUrl); const fileName = `external-image-api-${Date.now()}-${Math.random() .toString(36) .slice(2, 8)}.${ext}`; const uploaded = await uploadFile(fileName, arrayBuffer); return uploaded.url; } async function uploadDataUrlImage(dataUrl: string): Promise { const match = dataUrl.match(/^data:([^;]+);base64,([\s\S]+)$/i); if (!match) { throw new Error("Invalid data URL image format"); } const mimeType = match[1] || "image/jpeg"; const base64Data = normalizeText(match[2]).replace(/\s+/g, ""); if (!base64Data) { throw new Error("Empty base64 image content"); } const buffer = Buffer.from(base64Data, "base64"); const fileName = `external-inline-${Date.now()}-${Math.random() .toString(36) .slice(2, 8)}.${mimeTypeToExtension(mimeType)}`; const arrayBuffer = buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength, ) as ArrayBuffer; const uploaded = await uploadFile(fileName, arrayBuffer); return uploaded.url; } async function ensureHostedImageUrl(imageUrl: string): Promise { const normalizedUrl = normalizeText(imageUrl); if (!normalizedUrl) { throw new Error("Image URL is empty"); } if (normalizedUrl.startsWith("data:image")) { return uploadDataUrlImage(normalizedUrl); } if (!/^https?:\/\//i.test(normalizedUrl)) { throw new Error(`Unsupported image URL: ${normalizedUrl}`); } if (/\/uploads\//i.test(normalizedUrl)) { return normalizedUrl; } return uploadRemoteImageToStorage(normalizedUrl); } export async function ensureHostedImageMarkdown( markdown: string, imageSize = "2K", ) { const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim; const matches = [...markdown.matchAll(pattern)]; if (!matches.length) { const plainUrls = extractImageUrlsFromText(markdown); if (!plainUrls.length) { return markdown; } const hostedUrls = await Promise.all( plainUrls.map((url) => ensureHostedImageUrl(url)), ); return hostedUrls .map((url) => `![thumb|||${url}|||${imageSize}](${url})`) .join("\n"); } let rebuilt = ""; let cursor = 0; for (const match of matches) { const raw = match[0]; const sourceUrl = match[2] || ""; const start = match.index ?? -1; if (start < 0) continue; rebuilt += markdown.slice(cursor, start); const hostedUrl = await ensureHostedImageUrl(sourceUrl); rebuilt += `![thumb|||${hostedUrl}|||${imageSize}](${hostedUrl})`; cursor = start + raw.length; } rebuilt += markdown.slice(cursor); return rebuilt; } export function extractGeneratedImagesFromMarkdown(markdown: string) { const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim; const matches = [...markdown.matchAll(pattern)]; return matches.map((match, index) => { const alt = match[1] || ""; const thumbnailUrl = match[2] || ""; let originalUrl = thumbnailUrl; let imageSize = "2K"; const metaMatch = alt.match(/thumb\|\|\|([^|]+)\|\|\|(.+)$/i); if (metaMatch) { originalUrl = normalizeText(metaMatch[1]) || thumbnailUrl; imageSize = normalizeText(metaMatch[2]) || imageSize; } return { id: index + 1, originalUrl, thumbnailUrl, imageSize, }; }); } export async function getExternalImageApiConfig() { const setting = await getSystemSetting({ key: EXTERNAL_IMAGE_API_CONFIG_KEY, }).catch(() => null); return normalizeExternalImageApiConfig(setting?.value); } export async function authenticateExternalImageApiRequest(request: Request) { const config = await getExternalImageApiConfig(); const token = extractBearerToken(request); if (!config.enabled) { return { config, accessKey: null, token, error: "第三方生图 API 未启用", status: 403 as const, }; } if (!token) { return { config, accessKey: null, token, error: "缺少 API Key", status: 401 as const, }; } const accessKey = config.apiKeys.find( (item) => item.enabled && safeTokenCompare(item.apiKey, token), ) || null; if (!accessKey) { return { config, accessKey, token, error: "API Key 无效或已禁用", status: 401 as const, }; } return { config, accessKey, token, error: null, status: 200 as const, }; } export async function getExternalImageApiModels() { const models = await getEnabledAiModelsWithProvider(false, false); return models.filter((entry) => isImageGenerationModelDescriptor({ providerType: entry.provider.type, modelId: entry.model.modelId, capabilities: entry.model.capabilities, supportsImageGen: entry.model.supportsImageGen, }), ); } export function serializeExternalImageApiModel(entry: ImageModelEntry) { return { id: entry.model.id, modelId: entry.model.modelId, displayName: entry.model.displayName, description: entry.model.description, providerType: entry.provider.type, providerName: entry.provider.name, avatarUrl: entry.model.avatarUrl, imageGenConfig: entry.model.imageGenConfig, }; } export async function resolveExternalImageApiModel(modelRef?: string | null) { const models = await getExternalImageApiModels(); if (!models.length) { throw new Error("当前没有启用可用于生图的模型"); } const normalizedRef = normalizeText(modelRef).toLowerCase(); if (!normalizedRef) { return models[0]; } const matched = models.find((entry) => { const candidates = [ entry.model.id, entry.model.modelId, entry.model.displayName, ] .map((item) => normalizeText(item).toLowerCase()) .filter(Boolean); return candidates.includes(normalizedRef); }); if (!matched) { throw new Error("未找到匹配的生图模型"); } return matched; } export async function resolveExternalImageApiOwnerUserId(ownerEmail?: string | null) { const normalizedEmail = normalizeText(ownerEmail).toLowerCase() || DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL; const existing = await db .select({ id: user.id }) .from(user) .where(sql`lower(${user.email}) = ${normalizedEmail}`) .limit(1); if (existing[0]?.id) { return existing[0].id; } const [created] = await db .insert(user) .values({ email: normalizedEmail, password: generateDummyPassword(), role: "guest", name: "第三方生图 API", isActive: true, }) .returning({ id: user.id }); if (!created?.id) { throw new Error("创建第三方 API 挂载用户失败"); } return created.id; } export async function generateImagesWithModel(params: { modelDbId: string; prompt: string; negativePrompt?: string | null; imageCount: number; aspectRatio?: string | null; imageSize?: string | null; }) { const languageModel = await getLanguageModelById(params.modelDbId); if (!languageModel || typeof (languageModel as any).doGenerate !== "function") { throw new Error("当前模型暂不支持第三方生图调用"); } const composedPrompt = [ normalizeText(params.prompt), normalizeText(params.negativePrompt) ? `Negative prompt:\n${normalizeText(params.negativePrompt)}` : "", ] .filter(Boolean) .join("\n\n"); const result = await (languageModel as any).doGenerate({ messages: [ { role: "user", content: [{ type: "text", text: composedPrompt }], }, ], imageParams: { imageCount: params.imageCount, aspectRatio: normalizeText(params.aspectRatio) || undefined, imageSize: normalizeText(params.imageSize) || undefined, }, }); if (result?.finishReason === "error") { throw new Error(result.text || "图片生成失败"); } const text = normalizeText(result?.text); if (!text) { throw new Error("模型未返回生成图片"); } return text; } export function buildExternalImageApiKeyFingerprint( accessKey: ExternalImageApiAccessKey | null, ) { if (!accessKey?.apiKey) return null; return createHash("sha256").update(accessKey.apiKey).digest("hex").slice(0, 16); } export function getRequesterIp(request: Request) { const forwarded = normalizeText(request.headers.get("x-forwarded-for")); if (forwarded) { return forwarded.split(",")[0]?.trim() || null; } return normalizeText(request.headers.get("x-real-ip")) || null; }