From 0fafe0ca73e17f0aa185a9e869b6f58c34e19b0e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 22:54:00 -0700 Subject: [PATCH 1/3] chore(deps): drop the archived image-size dependency --- apps/sim/lib/content/image-dimensions.test.ts | 155 ++++++++++++++++++ apps/sim/lib/content/image-dimensions.ts | 115 +++++++++++++ apps/sim/lib/content/registry-factory.ts | 10 +- apps/sim/package.json | 1 - bun.lock | 6 +- 5 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 apps/sim/lib/content/image-dimensions.test.ts create mode 100644 apps/sim/lib/content/image-dimensions.ts diff --git a/apps/sim/lib/content/image-dimensions.test.ts b/apps/sim/lib/content/image-dimensions.test.ts new file mode 100644 index 00000000000..7e9a4e0d38e --- /dev/null +++ b/apps/sim/lib/content/image-dimensions.test.ts @@ -0,0 +1,155 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { readImageDimensions } from '@/lib/content/image-dimensions' + +function png(width: number, height: number): Buffer { + const buffer = Buffer.alloc(24) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer) + buffer.writeUInt32BE(13, 8) + buffer.write('IHDR', 12, 'latin1') + buffer.writeUInt32BE(width, 16) + buffer.writeUInt32BE(height, 20) + return buffer +} + +/** Builds a JPEG whose SOF0 frame is preceded by `filler` app segments. */ +function jpeg(width: number, height: number, filler: Buffer = Buffer.alloc(0)): Buffer { + const sof = Buffer.alloc(11) + sof.writeUInt16BE(0xffc0, 0) + sof.writeUInt16BE(8, 2) + sof.writeUInt8(8, 4) + sof.writeUInt16BE(height, 5) + sof.writeUInt16BE(width, 7) + return Buffer.concat([Buffer.from([0xff, 0xd8]), filler, sof]) +} + +function webpVp8x(width: number, height: number): Buffer { + const buffer = Buffer.alloc(30) + buffer.write('RIFF', 0, 'latin1') + buffer.write('WEBP', 8, 'latin1') + buffer.write('VP8X', 12, 'latin1') + buffer.writeUInt32LE(10, 16) + buffer.writeUIntLE(width - 1, 24, 3) + buffer.writeUIntLE(height - 1, 27, 3) + return buffer +} + +function webpVp8l(width: number, height: number): Buffer { + const buffer = Buffer.alloc(25) + buffer.write('RIFF', 0, 'latin1') + buffer.write('WEBP', 8, 'latin1') + buffer.write('VP8L', 12, 'latin1') + buffer.writeUInt8(0x2f, 20) + buffer.writeUInt32LE(((height - 1) << 14) | (width - 1), 21) + return buffer +} + +function webpVp8(width: number, height: number): Buffer { + const buffer = Buffer.alloc(30) + buffer.write('RIFF', 0, 'latin1') + buffer.write('WEBP', 8, 'latin1') + buffer.write('VP8 ', 12, 'latin1') + Buffer.from([0x9d, 0x01, 0x2a]).copy(buffer, 23) + buffer.writeUInt16LE(width, 26) + buffer.writeUInt16LE(height, 28) + return buffer +} + +describe('readImageDimensions', () => { + it('reads PNG dimensions from IHDR', () => { + expect(readImageDimensions(png(1200, 630))).toEqual({ width: 1200, height: 630 }) + }) + + it('reads JPEG dimensions from the SOF0 frame', () => { + expect(readImageDimensions(jpeg(1920, 1080))).toEqual({ width: 1920, height: 1080 }) + }) + + it('skips JPEG app segments before the frame', () => { + const app0 = Buffer.alloc(18) + app0.writeUInt16BE(0xffe0, 0) + app0.writeUInt16BE(16, 2) + app0.write('JFIF\0', 4, 'latin1') + expect(readImageDimensions(jpeg(800, 400, app0))).toEqual({ width: 800, height: 400 }) + }) + + it('tolerates JPEG marker padding bytes', () => { + expect(readImageDimensions(jpeg(640, 480, Buffer.from([0xff, 0xff, 0xff])))).toEqual({ + width: 640, + height: 480, + }) + }) + + it('reads extended WebP canvas dimensions', () => { + expect(readImageDimensions(webpVp8x(2400, 1260))).toEqual({ width: 2400, height: 1260 }) + }) + + it('reads lossless WebP dimensions', () => { + expect(readImageDimensions(webpVp8l(1024, 768))).toEqual({ width: 1024, height: 768 }) + }) + + it('reads lossy WebP dimensions', () => { + expect(readImageDimensions(webpVp8(512, 256))).toEqual({ width: 512, height: 256 }) + }) + + it('returns null for an unrecognized format', () => { + expect(readImageDimensions(Buffer.from('not an image at all, really'))).toBeNull() + }) + + it('returns null for a truncated buffer', () => { + expect(readImageDimensions(png(100, 100).subarray(0, 20))).toBeNull() + }) + + it('returns null when a header declares zero dimensions', () => { + expect(readImageDimensions(png(0, 0))).toBeNull() + expect(readImageDimensions(jpeg(0, 0))).toBeNull() + }) + + /** + * The `image-size` advisories this parser replaces (GHSA-w3rx-r6r6-pgpr, + * GHSA-5p2g-fcmc-qvqq) were zero-valued length fields that left the read + * offset unchanged, hanging the event loop. Each case below must terminate. + */ + describe('malformed-length denial-of-service inputs', () => { + it('terminates on a JPEG segment declaring zero length', () => { + const buffer = Buffer.alloc(64) + buffer.writeUInt16BE(0xffd8, 0) + buffer.writeUInt16BE(0xffe0, 2) + buffer.writeUInt16BE(0, 4) + expect(readImageDimensions(buffer)).toBeNull() + }) + + it('terminates on a JPEG segment declaring a length of one', () => { + const buffer = Buffer.alloc(64) + buffer.writeUInt16BE(0xffd8, 0) + buffer.writeUInt16BE(0xffe0, 2) + buffer.writeUInt16BE(1, 4) + expect(readImageDimensions(buffer)).toBeNull() + }) + + it('rejects an ICNS buffer with a zero-valued entry length', () => { + const buffer = Buffer.alloc(32) + buffer.write('icns', 0, 'latin1') + buffer.writeUInt32BE(32, 4) + buffer.write('ic07', 8, 'latin1') + buffer.writeUInt32BE(0, 12) + expect(readImageDimensions(buffer)).toBeNull() + }) + + it('rejects a HEIF buffer with a zero-valued box size', () => { + const buffer = Buffer.alloc(32) + buffer.writeUInt32BE(0, 0) + buffer.write('ftyp', 4, 'latin1') + buffer.write('heic', 8, 'latin1') + expect(readImageDimensions(buffer)).toBeNull() + }) + + it('rejects a JXL buffer with a zero-valued box size', () => { + const buffer = Buffer.alloc(32) + buffer.writeUInt32BE(0, 0) + buffer.write('JXL ', 4, 'latin1') + expect(readImageDimensions(buffer)).toBeNull() + }) + }) +}) diff --git a/apps/sim/lib/content/image-dimensions.ts b/apps/sim/lib/content/image-dimensions.ts new file mode 100644 index 00000000000..31d9ef8fda2 --- /dev/null +++ b/apps/sim/lib/content/image-dimensions.ts @@ -0,0 +1,115 @@ +/** + * Minimal intrinsic-dimension reader for the image formats the content + * pipeline actually ships as OG covers (PNG, JPEG, WebP). + * + * This replaces the `image-size` package, which is archived upstream and + * carries unpatched high-severity DoS advisories (GHSA-w3rx-r6r6-pgpr, + * GHSA-5p2g-fcmc-qvqq) in its ICNS/JXL/HEIF parsers — formats this app never + * reads. Only the JPEG marker scan loops at all, and it advances on every + * iteration regardless of the declared lengths (see `readJpeg`); PNG and WebP + * are fixed-offset header reads. + */ + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + +/** JPEG frame markers that carry a size record, excluding DHT/JPG/DAC. */ +const JPEG_SOF_MARKERS = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, +]) + +export interface ImageDimensions { + width: number + height: number +} + +function readPng(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 24) return null + if (buffer.subarray(12, 16).toString('latin1') !== 'IHDR') return null + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) } +} + +/** + * Walks the JPEG marker chain to the first start-of-frame segment. + * + * The scan always terminates: `offset` grows by at least 1 on every branch, and + * a segment declaring a length below the 2-byte minimum lands the next + * iteration back on its own length bytes, which cannot be the `0xff` a marker + * requires. This is the property the replaced `image-size` parsers lacked. + */ +function readJpeg(buffer: Buffer): ImageDimensions | null { + let offset = 2 + while (offset + 3 < buffer.length) { + if (buffer[offset] !== 0xff) return null + const marker = buffer[offset + 1] + if (marker === 0xff) { + offset += 1 + continue + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2 + continue + } + const segmentLength = buffer.readUInt16BE(offset + 2) + if (JPEG_SOF_MARKERS.has(marker)) { + if (offset + 9 > buffer.length) return null + return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) } + } + offset += 2 + segmentLength + } + return null +} + +function readWebp(buffer: Buffer): ImageDimensions | null { + const chunkType = buffer.subarray(12, 16).toString('latin1') + + if (chunkType === 'VP8X') { + if (buffer.length < 30) return null + return { + width: buffer.readUIntLE(24, 3) + 1, + height: buffer.readUIntLE(27, 3) + 1, + } + } + + if (chunkType === 'VP8L') { + if (buffer.length < 25 || buffer[20] !== 0x2f) return null + const bits = buffer.readUInt32LE(21) + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + } + } + + if (chunkType === 'VP8 ') { + if (buffer.length < 30) return null + if (buffer[23] !== 0x9d || buffer[24] !== 0x01 || buffer[25] !== 0x2a) return null + return { + width: buffer.readUInt16LE(26) & 0x3fff, + height: buffer.readUInt16LE(28) & 0x3fff, + } + } + + return null +} + +/** + * Reads intrinsic pixel dimensions from a PNG, JPEG, or WebP buffer. Returns + * null for unrecognized formats, truncated buffers, or zero-valued dimensions. + */ +export function readImageDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 12) return null + + let dimensions: ImageDimensions | null = null + if (buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { + dimensions = readPng(buffer) + } else if (buffer[0] === 0xff && buffer[1] === 0xd8) { + dimensions = readJpeg(buffer) + } else if ( + buffer.subarray(0, 4).toString('latin1') === 'RIFF' && + buffer.subarray(8, 12).toString('latin1') === 'WEBP' + ) { + dimensions = readWebp(buffer) + } + + if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) return null + return dimensions +} diff --git a/apps/sim/lib/content/registry-factory.ts b/apps/sim/lib/content/registry-factory.ts index ca06e5e6b13..12f67867a7b 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -2,11 +2,12 @@ import fs from 'fs/promises' import path from 'path' import { cache } from 'react' import matter from 'gray-matter' -import { imageSize } from 'image-size' import { compileMDX } from 'next-mdx-remote/rsc' import rehypeAutolinkHeadings from 'rehype-autolink-headings' import rehypeSlug from 'rehype-slug' import remarkGfm from 'remark-gfm' +import type { ImageDimensions } from '@/lib/content/image-dimensions' +import { readImageDimensions } from '@/lib/content/image-dimensions' import { mdxComponents } from '@/lib/content/mdx' import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/content/schema' import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema' @@ -96,14 +97,11 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg * null for remote URLs or unreadable files, in which case the builders fall * back to the 1200x630 OG default. */ - async function readOgImageDimensions( - ogImage: string - ): Promise<{ width: number; height: number } | null> { + async function readOgImageDimensions(ogImage: string): Promise { if (ogImage.startsWith('http')) return null try { const buffer = await fs.readFile(path.join(process.cwd(), 'public', ogImage)) - const { width, height } = imageSize(buffer) - return width && height ? { width, height } : null + return readImageDimensions(buffer) } catch { return null } diff --git a/apps/sim/package.json b/apps/sim/package.json index b59834d953a..7b0e95742e6 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -174,7 +174,6 @@ "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "idb-keyval": "6.2.2", - "image-size": "2.0.2", "imapflow": "1.2.4", "input-otp": "^1.4.2", "ioredis": "^5.6.0", diff --git a/bun.lock b/bun.lock index 1e30a508d8c..3f93d781422 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -276,7 +277,6 @@ "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "idb-keyval": "6.2.2", - "image-size": "2.0.2", "imapflow": "1.2.4", "input-otp": "^1.4.2", "ioredis": "^5.6.0", @@ -3121,7 +3121,7 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], "imapflow": ["imapflow@1.2.4", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.1", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "7.0.12", "pino": "10.1.0", "socks": "2.8.7" } }, "sha512-X/eRQeje33uZycfopjwoQKKbya+bBIaqpviOFxhPOD24DXU2hMfXwYe9e8j1+ADwFVgTvKq4G2/ljjZK3Y8mvg=="], @@ -5155,8 +5155,6 @@ "pptxgenjs/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], - "pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], - "protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], From 14448a7238899a4a2be395f20ac198fcdb2361e8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 23:00:27 -0700 Subject: [PATCH 2/3] improvement(content): cover GIF and warn when OG dimensions are unreadable --- apps/sim/lib/content/image-dimensions.test.ts | 28 +++++++++++++++++++ apps/sim/lib/content/image-dimensions.ts | 28 +++++++++++++++---- apps/sim/lib/content/registry-factory.ts | 11 +++++++- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/content/image-dimensions.test.ts b/apps/sim/lib/content/image-dimensions.test.ts index 7e9a4e0d38e..215f3938bd7 100644 --- a/apps/sim/lib/content/image-dimensions.test.ts +++ b/apps/sim/lib/content/image-dimensions.test.ts @@ -57,6 +57,14 @@ function webpVp8(width: number, height: number): Buffer { return buffer } +function gif(width: number, height: number, version: 'GIF87a' | 'GIF89a' = 'GIF89a'): Buffer { + const buffer = Buffer.alloc(14) + buffer.write(version, 0, 'latin1') + buffer.writeUInt16LE(width, 6) + buffer.writeUInt16LE(height, 8) + return buffer +} + describe('readImageDimensions', () => { it('reads PNG dimensions from IHDR', () => { expect(readImageDimensions(png(1200, 630))).toEqual({ width: 1200, height: 630 }) @@ -93,10 +101,30 @@ describe('readImageDimensions', () => { expect(readImageDimensions(webpVp8(512, 256))).toEqual({ width: 512, height: 256 }) }) + it('reads GIF dimensions from the logical screen descriptor', () => { + expect(readImageDimensions(gif(800, 424))).toEqual({ width: 800, height: 424 }) + expect(readImageDimensions(gif(640, 722, 'GIF87a'))).toEqual({ width: 640, height: 722 }) + }) + it('returns null for an unrecognized format', () => { expect(readImageDimensions(Buffer.from('not an image at all, really'))).toBeNull() }) + /** + * SVG and ICO are intentionally out of scope — neither is a valid `og:image` + * for the social crawlers, and callers fall back to the OG default. + */ + it('returns null for SVG and ICO', () => { + expect(readImageDimensions(Buffer.from(''))).toBeNull() + const ico = Buffer.alloc(16) + ico.writeUInt16LE(0, 0) + ico.writeUInt16LE(1, 2) + ico.writeUInt16LE(1, 4) + ico.writeUInt8(32, 6) + ico.writeUInt8(32, 7) + expect(readImageDimensions(ico)).toBeNull() + }) + it('returns null for a truncated buffer', () => { expect(readImageDimensions(png(100, 100).subarray(0, 20))).toBeNull() }) diff --git a/apps/sim/lib/content/image-dimensions.ts b/apps/sim/lib/content/image-dimensions.ts index 31d9ef8fda2..c289ac8d91c 100644 --- a/apps/sim/lib/content/image-dimensions.ts +++ b/apps/sim/lib/content/image-dimensions.ts @@ -1,17 +1,25 @@ /** - * Minimal intrinsic-dimension reader for the image formats the content - * pipeline actually ships as OG covers (PNG, JPEG, WebP). + * Minimal intrinsic-dimension reader for the raster formats that are valid as + * social preview images: PNG, JPEG, WebP, and GIF. * * This replaces the `image-size` package, which is archived upstream and * carries unpatched high-severity DoS advisories (GHSA-w3rx-r6r6-pgpr, * GHSA-5p2g-fcmc-qvqq) in its ICNS/JXL/HEIF parsers — formats this app never * reads. Only the JPEG marker scan loops at all, and it advances on every - * iteration regardless of the declared lengths (see `readJpeg`); PNG and WebP - * are fixed-offset header reads. + * iteration regardless of the declared lengths (see `readJpeg`); the rest are + * fixed-offset header reads. + * + * SVG and ICO are deliberately unsupported: neither is accepted as an + * `og:image` by the major social crawlers, and reading SVG dimensions means + * regex-matching untrusted-shaped XML, which is the failure class that + * motivated removing the dependency in the first place. Callers are expected + * to treat a null return as "fall back to the declared OG default". */ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) +const GIF_SIGNATURES = new Set(['GIF87a', 'GIF89a']) + /** JPEG frame markers that carry a size record, excluding DHT/JPG/DAC. */ const JPEG_SOF_MARKERS = new Set([ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, @@ -59,6 +67,11 @@ function readJpeg(buffer: Buffer): ImageDimensions | null { return null } +function readGif(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 10) return null + return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) } +} + function readWebp(buffer: Buffer): ImageDimensions | null { const chunkType = buffer.subarray(12, 16).toString('latin1') @@ -92,8 +105,9 @@ function readWebp(buffer: Buffer): ImageDimensions | null { } /** - * Reads intrinsic pixel dimensions from a PNG, JPEG, or WebP buffer. Returns - * null for unrecognized formats, truncated buffers, or zero-valued dimensions. + * Reads intrinsic pixel dimensions from a PNG, JPEG, WebP, or GIF buffer. + * Returns null for unrecognized formats, truncated buffers, or zero-valued + * dimensions. */ export function readImageDimensions(buffer: Buffer): ImageDimensions | null { if (buffer.length < 12) return null @@ -108,6 +122,8 @@ export function readImageDimensions(buffer: Buffer): ImageDimensions | null { buffer.subarray(8, 12).toString('latin1') === 'WEBP' ) { dimensions = readWebp(buffer) + } else if (GIF_SIGNATURES.has(buffer.subarray(0, 6).toString('latin1'))) { + dimensions = readGif(buffer) } if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) return null diff --git a/apps/sim/lib/content/registry-factory.ts b/apps/sim/lib/content/registry-factory.ts index 12f67867a7b..d3bda30dac2 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -1,6 +1,7 @@ import fs from 'fs/promises' import path from 'path' import { cache } from 'react' +import { createLogger } from '@sim/logger' import matter from 'gray-matter' import { compileMDX } from 'next-mdx-remote/rsc' import rehypeAutolinkHeadings from 'rehype-autolink-headings' @@ -13,6 +14,8 @@ import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/conte import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema' import { byDateDesc, ensureContentDirs, toIsoDate } from '@/lib/content/utils' +const logger = createLogger('ContentRegistry') + /** Loads a post's custom MDX component overrides, keyed by slug. */ export type ContentComponentLoaders = Record< string, @@ -101,7 +104,13 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg if (ogImage.startsWith('http')) return null try { const buffer = await fs.readFile(path.join(process.cwd(), 'public', ogImage)) - return readImageDimensions(buffer) + const dimensions = readImageDimensions(buffer) + if (!dimensions) { + logger.warn('OG image dimensions could not be read; falling back to the OG default', { + ogImage, + }) + } + return dimensions } catch { return null } From e53b2460753232ce86513d2562b06eb85d286388 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 23:07:21 -0700 Subject: [PATCH 3/3] improvement(content): read OG dimensions via sharp instead of a bespoke parser --- apps/sim/lib/content/image-dimensions.test.ts | 183 ------------------ apps/sim/lib/content/image-dimensions.ts | 131 ------------- apps/sim/lib/content/og-image.test.ts | 55 ++++++ apps/sim/lib/content/registry-factory.ts | 20 +- 4 files changed, 68 insertions(+), 321 deletions(-) delete mode 100644 apps/sim/lib/content/image-dimensions.test.ts delete mode 100644 apps/sim/lib/content/image-dimensions.ts create mode 100644 apps/sim/lib/content/og-image.test.ts diff --git a/apps/sim/lib/content/image-dimensions.test.ts b/apps/sim/lib/content/image-dimensions.test.ts deleted file mode 100644 index 215f3938bd7..00000000000 --- a/apps/sim/lib/content/image-dimensions.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { readImageDimensions } from '@/lib/content/image-dimensions' - -function png(width: number, height: number): Buffer { - const buffer = Buffer.alloc(24) - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer) - buffer.writeUInt32BE(13, 8) - buffer.write('IHDR', 12, 'latin1') - buffer.writeUInt32BE(width, 16) - buffer.writeUInt32BE(height, 20) - return buffer -} - -/** Builds a JPEG whose SOF0 frame is preceded by `filler` app segments. */ -function jpeg(width: number, height: number, filler: Buffer = Buffer.alloc(0)): Buffer { - const sof = Buffer.alloc(11) - sof.writeUInt16BE(0xffc0, 0) - sof.writeUInt16BE(8, 2) - sof.writeUInt8(8, 4) - sof.writeUInt16BE(height, 5) - sof.writeUInt16BE(width, 7) - return Buffer.concat([Buffer.from([0xff, 0xd8]), filler, sof]) -} - -function webpVp8x(width: number, height: number): Buffer { - const buffer = Buffer.alloc(30) - buffer.write('RIFF', 0, 'latin1') - buffer.write('WEBP', 8, 'latin1') - buffer.write('VP8X', 12, 'latin1') - buffer.writeUInt32LE(10, 16) - buffer.writeUIntLE(width - 1, 24, 3) - buffer.writeUIntLE(height - 1, 27, 3) - return buffer -} - -function webpVp8l(width: number, height: number): Buffer { - const buffer = Buffer.alloc(25) - buffer.write('RIFF', 0, 'latin1') - buffer.write('WEBP', 8, 'latin1') - buffer.write('VP8L', 12, 'latin1') - buffer.writeUInt8(0x2f, 20) - buffer.writeUInt32LE(((height - 1) << 14) | (width - 1), 21) - return buffer -} - -function webpVp8(width: number, height: number): Buffer { - const buffer = Buffer.alloc(30) - buffer.write('RIFF', 0, 'latin1') - buffer.write('WEBP', 8, 'latin1') - buffer.write('VP8 ', 12, 'latin1') - Buffer.from([0x9d, 0x01, 0x2a]).copy(buffer, 23) - buffer.writeUInt16LE(width, 26) - buffer.writeUInt16LE(height, 28) - return buffer -} - -function gif(width: number, height: number, version: 'GIF87a' | 'GIF89a' = 'GIF89a'): Buffer { - const buffer = Buffer.alloc(14) - buffer.write(version, 0, 'latin1') - buffer.writeUInt16LE(width, 6) - buffer.writeUInt16LE(height, 8) - return buffer -} - -describe('readImageDimensions', () => { - it('reads PNG dimensions from IHDR', () => { - expect(readImageDimensions(png(1200, 630))).toEqual({ width: 1200, height: 630 }) - }) - - it('reads JPEG dimensions from the SOF0 frame', () => { - expect(readImageDimensions(jpeg(1920, 1080))).toEqual({ width: 1920, height: 1080 }) - }) - - it('skips JPEG app segments before the frame', () => { - const app0 = Buffer.alloc(18) - app0.writeUInt16BE(0xffe0, 0) - app0.writeUInt16BE(16, 2) - app0.write('JFIF\0', 4, 'latin1') - expect(readImageDimensions(jpeg(800, 400, app0))).toEqual({ width: 800, height: 400 }) - }) - - it('tolerates JPEG marker padding bytes', () => { - expect(readImageDimensions(jpeg(640, 480, Buffer.from([0xff, 0xff, 0xff])))).toEqual({ - width: 640, - height: 480, - }) - }) - - it('reads extended WebP canvas dimensions', () => { - expect(readImageDimensions(webpVp8x(2400, 1260))).toEqual({ width: 2400, height: 1260 }) - }) - - it('reads lossless WebP dimensions', () => { - expect(readImageDimensions(webpVp8l(1024, 768))).toEqual({ width: 1024, height: 768 }) - }) - - it('reads lossy WebP dimensions', () => { - expect(readImageDimensions(webpVp8(512, 256))).toEqual({ width: 512, height: 256 }) - }) - - it('reads GIF dimensions from the logical screen descriptor', () => { - expect(readImageDimensions(gif(800, 424))).toEqual({ width: 800, height: 424 }) - expect(readImageDimensions(gif(640, 722, 'GIF87a'))).toEqual({ width: 640, height: 722 }) - }) - - it('returns null for an unrecognized format', () => { - expect(readImageDimensions(Buffer.from('not an image at all, really'))).toBeNull() - }) - - /** - * SVG and ICO are intentionally out of scope — neither is a valid `og:image` - * for the social crawlers, and callers fall back to the OG default. - */ - it('returns null for SVG and ICO', () => { - expect(readImageDimensions(Buffer.from(''))).toBeNull() - const ico = Buffer.alloc(16) - ico.writeUInt16LE(0, 0) - ico.writeUInt16LE(1, 2) - ico.writeUInt16LE(1, 4) - ico.writeUInt8(32, 6) - ico.writeUInt8(32, 7) - expect(readImageDimensions(ico)).toBeNull() - }) - - it('returns null for a truncated buffer', () => { - expect(readImageDimensions(png(100, 100).subarray(0, 20))).toBeNull() - }) - - it('returns null when a header declares zero dimensions', () => { - expect(readImageDimensions(png(0, 0))).toBeNull() - expect(readImageDimensions(jpeg(0, 0))).toBeNull() - }) - - /** - * The `image-size` advisories this parser replaces (GHSA-w3rx-r6r6-pgpr, - * GHSA-5p2g-fcmc-qvqq) were zero-valued length fields that left the read - * offset unchanged, hanging the event loop. Each case below must terminate. - */ - describe('malformed-length denial-of-service inputs', () => { - it('terminates on a JPEG segment declaring zero length', () => { - const buffer = Buffer.alloc(64) - buffer.writeUInt16BE(0xffd8, 0) - buffer.writeUInt16BE(0xffe0, 2) - buffer.writeUInt16BE(0, 4) - expect(readImageDimensions(buffer)).toBeNull() - }) - - it('terminates on a JPEG segment declaring a length of one', () => { - const buffer = Buffer.alloc(64) - buffer.writeUInt16BE(0xffd8, 0) - buffer.writeUInt16BE(0xffe0, 2) - buffer.writeUInt16BE(1, 4) - expect(readImageDimensions(buffer)).toBeNull() - }) - - it('rejects an ICNS buffer with a zero-valued entry length', () => { - const buffer = Buffer.alloc(32) - buffer.write('icns', 0, 'latin1') - buffer.writeUInt32BE(32, 4) - buffer.write('ic07', 8, 'latin1') - buffer.writeUInt32BE(0, 12) - expect(readImageDimensions(buffer)).toBeNull() - }) - - it('rejects a HEIF buffer with a zero-valued box size', () => { - const buffer = Buffer.alloc(32) - buffer.writeUInt32BE(0, 0) - buffer.write('ftyp', 4, 'latin1') - buffer.write('heic', 8, 'latin1') - expect(readImageDimensions(buffer)).toBeNull() - }) - - it('rejects a JXL buffer with a zero-valued box size', () => { - const buffer = Buffer.alloc(32) - buffer.writeUInt32BE(0, 0) - buffer.write('JXL ', 4, 'latin1') - expect(readImageDimensions(buffer)).toBeNull() - }) - }) -}) diff --git a/apps/sim/lib/content/image-dimensions.ts b/apps/sim/lib/content/image-dimensions.ts deleted file mode 100644 index c289ac8d91c..00000000000 --- a/apps/sim/lib/content/image-dimensions.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Minimal intrinsic-dimension reader for the raster formats that are valid as - * social preview images: PNG, JPEG, WebP, and GIF. - * - * This replaces the `image-size` package, which is archived upstream and - * carries unpatched high-severity DoS advisories (GHSA-w3rx-r6r6-pgpr, - * GHSA-5p2g-fcmc-qvqq) in its ICNS/JXL/HEIF parsers — formats this app never - * reads. Only the JPEG marker scan loops at all, and it advances on every - * iteration regardless of the declared lengths (see `readJpeg`); the rest are - * fixed-offset header reads. - * - * SVG and ICO are deliberately unsupported: neither is accepted as an - * `og:image` by the major social crawlers, and reading SVG dimensions means - * regex-matching untrusted-shaped XML, which is the failure class that - * motivated removing the dependency in the first place. Callers are expected - * to treat a null return as "fall back to the declared OG default". - */ - -const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - -const GIF_SIGNATURES = new Set(['GIF87a', 'GIF89a']) - -/** JPEG frame markers that carry a size record, excluding DHT/JPG/DAC. */ -const JPEG_SOF_MARKERS = new Set([ - 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, -]) - -export interface ImageDimensions { - width: number - height: number -} - -function readPng(buffer: Buffer): ImageDimensions | null { - if (buffer.length < 24) return null - if (buffer.subarray(12, 16).toString('latin1') !== 'IHDR') return null - return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) } -} - -/** - * Walks the JPEG marker chain to the first start-of-frame segment. - * - * The scan always terminates: `offset` grows by at least 1 on every branch, and - * a segment declaring a length below the 2-byte minimum lands the next - * iteration back on its own length bytes, which cannot be the `0xff` a marker - * requires. This is the property the replaced `image-size` parsers lacked. - */ -function readJpeg(buffer: Buffer): ImageDimensions | null { - let offset = 2 - while (offset + 3 < buffer.length) { - if (buffer[offset] !== 0xff) return null - const marker = buffer[offset + 1] - if (marker === 0xff) { - offset += 1 - continue - } - if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { - offset += 2 - continue - } - const segmentLength = buffer.readUInt16BE(offset + 2) - if (JPEG_SOF_MARKERS.has(marker)) { - if (offset + 9 > buffer.length) return null - return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) } - } - offset += 2 + segmentLength - } - return null -} - -function readGif(buffer: Buffer): ImageDimensions | null { - if (buffer.length < 10) return null - return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) } -} - -function readWebp(buffer: Buffer): ImageDimensions | null { - const chunkType = buffer.subarray(12, 16).toString('latin1') - - if (chunkType === 'VP8X') { - if (buffer.length < 30) return null - return { - width: buffer.readUIntLE(24, 3) + 1, - height: buffer.readUIntLE(27, 3) + 1, - } - } - - if (chunkType === 'VP8L') { - if (buffer.length < 25 || buffer[20] !== 0x2f) return null - const bits = buffer.readUInt32LE(21) - return { - width: (bits & 0x3fff) + 1, - height: ((bits >> 14) & 0x3fff) + 1, - } - } - - if (chunkType === 'VP8 ') { - if (buffer.length < 30) return null - if (buffer[23] !== 0x9d || buffer[24] !== 0x01 || buffer[25] !== 0x2a) return null - return { - width: buffer.readUInt16LE(26) & 0x3fff, - height: buffer.readUInt16LE(28) & 0x3fff, - } - } - - return null -} - -/** - * Reads intrinsic pixel dimensions from a PNG, JPEG, WebP, or GIF buffer. - * Returns null for unrecognized formats, truncated buffers, or zero-valued - * dimensions. - */ -export function readImageDimensions(buffer: Buffer): ImageDimensions | null { - if (buffer.length < 12) return null - - let dimensions: ImageDimensions | null = null - if (buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { - dimensions = readPng(buffer) - } else if (buffer[0] === 0xff && buffer[1] === 0xd8) { - dimensions = readJpeg(buffer) - } else if ( - buffer.subarray(0, 4).toString('latin1') === 'RIFF' && - buffer.subarray(8, 12).toString('latin1') === 'WEBP' - ) { - dimensions = readWebp(buffer) - } else if (GIF_SIGNATURES.has(buffer.subarray(0, 6).toString('latin1'))) { - dimensions = readGif(buffer) - } - - if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) return null - return dimensions -} diff --git a/apps/sim/lib/content/og-image.test.ts b/apps/sim/lib/content/og-image.test.ts new file mode 100644 index 00000000000..998cf3ff967 --- /dev/null +++ b/apps/sim/lib/content/og-image.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import fs from 'fs' +import path from 'path' +import matter from 'gray-matter' +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' + +/** + * Guards the content invariant behind `ogImageWidth`/`ogImageHeight` in + * `registry-factory`: every local `ogImage` must exist and expose intrinsic + * dimensions, or the SEO builders silently fall back to a 1200x630 default that + * misdescribes the real asset. + * + * It also pins the format to one the social crawlers actually accept. SVG is the + * trap worth naming: it renders fine in the browser and `sharp` reports + * dimensions for it, so a dimension check alone would pass while Open Graph + * previews silently break. + */ +const CRAWLER_SAFE_FORMATS = ['jpeg', 'png', 'webp', 'gif'] + +function collectOgImages(): { slug: string; ogImage: string }[] { + const entries: { slug: string; ogImage: string }[] = [] + for (const dir of ['content/blog', 'content/library']) { + if (!fs.existsSync(dir)) continue + for (const slug of fs.readdirSync(dir)) { + const mdxPath = path.join(dir, slug, 'index.mdx') + if (!fs.existsSync(mdxPath)) continue + const { data } = matter(fs.readFileSync(mdxPath, 'utf-8')) + if (typeof data.ogImage === 'string' && !data.ogImage.startsWith('http')) { + entries.push({ slug, ogImage: data.ogImage }) + } + } + } + return entries +} + +describe('content OG images', () => { + const entries = collectOgImages() + + it('finds local OG images to check', () => { + expect(entries.length).toBeGreaterThan(0) + }) + + it.each(entries)('$slug resolves readable dimensions for $ogImage', async ({ ogImage }) => { + const file = path.join('public', ogImage) + expect(fs.existsSync(file), `${file} does not exist`).toBe(true) + + const { width, height, format } = await sharp(fs.readFileSync(file)).metadata() + expect(width, `${file} has no readable width`).toBeGreaterThan(0) + expect(height, `${file} has no readable height`).toBeGreaterThan(0) + expect(CRAWLER_SAFE_FORMATS, `${file} is a ${format}, which crawlers reject`).toContain(format) + }) +}) diff --git a/apps/sim/lib/content/registry-factory.ts b/apps/sim/lib/content/registry-factory.ts index d3bda30dac2..8df6fceba6f 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -7,8 +7,7 @@ import { compileMDX } from 'next-mdx-remote/rsc' import rehypeAutolinkHeadings from 'rehype-autolink-headings' import rehypeSlug from 'rehype-slug' import remarkGfm from 'remark-gfm' -import type { ImageDimensions } from '@/lib/content/image-dimensions' -import { readImageDimensions } from '@/lib/content/image-dimensions' +import sharp from 'sharp' import { mdxComponents } from '@/lib/content/mdx' import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/content/schema' import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema' @@ -99,18 +98,25 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg * SEO builders can declare accurate `og:image` and JSON-LD sizes. Returns * null for remote URLs or unreadable files, in which case the builders fall * back to the 1200x630 OG default. + * + * Uses `sharp`, which only parses headers for `metadata()`. It replaced the + * `image-size` package, archived upstream with unpatched DoS advisories in + * its ICNS/JXL/HEIF parsers (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). */ - async function readOgImageDimensions(ogImage: string): Promise { + async function readOgImageDimensions( + ogImage: string + ): Promise<{ width: number; height: number } | null> { if (ogImage.startsWith('http')) return null try { const buffer = await fs.readFile(path.join(process.cwd(), 'public', ogImage)) - const dimensions = readImageDimensions(buffer) - if (!dimensions) { - logger.warn('OG image dimensions could not be read; falling back to the OG default', { + const { width, height } = await sharp(buffer).metadata() + if (!width || !height) { + logger.warn('OG image has no readable dimensions; falling back to the OG default', { ogImage, }) + return null } - return dimensions + return { width, height } } catch { return null }