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 ca06e5e6b13..8df6fceba6f 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -1,17 +1,20 @@ import fs from 'fs/promises' import path from 'path' import { cache } from 'react' +import { createLogger } from '@sim/logger' 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 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' 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, @@ -95,6 +98,10 @@ 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 @@ -102,8 +109,14 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg 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 + 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 { width, height } } 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=="],