fix: render prompt museum media

This commit is contained in:
2026-08-18 12:32:34 +08:00
parent 11b19832a3
commit f8d82e6c19
9 changed files with 609 additions and 37 deletions

View File

@@ -14,6 +14,16 @@ 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;
@@ -22,11 +32,17 @@ type PromptMuseumRouteDependencies = {
};
function isMuseumPath(pathname: string): boolean {
return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname);
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))}`;
@@ -80,6 +96,12 @@ function httpsUrl(value: unknown): string {
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;
@@ -116,13 +138,43 @@ function boundedArray(value: unknown, maximum: number): unknown[] {
function projectImage(value: unknown): Record<string, unknown> {
const image = asRecord(value);
return {
url: httpsUrl(image.url),
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);
@@ -142,6 +194,7 @@ function projectAttribution(value: unknown): Record<string, unknown> {
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: {
@@ -150,7 +203,7 @@ function projectAttribution(value: unknown): Record<string, unknown> {
},
source: {
name: boundedString(source.name, 300),
url: httpsUrl(source.url),
...(sourceUrl === undefined ? {} : { url: sourceUrl }),
},
license: {
name: boundedString(license.name, 300),
@@ -310,6 +363,7 @@ export function createImagePromptMuseumRouteHandler(
}
try {
const isMediaRequest = LOCAL_MEDIA_PATH_PATTERN.test(url.pathname);
const token = await getAccessToken({ fetchImpl });
if (!token) {
sendJson(res, 401, {
@@ -326,7 +380,7 @@ export function createImagePromptMuseumRouteHandler(
{
method: 'GET',
headers: {
Accept: 'application/json',
Accept: isMediaRequest ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' : 'application/json',
Authorization: `Bearer ${accessToken}`,
},
redirect: 'manual',
@@ -344,12 +398,30 @@ export function createImagePromptMuseumRouteHandler(
response = await request(refreshed);
}
const payload = await readPayload(response);
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;