Files
makelore/electron/api/routes/image-prompt-museum.ts

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();