import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; const LOCAL_ROOT = '/api/works/image-prompt-museum'; const UPSTREAM_ROOT = '/api/image-prompt-museum'; const LIST_QUERY_KEYS = ['q', 'use_case', 'style', 'subject', 'language', 'model', 'cursor', 'limit'] as const; const MAX_QUERY_VALUE_LENGTH = 256; const MAX_LIST_ITEMS = 48; const MAX_FACET_ITEMS = 256; const MAX_CATEGORIES = 32; const MAX_IMAGES = 16; const MAX_VARIABLES = 64; const MAX_MEDIA_BYTES = 10 * 1024 * 1024; const MEDIA_URL_PATTERN = /^\/api\/image-prompt-museum\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/media\/(?:thumbnail|[0-9]+)$/; const LOCAL_MEDIA_PATH_PATTERN = /^\/api\/works\/image-prompt-museum\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/media\/(thumbnail|[0-9]+)$/; const TRUSTED_MEDIA_MIME_TYPES = new Set([ 'image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp', ]); type PromptMuseumRouteDependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; apiBaseUrl?: string; }; function isMuseumPath(pathname: string): boolean { return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname) || LOCAL_MEDIA_PATH_PATTERN.test(pathname); } function upstreamPath(pathname: string): string | null { if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT; const mediaMatch = LOCAL_MEDIA_PATH_PATTERN.exec(pathname); if (mediaMatch) { return `${UPSTREAM_ROOT}/${mediaMatch[1]}/media/${mediaMatch[2]}`; } const entryId = pathname.slice(`${LOCAL_ROOT}/`.length); if (!entryId) return null; return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`; } function copyAllowedQuery(source: URL): string { const query = new URLSearchParams(); for (const key of LIST_QUERY_KEYS) { const value = source.searchParams.get(key)?.trim(); if (!value || value.length > MAX_QUERY_VALUE_LENGTH) continue; query.set(key, value); } const encoded = query.toString(); return encoded ? `?${encoded}` : ''; } async function readPayload(response: Response): Promise { try { return await response.json(); } catch { return null; } } function asRecord(value: unknown): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { throw new Error('invalid Prompt Museum object'); } return value as Record; } function boundedString(value: unknown, maxLength = 4096): string { if (typeof value !== 'string') throw new Error('invalid Prompt Museum string'); const normalized = value.trim(); if (!normalized || normalized.length > maxLength) throw new Error('invalid Prompt Museum string'); return normalized; } function nullableString(value: unknown, maxLength = 4096): string | null | undefined { if (value === undefined) return undefined; if (value === null) return null; return boundedString(value, maxLength); } function httpsUrl(value: unknown): string { const raw = boundedString(value, 2048); const parsed = new URL(raw); if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { throw new Error('invalid Prompt Museum URL'); } return parsed.toString(); } function imageUrl(value: unknown): string { const raw = boundedString(value, 2048); if (MEDIA_URL_PATTERN.test(raw)) return raw; return httpsUrl(raw); } function nullableHttpsUrl(value: unknown): string | null | undefined { if (value === undefined) return undefined; if (value === null) return null; return httpsUrl(value); } function positiveInteger(value: unknown, maximum = 100_000): number { if (!Number.isInteger(value) || (value as number) <= 0 || (value as number) > maximum) { throw new Error('invalid Prompt Museum integer'); } return value as number; } function nonNegativeInteger(value: unknown): number { if (!Number.isSafeInteger(value) || (value as number) < 0) { throw new Error('invalid Prompt Museum total'); } return value as number; } function isoTimestamp(value: unknown): string { const timestamp = boundedString(value, 64); if (!Number.isFinite(Date.parse(timestamp))) throw new Error('invalid Prompt Museum timestamp'); return timestamp; } function boundedArray(value: unknown, maximum: number): unknown[] { if (!Array.isArray(value) || value.length > maximum) { throw new Error('invalid Prompt Museum array'); } return value; } function projectImage(value: unknown): Record { const image = asRecord(value); return { url: imageUrl(image.url), width: positiveInteger(image.width, 32_768), height: positiveInteger(image.height, 32_768), alt: boundedString(image.alt, 500), }; } function mediaMimeType(response: Response): string | null { const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null; } async function readBoundedMedia(response: Response): Promise { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) { await response.body?.cancel().catch(() => undefined); return null; } if (!response.body) return null; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_MEDIA_BYTES) { await reader.cancel().catch(() => undefined); return null; } chunks.push(value); } if (size === 0) return null; return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); } function projectCategory(value: unknown): Record { const category = asRecord(value); const group = boundedString(category.group, 32); if (!['use_case', 'style', 'subject'].includes(group)) { throw new Error('invalid Prompt Museum category'); } return { id: boundedString(category.id, 128), name: boundedString(category.name, 200), group, }; } function projectAttribution(value: unknown): Record { const attribution = asRecord(value); const author = asRecord(attribution.author); const source = asRecord(attribution.source); const license = asRecord(attribution.license); const authorUrl = nullableHttpsUrl(author.url); const sourceUrl = nullableHttpsUrl(source.url); const licenseUrl = nullableHttpsUrl(license.url); return { author: { name: boundedString(author.name, 300), ...(authorUrl === undefined ? {} : { url: authorUrl }), }, source: { name: boundedString(source.name, 300), ...(sourceUrl === undefined ? {} : { url: sourceUrl }), }, license: { name: boundedString(license.name, 300), ...(licenseUrl === undefined ? {} : { url: licenseUrl }), attributionText: boundedString(license.attributionText, 2000), }, }; } function projectCard(value: unknown): Record { const card = asRecord(value); const model = asRecord(card.model); return { id: boundedString(card.id, 128), slug: boundedString(card.slug, 200), title: boundedString(card.title, 300), summary: boundedString(card.summary, 2000), thumbnail: projectImage(card.thumbnail), categories: boundedArray(card.categories, MAX_CATEGORIES).map(projectCategory), model: { id: boundedString(model.id, 128), name: boundedString(model.name, 300), }, language: boundedString(card.language, 64), attribution: projectAttribution(card.attribution), publishedAt: isoTimestamp(card.publishedAt), updatedAt: isoTimestamp(card.updatedAt), }; } function projectFacet(value: unknown): Record { const facet = asRecord(value); const count = facet.count === undefined ? undefined : nonNegativeInteger(facet.count); return { id: boundedString(facet.id, 128), name: boundedString(facet.name, 200), ...(count === undefined ? {} : { count }), }; } function projectPage(payload: unknown): Record { const envelope = asRecord(payload); if (envelope.success !== true) throw new Error('invalid Prompt Museum envelope'); const page = asRecord(envelope.data); const facets = asRecord(page.facets); const nextCursor = nullableString(page.nextCursor, 1024); if (nextCursor === undefined) throw new Error('invalid Prompt Museum cursor'); const total = page.total === undefined ? undefined : nonNegativeInteger(page.total); return { success: true, data: { items: boundedArray(page.items, MAX_LIST_ITEMS).map(projectCard), facets: { useCases: boundedArray(facets.useCases, MAX_FACET_ITEMS).map(projectFacet), styles: boundedArray(facets.styles, MAX_FACET_ITEMS).map(projectFacet), subjects: boundedArray(facets.subjects, MAX_FACET_ITEMS).map(projectFacet), }, nextCursor, ...(total === undefined ? {} : { total }), }, }; } function projectEntry(payload: unknown): Record { const envelope = asRecord(payload); if (envelope.success !== true) throw new Error('invalid Prompt Museum envelope'); const entry = asRecord(envelope.data); const variables = boundedArray(entry.variables, MAX_VARIABLES).map((value) => { const variable = asRecord(value); const defaultValue = nullableString(variable.defaultValue, 20_000); if (typeof variable.required !== 'boolean') throw new Error('invalid Prompt Museum variable'); return { name: boundedString(variable.name, 128), label: boundedString(variable.label, 300), ...(defaultValue === undefined ? {} : { defaultValue }), required: variable.required, }; }); if (typeof entry.requiresReferenceImages !== 'boolean') { throw new Error('invalid Prompt Museum reference flag'); } return { success: true, data: { ...projectCard(entry), prompt: boundedString(entry.prompt, 100_000), variables, images: boundedArray(entry.images, MAX_IMAGES).map(projectImage), requiresReferenceImages: entry.requiresReferenceImages, }, }; } function safeError(status: number): { status: number; code: string; error: string } { if (status === 400) { return { status, code: 'PROMPT_MUSEUM_INVALID_QUERY', error: '筛选条件无效,请调整后重试' }; } if (status === 401) { return { status, code: 'PROMPT_MUSEUM_AUTH_REQUIRED', error: '请先登录后再获取灵感' }; } if (status === 404) { return { status, code: 'PROMPT_MUSEUM_NOT_FOUND', error: '提示词条目不存在' }; } const safeStatus = status >= 400 && status <= 599 ? status : 502; return { status: safeStatus, code: 'PROMPT_MUSEUM_UNAVAILABLE', error: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '提示词博物馆暂时不可用', }; } function sendSafeError(res: ServerResponse, status: number): void { const safe = safeError(status); sendJson(res, safe.status, { success: false, ...safe }); } function sendInvalidResponse(res: ServerResponse): void { sendJson(res, 502, { success: false, status: 502, code: 'PROMPT_MUSEUM_INVALID_RESPONSE', error: '提示词博物馆返回了无效数据', }); } export function createImagePromptMuseumRouteHandler( dependencies: PromptMuseumRouteDependencies = {}, ) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); return async function handleImagePromptMuseumRoutes( req: IncomingMessage, res: ServerResponse, url: URL, _ctx: HostApiContext, ): Promise { if (!isMuseumPath(url.pathname)) return false; if (req.method !== 'GET') { sendJson(res, 405, { success: false, code: 'PROMPT_MUSEUM_METHOD_NOT_ALLOWED', error: '提示词博物馆只支持读取', }); return true; } const path = upstreamPath(url.pathname); if (!path) { sendJson(res, 404, { success: false, code: 'PROMPT_MUSEUM_NOT_FOUND', error: '提示词条目不存在', }); return true; } try { const isMediaRequest = LOCAL_MEDIA_PATH_PATTERN.test(url.pathname); const token = await getAccessToken({ fetchImpl }); if (!token) { sendJson(res, 401, { success: false, status: 401, code: 'PROMPT_MUSEUM_AUTH_REQUIRED', error: '请先登录后再获取灵感', }); return true; } const request = (accessToken: string) => fetchImpl( `${apiBaseUrl}${path}${url.pathname === LOCAL_ROOT ? copyAllowedQuery(url) : ''}`, { method: 'GET', headers: { Accept: isMediaRequest ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' : 'application/json', Authorization: `Bearer ${accessToken}`, }, redirect: 'manual', }, ); let response = await request(token); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); if (!refreshed) { sendSafeError(res, 401); return true; } response = await request(refreshed); } if (!response.ok) { await response.body?.cancel().catch(() => undefined); sendSafeError(res, response.status); return true; } if (isMediaRequest) { const mimeType = mediaMimeType(response); if (!mimeType) { await response.body?.cancel().catch(() => undefined); sendInvalidResponse(res); return true; } const bytes = await readBoundedMedia(response); if (!bytes) { sendInvalidResponse(res); return true; } sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType }); return true; } const payload = await readPayload(response); if (payload === null) { sendInvalidResponse(res); return true; } try { sendJson( res, response.status, url.pathname === LOCAL_ROOT ? projectPage(payload) : projectEntry(payload), ); } catch { sendInvalidResponse(res); } return true; } catch { sendSafeError(res, 502); return true; } }; } export const handleImagePromptMuseumRoutes = createImagePromptMuseumRouteHandler();