447 lines
15 KiB
TypeScript
447 lines
15 KiB
TypeScript
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<unknown> {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
throw new Error('invalid Prompt Museum object');
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown> {
|
|
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<Buffer | null> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<boolean> {
|
|
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();
|