Skip to content

Commit 0fafe0c

Browse files
committed
chore(deps): drop the archived image-size dependency
1 parent 3fe2f4f commit 0fafe0c

5 files changed

Lines changed: 276 additions & 11 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { readImageDimensions } from '@/lib/content/image-dimensions'
6+
7+
function png(width: number, height: number): Buffer {
8+
const buffer = Buffer.alloc(24)
9+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer)
10+
buffer.writeUInt32BE(13, 8)
11+
buffer.write('IHDR', 12, 'latin1')
12+
buffer.writeUInt32BE(width, 16)
13+
buffer.writeUInt32BE(height, 20)
14+
return buffer
15+
}
16+
17+
/** Builds a JPEG whose SOF0 frame is preceded by `filler` app segments. */
18+
function jpeg(width: number, height: number, filler: Buffer = Buffer.alloc(0)): Buffer {
19+
const sof = Buffer.alloc(11)
20+
sof.writeUInt16BE(0xffc0, 0)
21+
sof.writeUInt16BE(8, 2)
22+
sof.writeUInt8(8, 4)
23+
sof.writeUInt16BE(height, 5)
24+
sof.writeUInt16BE(width, 7)
25+
return Buffer.concat([Buffer.from([0xff, 0xd8]), filler, sof])
26+
}
27+
28+
function webpVp8x(width: number, height: number): Buffer {
29+
const buffer = Buffer.alloc(30)
30+
buffer.write('RIFF', 0, 'latin1')
31+
buffer.write('WEBP', 8, 'latin1')
32+
buffer.write('VP8X', 12, 'latin1')
33+
buffer.writeUInt32LE(10, 16)
34+
buffer.writeUIntLE(width - 1, 24, 3)
35+
buffer.writeUIntLE(height - 1, 27, 3)
36+
return buffer
37+
}
38+
39+
function webpVp8l(width: number, height: number): Buffer {
40+
const buffer = Buffer.alloc(25)
41+
buffer.write('RIFF', 0, 'latin1')
42+
buffer.write('WEBP', 8, 'latin1')
43+
buffer.write('VP8L', 12, 'latin1')
44+
buffer.writeUInt8(0x2f, 20)
45+
buffer.writeUInt32LE(((height - 1) << 14) | (width - 1), 21)
46+
return buffer
47+
}
48+
49+
function webpVp8(width: number, height: number): Buffer {
50+
const buffer = Buffer.alloc(30)
51+
buffer.write('RIFF', 0, 'latin1')
52+
buffer.write('WEBP', 8, 'latin1')
53+
buffer.write('VP8 ', 12, 'latin1')
54+
Buffer.from([0x9d, 0x01, 0x2a]).copy(buffer, 23)
55+
buffer.writeUInt16LE(width, 26)
56+
buffer.writeUInt16LE(height, 28)
57+
return buffer
58+
}
59+
60+
describe('readImageDimensions', () => {
61+
it('reads PNG dimensions from IHDR', () => {
62+
expect(readImageDimensions(png(1200, 630))).toEqual({ width: 1200, height: 630 })
63+
})
64+
65+
it('reads JPEG dimensions from the SOF0 frame', () => {
66+
expect(readImageDimensions(jpeg(1920, 1080))).toEqual({ width: 1920, height: 1080 })
67+
})
68+
69+
it('skips JPEG app segments before the frame', () => {
70+
const app0 = Buffer.alloc(18)
71+
app0.writeUInt16BE(0xffe0, 0)
72+
app0.writeUInt16BE(16, 2)
73+
app0.write('JFIF\0', 4, 'latin1')
74+
expect(readImageDimensions(jpeg(800, 400, app0))).toEqual({ width: 800, height: 400 })
75+
})
76+
77+
it('tolerates JPEG marker padding bytes', () => {
78+
expect(readImageDimensions(jpeg(640, 480, Buffer.from([0xff, 0xff, 0xff])))).toEqual({
79+
width: 640,
80+
height: 480,
81+
})
82+
})
83+
84+
it('reads extended WebP canvas dimensions', () => {
85+
expect(readImageDimensions(webpVp8x(2400, 1260))).toEqual({ width: 2400, height: 1260 })
86+
})
87+
88+
it('reads lossless WebP dimensions', () => {
89+
expect(readImageDimensions(webpVp8l(1024, 768))).toEqual({ width: 1024, height: 768 })
90+
})
91+
92+
it('reads lossy WebP dimensions', () => {
93+
expect(readImageDimensions(webpVp8(512, 256))).toEqual({ width: 512, height: 256 })
94+
})
95+
96+
it('returns null for an unrecognized format', () => {
97+
expect(readImageDimensions(Buffer.from('not an image at all, really'))).toBeNull()
98+
})
99+
100+
it('returns null for a truncated buffer', () => {
101+
expect(readImageDimensions(png(100, 100).subarray(0, 20))).toBeNull()
102+
})
103+
104+
it('returns null when a header declares zero dimensions', () => {
105+
expect(readImageDimensions(png(0, 0))).toBeNull()
106+
expect(readImageDimensions(jpeg(0, 0))).toBeNull()
107+
})
108+
109+
/**
110+
* The `image-size` advisories this parser replaces (GHSA-w3rx-r6r6-pgpr,
111+
* GHSA-5p2g-fcmc-qvqq) were zero-valued length fields that left the read
112+
* offset unchanged, hanging the event loop. Each case below must terminate.
113+
*/
114+
describe('malformed-length denial-of-service inputs', () => {
115+
it('terminates on a JPEG segment declaring zero length', () => {
116+
const buffer = Buffer.alloc(64)
117+
buffer.writeUInt16BE(0xffd8, 0)
118+
buffer.writeUInt16BE(0xffe0, 2)
119+
buffer.writeUInt16BE(0, 4)
120+
expect(readImageDimensions(buffer)).toBeNull()
121+
})
122+
123+
it('terminates on a JPEG segment declaring a length of one', () => {
124+
const buffer = Buffer.alloc(64)
125+
buffer.writeUInt16BE(0xffd8, 0)
126+
buffer.writeUInt16BE(0xffe0, 2)
127+
buffer.writeUInt16BE(1, 4)
128+
expect(readImageDimensions(buffer)).toBeNull()
129+
})
130+
131+
it('rejects an ICNS buffer with a zero-valued entry length', () => {
132+
const buffer = Buffer.alloc(32)
133+
buffer.write('icns', 0, 'latin1')
134+
buffer.writeUInt32BE(32, 4)
135+
buffer.write('ic07', 8, 'latin1')
136+
buffer.writeUInt32BE(0, 12)
137+
expect(readImageDimensions(buffer)).toBeNull()
138+
})
139+
140+
it('rejects a HEIF buffer with a zero-valued box size', () => {
141+
const buffer = Buffer.alloc(32)
142+
buffer.writeUInt32BE(0, 0)
143+
buffer.write('ftyp', 4, 'latin1')
144+
buffer.write('heic', 8, 'latin1')
145+
expect(readImageDimensions(buffer)).toBeNull()
146+
})
147+
148+
it('rejects a JXL buffer with a zero-valued box size', () => {
149+
const buffer = Buffer.alloc(32)
150+
buffer.writeUInt32BE(0, 0)
151+
buffer.write('JXL ', 4, 'latin1')
152+
expect(readImageDimensions(buffer)).toBeNull()
153+
})
154+
})
155+
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Minimal intrinsic-dimension reader for the image formats the content
3+
* pipeline actually ships as OG covers (PNG, JPEG, WebP).
4+
*
5+
* This replaces the `image-size` package, which is archived upstream and
6+
* carries unpatched high-severity DoS advisories (GHSA-w3rx-r6r6-pgpr,
7+
* GHSA-5p2g-fcmc-qvqq) in its ICNS/JXL/HEIF parsers — formats this app never
8+
* reads. Only the JPEG marker scan loops at all, and it advances on every
9+
* iteration regardless of the declared lengths (see `readJpeg`); PNG and WebP
10+
* are fixed-offset header reads.
11+
*/
12+
13+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
14+
15+
/** JPEG frame markers that carry a size record, excluding DHT/JPG/DAC. */
16+
const JPEG_SOF_MARKERS = new Set([
17+
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
18+
])
19+
20+
export interface ImageDimensions {
21+
width: number
22+
height: number
23+
}
24+
25+
function readPng(buffer: Buffer): ImageDimensions | null {
26+
if (buffer.length < 24) return null
27+
if (buffer.subarray(12, 16).toString('latin1') !== 'IHDR') return null
28+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
29+
}
30+
31+
/**
32+
* Walks the JPEG marker chain to the first start-of-frame segment.
33+
*
34+
* The scan always terminates: `offset` grows by at least 1 on every branch, and
35+
* a segment declaring a length below the 2-byte minimum lands the next
36+
* iteration back on its own length bytes, which cannot be the `0xff` a marker
37+
* requires. This is the property the replaced `image-size` parsers lacked.
38+
*/
39+
function readJpeg(buffer: Buffer): ImageDimensions | null {
40+
let offset = 2
41+
while (offset + 3 < buffer.length) {
42+
if (buffer[offset] !== 0xff) return null
43+
const marker = buffer[offset + 1]
44+
if (marker === 0xff) {
45+
offset += 1
46+
continue
47+
}
48+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
49+
offset += 2
50+
continue
51+
}
52+
const segmentLength = buffer.readUInt16BE(offset + 2)
53+
if (JPEG_SOF_MARKERS.has(marker)) {
54+
if (offset + 9 > buffer.length) return null
55+
return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5) }
56+
}
57+
offset += 2 + segmentLength
58+
}
59+
return null
60+
}
61+
62+
function readWebp(buffer: Buffer): ImageDimensions | null {
63+
const chunkType = buffer.subarray(12, 16).toString('latin1')
64+
65+
if (chunkType === 'VP8X') {
66+
if (buffer.length < 30) return null
67+
return {
68+
width: buffer.readUIntLE(24, 3) + 1,
69+
height: buffer.readUIntLE(27, 3) + 1,
70+
}
71+
}
72+
73+
if (chunkType === 'VP8L') {
74+
if (buffer.length < 25 || buffer[20] !== 0x2f) return null
75+
const bits = buffer.readUInt32LE(21)
76+
return {
77+
width: (bits & 0x3fff) + 1,
78+
height: ((bits >> 14) & 0x3fff) + 1,
79+
}
80+
}
81+
82+
if (chunkType === 'VP8 ') {
83+
if (buffer.length < 30) return null
84+
if (buffer[23] !== 0x9d || buffer[24] !== 0x01 || buffer[25] !== 0x2a) return null
85+
return {
86+
width: buffer.readUInt16LE(26) & 0x3fff,
87+
height: buffer.readUInt16LE(28) & 0x3fff,
88+
}
89+
}
90+
91+
return null
92+
}
93+
94+
/**
95+
* Reads intrinsic pixel dimensions from a PNG, JPEG, or WebP buffer. Returns
96+
* null for unrecognized formats, truncated buffers, or zero-valued dimensions.
97+
*/
98+
export function readImageDimensions(buffer: Buffer): ImageDimensions | null {
99+
if (buffer.length < 12) return null
100+
101+
let dimensions: ImageDimensions | null = null
102+
if (buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
103+
dimensions = readPng(buffer)
104+
} else if (buffer[0] === 0xff && buffer[1] === 0xd8) {
105+
dimensions = readJpeg(buffer)
106+
} else if (
107+
buffer.subarray(0, 4).toString('latin1') === 'RIFF' &&
108+
buffer.subarray(8, 12).toString('latin1') === 'WEBP'
109+
) {
110+
dimensions = readWebp(buffer)
111+
}
112+
113+
if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) return null
114+
return dimensions
115+
}

apps/sim/lib/content/registry-factory.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import fs from 'fs/promises'
22
import path from 'path'
33
import { cache } from 'react'
44
import matter from 'gray-matter'
5-
import { imageSize } from 'image-size'
65
import { compileMDX } from 'next-mdx-remote/rsc'
76
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
87
import rehypeSlug from 'rehype-slug'
98
import remarkGfm from 'remark-gfm'
9+
import type { ImageDimensions } from '@/lib/content/image-dimensions'
10+
import { readImageDimensions } from '@/lib/content/image-dimensions'
1011
import { mdxComponents } from '@/lib/content/mdx'
1112
import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/content/schema'
1213
import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema'
@@ -96,14 +97,11 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg
9697
* null for remote URLs or unreadable files, in which case the builders fall
9798
* back to the 1200x630 OG default.
9899
*/
99-
async function readOgImageDimensions(
100-
ogImage: string
101-
): Promise<{ width: number; height: number } | null> {
100+
async function readOgImageDimensions(ogImage: string): Promise<ImageDimensions | null> {
102101
if (ogImage.startsWith('http')) return null
103102
try {
104103
const buffer = await fs.readFile(path.join(process.cwd(), 'public', ogImage))
105-
const { width, height } = imageSize(buffer)
106-
return width && height ? { width, height } : null
104+
return readImageDimensions(buffer)
107105
} catch {
108106
return null
109107
}

apps/sim/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,6 @@
174174
"http-proxy-agent": "7.0.2",
175175
"https-proxy-agent": "7.0.6",
176176
"idb-keyval": "6.2.2",
177-
"image-size": "2.0.2",
178177
"imapflow": "1.2.4",
179178
"input-otp": "^1.4.2",
180179
"ioredis": "^5.6.0",

bun.lock

Lines changed: 2 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)