diff --git a/apps/worker/src/tasks/url-metadata/utils/processImage.test.ts b/apps/worker/src/tasks/url-metadata/utils/processImage.test.ts new file mode 100644 index 00000000..488fc21f --- /dev/null +++ b/apps/worker/src/tasks/url-metadata/utils/processImage.test.ts @@ -0,0 +1,129 @@ +import crypto from "crypto"; +import { s3 } from "@playfulprogramming/s3"; +import { vi } from "vitest"; +import { mockEndpoint } from "../../../../test-utils/server.ts"; +import { processImages } from "./processImage.ts"; + +function md5(value: string): string { + return crypto.createHash("md5").update(value).digest("hex"); +} + +async function readStreamToString(readable: NodeJS.ReadableStream) { + const chunks: Buffer[] = []; + for await (const chunk of readable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +const onePixelPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR42mMAAQAABQABoIJXOQAAAABJRU5ErkJggg=="; + +test("decodes an inline percent-encoded SVG data URL without a network request", async () => { + // Matches the shape from issue #28: an SVG data URL whose payload has an + // unescaped `#`. The WHATWG URL parser splits that off into url.hash, so + // url.pathname alone silently loses everything after it - the fix has to + // parse from url.href instead. + const dataUrl = new URL( + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Ccircle fill='#ff0000' cx='5' cy='5' r='5'/%3E%3C/svg%3E", + ); + + // setup.ts's default s3.upload mock drains the file stream itself (to let + // sharp's resize pipeline settle), which leaves nothing to read back from + // mock.calls afterward - capture the content as it's uploaded instead. + let uploadedSvg = ""; + vi.mocked(s3.upload).mockImplementationOnce( + async (_bucket, _key, _tag, file) => { + uploadedSvg = await readStreamToString(file as NodeJS.ReadableStream); + }, + ); + + const result = await processImages( + [dataUrl], + 24, + "test-bucket", + "remote-icon", + "job-1", + new AbortController().signal, + ); + + const expectedKey = `remote-icon-${md5(dataUrl.href)}.svg`; + expect(result).toEqual({ key: expectedKey }); + + expect(s3.upload).toBeCalledTimes(1); + expect(s3.upload).toBeCalledWith( + "test-bucket", + expectedKey, + "job-1", + expect.anything(), + "image/svg+xml", + ); + + expect(uploadedSvg).toContain(" { + const dataUrl = new URL(`data:image/png;base64,${onePixelPngBase64}`); + + const result = await processImages( + [dataUrl], + 896, + "test-bucket", + "remote-banner", + "job-2", + new AbortController().signal, + ); + + const expectedKey = `remote-banner-${md5(dataUrl.href)}.png`; + expect(result).toEqual({ + key: expectedKey, + width: 1, + height: 1, + }); + + expect(s3.upload).toBeCalledWith( + "test-bucket", + expectedKey, + "job-2", + expect.anything(), + "image/png", + ); +}); + +test("still fetches and processes an http(s) image over the network", async () => { + const url = new URL("https://example.test/banner.png"); + mockEndpoint({ + path: url, + body: Uint8Array.from(Buffer.from(onePixelPngBase64, "base64")).buffer, + }); + + const result = await processImages( + [url], + 896, + "test-bucket", + "remote-banner", + "job-3", + new AbortController().signal, + ); + + const expectedKey = `remote-banner-${md5(url.href)}.png`; + expect(result).toEqual({ + key: expectedKey, + width: 1, + height: 1, + }); + + expect(s3.upload).toBeCalledWith( + "test-bucket", + expectedKey, + "job-3", + expect.anything(), + "image/png", + ); +}); diff --git a/apps/worker/src/tasks/url-metadata/utils/processImage.ts b/apps/worker/src/tasks/url-metadata/utils/processImage.ts index 21dca95a..bfd88350 100644 --- a/apps/worker/src/tasks/url-metadata/utils/processImage.ts +++ b/apps/worker/src/tasks/url-metadata/utils/processImage.ts @@ -7,7 +7,6 @@ import { env } from "@playfulprogramming/common"; import { s3 } from "@playfulprogramming/s3"; import { fetchAsBot } from "../../../utils/fetchAsBot.ts"; import { setTimeout } from "timers/promises"; -import type { Dispatcher } from "undici"; export interface ProcessImageResult { key: string; @@ -15,46 +14,187 @@ export interface ProcessImageResult { height?: number; } +interface ReadImageResult { + body: stream.Readable; + format: string; + width?: number; + height?: number; + // undefined for sources with no HTTP resource to compare a last-modified + // header against (e.g. data: URLs) - always upload in that case + lastModified?: Date; +} + async function compareLastModified( - request: Dispatcher.ResponseData, + lastModified: Date, bucket: string, key: string, signal?: AbortSignal, ): Promise { - // If there is a last-modified header, compare it to the header from S3 - const lastModified = request.headers["last-modified"]?.toString(); - if (lastModified) { - const existingFile = await fetchAsBot({ - url: new URL(`${bucket}/${key}`, env.S3_PUBLIC_URL), - method: "HEAD", - skipRobotsCheck: true, - signal, - }).catch(() => undefined); - - if (existingFile && existingFile.statusCode == 200) { - const modS3 = existingFile.headers["last-modified"]?.toString(); - const modExternal = lastModified; - - if (modS3 && modExternal) { - return new Date(modS3) > new Date(modExternal); - } else { - console.error("File exists in S3, but has no last-modified header."); - return false; - } + const existingFile = await fetchAsBot({ + url: new URL(`${bucket}/${key}`, env.S3_PUBLIC_URL), + method: "HEAD", + skipRobotsCheck: true, + signal, + }).catch(() => undefined); + + if (existingFile && existingFile.statusCode == 200) { + const modS3 = existingFile.headers["last-modified"]?.toString(); + + if (modS3) { + return new Date(modS3) > lastModified; + } else { + console.error("File exists in S3, but has no last-modified header."); + return false; } } return false; } -async function processImage( - url: URL, +async function readStreamToBuffer(readable: stream.Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of readable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +async function uploadSvg( + body: stream.Readable, + bucket: string, + uploadKey: string, + tag: string | undefined, +): Promise { + const svg = (await readStreamToBuffer(body)).toString("utf-8"); + const optimizedSvg = svgo.optimize(svg, { multipass: true }).data; + await s3.upload( + bucket, + uploadKey, + tag, + stream.Readable.from([optimizedSvg]), + "image/svg+xml", + ); + return { key: uploadKey }; +} + +function computeRasterDimensions( + image: Pick, width: number, +): { width: number; height?: number } { + const transformWidth = Math.min(width, image.width || width); + const transformHeight = + image.height && image.width + ? Math.round(image.height * (transformWidth / image.width)) + : undefined; + + return { width: transformWidth, height: transformHeight }; +} + +async function uploadRasterImage( + image: ReadImageResult, + dimensions: { width: number; height?: number }, bucket: string, - key: string, - tag?: string, - signal?: AbortSignal, -): Promise { + uploadKey: string, + tag: string | undefined, +): Promise { + const transformer = sharp().resize(dimensions.width); + const transformerStream = image.body.pipe(transformer); + + await s3.upload( + bucket, + uploadKey, + tag, + transformerStream, + `image/${image.format}`, + ); + + return { + key: uploadKey, + width: dimensions.width, + height: dimensions.height, + }; +} + +interface ParsedDataUrl { + mediaType: string; + isBase64: boolean; + payload: string; +} + +// The WHATWG URL parser treats `data:` as an opaque-path scheme, so an +// unescaped `#` in the payload gets split off into url.hash and url.pathname +// alone silently loses everything after it. url.href keeps the full string +// intact, so parse the data:[;base64], shape from there. +function parseDataUrl(url: URL): ParsedDataUrl | undefined { + const href = url.href; + const commaIndex = href.indexOf(","); + if (!href.startsWith("data:") || commaIndex === -1) { + return undefined; + } + + const meta = href.slice("data:".length, commaIndex); + const payload = href.slice(commaIndex + 1); + const isBase64 = /;base64$/i.test(meta); + const mediaType = + (isBase64 ? meta.slice(0, -";base64".length) : meta) || + "text/plain;charset=US-ASCII"; + + return { mediaType, isBase64, payload }; +} + +async function readRasterMetadata( + body: stream.Readable, + url: URL, +): Promise { + const pipeline = sharp(); + const metadataStream = body.pipe(pipeline); + const metadata = await Promise.race([ + setTimeout(10 * 1000).then(() => undefined), + pipeline.metadata().catch(() => undefined), + ]); + + if (!metadata || !metadata.format) { + console.error(`Image format for ${url} could not be found.`); + return undefined; + } + + return { + body: metadataStream, + format: metadata.format, + width: metadata.width, + height: metadata.height, + }; +} + +async function readDataUrlImage( + url: URL, +): Promise { + const parsed = parseDataUrl(url); + if (!parsed) { + console.error(`Unable to parse data URL ${url}`); + return undefined; + } + + const isSvg = parsed.mediaType.includes("image/svg"); + if (isSvg) { + const svg = parsed.isBase64 + ? Buffer.from(parsed.payload, "base64").toString("utf-8") + : decodeURIComponent(parsed.payload); + + return { body: stream.Readable.from([svg]), format: "svg" }; + } + + const buffer = parsed.isBase64 + ? Buffer.from(parsed.payload, "base64") + : Buffer.from(decodeURIComponent(parsed.payload), "utf-8"); + + return readRasterMetadata(stream.Readable.from(buffer), url); +} + +async function readFetchedImage( + url: URL, + signal: AbortSignal | undefined, +): Promise { const request = await fetchAsBot({ url, method: "GET", signal }).catch( (e) => { console.error(`Error fetching ${url}`, e); @@ -70,7 +210,10 @@ async function processImage( return undefined; } - const urlHash = crypto.createHash("md5").update(url.href).digest("hex"); + const lastModifiedHeader = request.headers["last-modified"]?.toString(); + const lastModified = lastModifiedHeader + ? new Date(lastModifiedHeader) + : undefined; const isSvg = request.headers["content-type"]?.includes("image/svg") || @@ -78,70 +221,66 @@ async function processImage( path.extname(url.pathname) === ".svg"); if (isSvg) { - const uploadKey = `${key}-${urlHash}.svg`; + return { body, format: "svg", lastModified }; + } - if (await compareLastModified(request, bucket, uploadKey, signal)) { - console.log(`Skipping ${uploadKey}, as it has already been stored.`); - await body.dump(); - } else { - const svg = await body.text(); - const optimizedSvg = svgo.optimize(svg, { multipass: true }).data; - await s3.upload( - bucket, - uploadKey, - tag, - stream.Readable.from([optimizedSvg]), - "image/svg+xml", - ); - } + const raster = await readRasterMetadata(body, url); + if (!raster) { + return undefined; + } - return { key: uploadKey }; + return { ...raster, lastModified }; +} + +async function readImage( + url: URL, + signal: AbortSignal | undefined, +): Promise { + if (url.protocol === "data:") { + return readDataUrlImage(url); } - const pipeline = sharp(); - const metadataStream = body.pipe(pipeline); - const metadata = await Promise.race([ - setTimeout(10 * 1000).then(() => undefined), - pipeline.metadata().catch(() => undefined), - ]); + return readFetchedImage(url, signal); +} - if (!metadata || !metadata.format) { - console.error(`Image format for ${url} could not be found.`); - await body.dump(); +async function processImage( + url: URL, + width: number, + bucket: string, + key: string, + tag?: string, + signal?: AbortSignal, +): Promise { + const image = await readImage(url, signal); + if (!image) { return undefined; } - const uploadKey = `${key}-${urlHash}.${metadata.format}`; + const urlHash = crypto.createHash("md5").update(url.href).digest("hex"); + const uploadKey = `${key}-${urlHash}.${image.format}`; - const transformWidth = Math.min(width, metadata.width || width); - const transformHeight = - metadata.height && metadata.width - ? Math.round(metadata.height * (transformWidth / metadata.width)) - : undefined; + const alreadyStored = + image.lastModified !== undefined && + (await compareLastModified(image.lastModified, bucket, uploadKey, signal)); - if (await compareLastModified(request, bucket, uploadKey, signal)) { + if (alreadyStored) { console.log(`Skipping ${uploadKey}, as it has already been stored.`); - metadataStream.destroy(); - } else { - const transformer = sharp().resize(transformWidth); - const transformerStream = metadataStream.pipe(transformer); - - await s3.upload( - bucket, - uploadKey, - tag, - transformerStream, - `image/${metadata.format}`, - ); } - await body.dump(); + if (image.format === "svg") { + if (alreadyStored) { + image.body.destroy(); + return { key: uploadKey }; + } + return uploadSvg(image.body, bucket, uploadKey, tag); + } - return { - key: uploadKey, - width: transformWidth, - height: transformHeight, - }; + const dimensions = computeRasterDimensions(image, width); + if (alreadyStored) { + image.body.destroy(); + return { key: uploadKey, ...dimensions }; + } + return uploadRasterImage(image, dimensions, bucket, uploadKey, tag); } export async function processImages(