Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
911 changes: 911 additions & 0 deletions apps/sim/app/api/link-preview/route.test.ts

Large diffs are not rendered by default.

32 changes: 28 additions & 4 deletions apps/sim/app/api/link-preview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,17 @@ const MAX_RESPONSE_BYTES = 256 * 1024
const MAX_REDIRECTS = 3
const TITLE_MAX_CHARS = 200
const DESCRIPTION_MAX_CHARS = 300
const IMAGE_URL_MAX_CHARS = 2048
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
const CACHE_TTL_SECONDS = 24 * 60 * 60
const NEGATIVE_CACHE_TTL_SECONDS = 60 * 60
const CACHE_KEY_PREFIX = 'link-preview:v1:'
const CACHE_KEY_PREFIX = 'link-preview:v2:'

/**
* Parses preview metadata from the fetched document (already capped at
* MAX_RESPONSE_BYTES); cheerio handles attribute order, quoting, and entity
* decoding.
*/
function parsePreview(html: string): LinkPreview {
function parsePreview(html: string, baseUrl: string): LinkPreview {
const $ = cheerio.load(html)

const meta = (key: string): string | null => {
Expand All @@ -42,12 +43,35 @@ function parsePreview(html: string): LinkPreview {
meta('og:title') ?? meta('twitter:title') ?? ($('title').first().text().trim() || null)
const description = meta('og:description') ?? meta('twitter:description') ?? meta('description')
const siteName = meta('og:site_name')
const rawImageUrl = meta('og:image') ?? meta('twitter:image')

if (!title && !description && !siteName) return null
let imageUrl: string | null = null
if (rawImageUrl) {
try {
const resolvedUrl = new URL(rawImageUrl, baseUrl)
if (resolvedUrl.href.length <= IMAGE_URL_MAX_CHARS) {
imageUrl = resolvedUrl.href
} else {
logger.warn('Image URL exceeds maximum length; dropping', {
rawImageUrl: truncate(rawImageUrl, 100),
resolvedLength: resolvedUrl.href.length,
maxLength: IMAGE_URL_MAX_CHARS,
})
}
} catch (error) {
logger.warn('Failed to resolve image URL', {
rawImageUrl: truncate(rawImageUrl, 100),
error: getErrorMessage(error),
})
}
}

if (!title && !description && !siteName && !imageUrl) return null
return {
title: title ? truncate(title, TITLE_MAX_CHARS) : null,
description: description ? truncate(description, DESCRIPTION_MAX_CHARS) : null,
siteName: siteName ? truncate(siteName, TITLE_MAX_CHARS) : null,
imageUrl,
}
}

Expand All @@ -66,7 +90,7 @@ async function fetchPreview(url: string): Promise<LinkPreview> {
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
return null
}
return parsePreview(await response.text())
return parsePreview(await response.text(), response.url)
}

export const GET = withRouteHandler(async (request: NextRequest) => {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/tools/agiloft/attach/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function mockSecureFetchResponse(body: {
statusText: '',
headers: new Headers(),
body: null,
url: 'https://example.com',
text: async () => body.text ?? '',
json: async () => body.json ?? {},
arrayBuffer: async () => new ArrayBuffer(0),
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/tools/agiloft/retrieve/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function mockSecureFetchResponse(body: {
statusText: '',
headers: body.headers ?? new Headers(),
body: null,
url: 'https://example.com',
text: async () => body.text ?? '',
json: async () => body.json ?? {},
arrayBuffer: async () => body.arrayBuffer ?? new ArrayBuffer(0),
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/tools/stt/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function mockSecureFetchResponse(body: { ok?: boolean; contentType?: string }) {
statusText: '',
headers: new Headers({ 'content-type': body.contentType ?? 'audio/mpeg' }),
body: null,
url: 'https://example.com',
text: async () => '',
json: async () => ({}),
arrayBuffer: async () => new ArrayBuffer(8),
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/link-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const linkPreviewResponseSchema = z.object({
title: z.string().nullable(),
description: z.string().nullable(),
siteName: z.string().nullable(),
imageUrl: z.string().nullable(),
})
.nullable(),
})
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/core/security/input-validation.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ export interface SecureFetchResponse {
statusText: string
headers: SecureFetchHeaders
body: ReadableStream<Uint8Array> | null
url: string
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
text: () => Promise<string>
json: () => Promise<unknown>
arrayBuffer: () => Promise<ArrayBuffer>
Expand Down Expand Up @@ -1170,6 +1171,7 @@ export async function secureFetchWithPinnedIP(
statusText: res.statusMessage || '',
headers: new SecureFetchHeaders(headersRecord, setCookieArray),
body: null,
url,
text: async () => '',
json: async () => ({}),
arrayBuffer: async () => new ArrayBuffer(0),
Expand Down Expand Up @@ -1249,6 +1251,7 @@ export async function secureFetchWithPinnedIP(
statusText: res.statusMessage || '',
headers: new SecureFetchHeaders(headersRecord, setCookieArray),
body,
url,
text: async () => (await readBodyAsBuffer()).toString('utf-8'),
json: async () => JSON.parse((await readBodyAsBuffer()).toString('utf-8')),
arrayBuffer: async () => {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/knowledge/documents/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function fakeResponse(
statusText: `status-${status}`,
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
body: null,
url: 'https://example.com',
text: async () => options.body ?? '',
json: async () => JSON.parse(options.body ?? '{}'),
arrayBuffer: async () => new ArrayBuffer(0),
Expand Down