fix: render prompt museum media
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user