diff --git a/apps/sim/app/api/link-preview/route.test.ts b/apps/sim/app/api/link-preview/route.test.ts
new file mode 100644
index 00000000000..7cbf71002d5
--- /dev/null
+++ b/apps/sim/app/api/link-preview/route.test.ts
@@ -0,0 +1,911 @@
+/**
+ * Tests for link preview API route
+ *
+ * @vitest-environment node
+ */
+
+import {
+ authMockFns,
+ createMockRedis,
+ createMockRequest,
+ inputValidationMock,
+ inputValidationMockFns,
+ redisConfigMockFns,
+ type MockRedis,
+} from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mockEnforceUserRateLimit = vi.fn()
+
+vi.mock('@/lib/core/rate-limiter/route-helpers', () => ({
+ enforceUserRateLimit: mockEnforceUserRateLimit,
+}))
+
+vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
+
+import { GET } from '@/app/api/link-preview/route'
+/**
+ * Creates a mock Response with a settable url property.
+ * Response.url is read-only, so we create a mock object that looks like a Response.
+ */
+function createMockResponse(
+ body: string | null,
+ init: ResponseInit & { url: string }
+): Response {
+ const { url, ...responseInit } = init
+ const response = new Response(body, responseInit)
+ // Create a wrapper that delegates to the real Response but allows url override
+ Object.defineProperty(response, 'url', {
+ value: url,
+ writable: false,
+ enumerable: true,
+ configurable: true,
+ })
+ return response
+}
+
+describe('Link Preview API Route', () => {
+ let mockRedis: MockRedis
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Default auth: authenticated user
+ authMockFns.mockGetSession.mockResolvedValue({
+ user: { id: 'user-123', email: 'test@example.com' },
+ session: { id: 'session-123' },
+ })
+
+ // Default rate limit: not limited
+ mockEnforceUserRateLimit.mockResolvedValue(null)
+
+ // Default redis: available
+ mockRedis = createMockRedis()
+ redisConfigMockFns.mockGetRedisClient.mockReturnValue(mockRedis as never)
+ })
+
+ describe('Authentication', () => {
+ it('should return 401 when user is not authenticated', async () => {
+ authMockFns.mockGetSession.mockResolvedValue(null)
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(401)
+ expect(data.error).toBe('Unauthorized')
+ })
+
+ it('should return 401 when session exists but no user', async () => {
+ authMockFns.mockGetSession.mockResolvedValue({ session: { id: 'session-123' } })
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(401)
+ expect(data.error).toBe('Unauthorized')
+ })
+ })
+
+ describe('Rate Limiting', () => {
+ it('should enforce rate limits for the user', async () => {
+ const rateLimitResponse = new Response(JSON.stringify({ error: 'Rate limit exceeded' }) {
+ status: 429,
+ })
+ mockEnforceUserRateLimit.mockResolvedValue(rateLimitResponse)
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(429)
+ expect(data.error).toBe('Rate limit exceeded')
+ expect(mockEnforceUserRateLimit).toHaveBeenCalledWith('link-preview', 'user-123')
+ })
+ })
+
+ describe('URL Validation', () => {
+ it('should return 400 when url parameter is missing', async () => {
+ const request = createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/link-preview')
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(data.error).toContain('url is required')
+ })
+
+ it('should return 400 when url parameter is empty', async () => {
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url='
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(data.error).toContain('url is required')
+ })
+
+ it('should return 400 when url parameter is not a valid URL', async () => {
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=not-a-url'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(data.error).toContain('url must be a valid URL')
+ })
+
+ it('should return 400 when url parameter exceeds maximum length', async () => {
+ const longUrl = 'https://example.com/' + 'a'.repeat(2100)
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ `http://localhost:3000/api/link-preview?url=${encodeURIComponent(longUrl)}`
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(data.error).toContain('url must be 2048 characters or less')
+ })
+
+ it('should return null preview for non-HTTPS URLs', async () => {
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=http://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeNull()
+ expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Image URL Extraction', () => {
+ it('should extract absolute og:image URL', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toEqual({
+ title: 'Test Title',
+ description: 'Test Description',
+ siteName: null,
+ imageUrl: 'https://example.com/image.jpg',
+ })
+ })
+
+ it('should extract absolute twitter:image URL when og:image is missing', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toEqual({
+ title: 'Test Title',
+ description: null,
+ siteName: null,
+ imageUrl: 'https://example.com/twitter-image.jpg',
+ })
+ })
+
+ it('should prefer og:image over twitter:image when both exist', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview?.imageUrl).toBe('https://example.com/og-image.jpg')
+ })
+
+ it('should resolve relative image URLs to absolute URLs', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com/page/article'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview?.imageUrl).toBe('https://example.com/images/relative-image.jpg')
+ })
+
+ it('should resolve protocol-relative image URLs', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview?.imageUrl).toBe('https://cdn.example.com/image.jpg')
+ })
+
+ it('should resolve relative image URLs against final URL after redirects', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://redirected.example.com/page',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview?.imageUrl).toBe('https://redirected.example.com/images/redirected-image.jpg')
+ })
+
+ it('should handle missing image URL', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toEqual({
+ title: 'Test Title',
+ description: 'Test Description',
+ siteName: null,
+ imageUrl: null,
+ })
+ })
+
+ it('should return null imageUrl when image URL resolution fails', async () => {
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toEqual({
+ title: 'Test Title',
+ description: null,
+ siteName: null,
+ imageUrl: null,
+ })
+ })
+
+ it('should drop image URLs that exceed maximum length', async () => {
+ const longImageUrl = 'https://example.com/' + 'a'.repeat(2100) + '.jpg'
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview?.imageUrl).toBeNull()
+ expect(data.preview?.title).toBe('Test Title')
+ })
+
+ it('should return preview with imageUrl only when no other metadata exists', async () => {
+ const html = `
+
+
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toEqual({
+ title: null,
+ description: null,
+ siteName: null,
+ imageUrl: 'https://example.com/image.jpg',
+ })
+ })
+ })
+
+ describe('Caching', () => {
+ it('should return cached preview when available', async () => {
+ const cachedPreview = {
+ title: 'Cached Title',
+ description: 'Cached Description',
+ siteName: 'Cached Site',
+ imageUrl: 'https://example.com/cached-image.jpg',
+ }
+
+ mockRedis.get.mockResolvedValue(JSON.stringify(cachedPreview))
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toEqual(cachedPreview)
+ expect(mockRedis.get).toHaveBeenCalled()
+ expect(inputValidationMockFns.mockSecureFetchWithValidation).not.toHaveBeenCalled()
+ })
+
+ it('should cache successful preview fetches', async () => {
+ mockRedis.get.mockResolvedValue(null)
+
+ const html = `
+
+
+
+ Test Page
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(mockRedis.set).toHaveBeenCalled()
+ const setCalls = (mockRedis.set as ReturnType).mock.calls
+ const cacheValue = JSON.parse(setCalls[0][1] as string)
+ expect(cacheValue).toEqual(data.preview)
+ expect(setCalls[0][3]).toBe(24 * 60 * 60) // TTL for successful fetch
+ })
+
+ it('should cache null previews with shorter TTL', async () => {
+ mockRedis.get.mockResolvedValue(null)
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse('Not Found', { status: 404, url: 'https://example.com' })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ await response.json()
+
+ expect(mockRedis.set).toHaveBeenCalled()
+ const setCalls = (mockRedis.set as ReturnType).mock.calls
+ expect(setCalls[0][3]).toBe(60 * 60) // TTL for failed fetch
+ })
+
+ it('should handle redis cache read failures gracefully', async () => {
+ mockRedis.get.mockRejectedValue(new Error('Redis connection failed'))
+
+ const html = `
+
+
+
+ Test Page
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeTruthy()
+ })
+
+ it('should handle redis cache write failures gracefully', async () => {
+ mockRedis.get.mockResolvedValue(null)
+ mockRedis.set.mockRejectedValue(new Error('Redis write failed'))
+
+ const html = `
+
+
+
+ Test Page
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeTruthy()
+ })
+
+ it('should work when redis is not available', async () => {
+ redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
+
+ const html = `
+
+
+
+ Test Page
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeTruthy()
+ })
+ })
+
+ describe('Fetch Failures', () => {
+ it('should return null preview when fetch fails', async () => {
+ mockRedis.get.mockResolvedValue(null)
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockRejectedValue(
+ new Error('Network error')
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeNull()
+ })
+
+ it('should return null preview for non-2xx status codes', async () => {
+ mockRedis.get.mockResolvedValue(null)
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse('Not Found', { status: 404, url: 'https://example.com' })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeNull()
+ })
+
+ it('should return null preview for non-HTML content types', async () => {
+ mockRedis.get.mockResolvedValue(null)
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse('{}', {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeNull()
+ })
+
+ it('should return null preview when no metadata is found', async () => {
+ mockRedis.get.mockResolvedValue(null)
+
+ const html = `
+
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toBeNull()
+ })
+ })
+
+ describe('Complete Metadata Extraction', () => {
+ it('should extract all metadata fields including imageUrl', async () => {
+ mockRedis.get.mockResolvedValue(null)
+
+ const html = `
+
+
+
+ Fallback Title
+
+
+
+
+
+
+ `
+
+ inputValidationMockFns.mockSecureFetchWithValidation.mockResolvedValue(
+ createMockResponse(html, {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ url: 'https://example.com',
+ })
+ )
+
+ const request = createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/link-preview?url=https://example.com'
+ )
+
+ const response = await GET(request)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.preview).toEqual({
+ title: 'Open Graph Title',
+ description: 'Open Graph Description',
+ siteName: 'Example Site',
+ imageUrl: 'https://example.com/og-image.jpg',
+ })
+ })
+ })
+})
diff --git a/apps/sim/app/api/link-preview/route.ts b/apps/sim/app/api/link-preview/route.ts
index 5dc8c230df1..a4ff7b71cb6 100644
--- a/apps/sim/app/api/link-preview/route.ts
+++ b/apps/sim/app/api/link-preview/route.ts
@@ -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
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 => {
@@ -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,
}
}
@@ -66,7 +90,7 @@ async function fetchPreview(url: string): Promise {
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) => {
diff --git a/apps/sim/app/api/tools/agiloft/attach/route.test.ts b/apps/sim/app/api/tools/agiloft/attach/route.test.ts
index 3f1ac2d1fae..a7dfd4b0fc8 100644
--- a/apps/sim/app/api/tools/agiloft/attach/route.test.ts
+++ b/apps/sim/app/api/tools/agiloft/attach/route.test.ts
@@ -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),
diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts
index efd435b5b04..994b5b6539e 100644
--- a/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts
+++ b/apps/sim/app/api/tools/agiloft/retrieve/route.test.ts
@@ -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),
diff --git a/apps/sim/app/api/tools/stt/route.test.ts b/apps/sim/app/api/tools/stt/route.test.ts
index 3413350e8e5..c95b1d0d01a 100644
--- a/apps/sim/app/api/tools/stt/route.test.ts
+++ b/apps/sim/app/api/tools/stt/route.test.ts
@@ -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),
diff --git a/apps/sim/lib/api/contracts/link-preview.ts b/apps/sim/lib/api/contracts/link-preview.ts
index 48b705c19e4..daeccda78ba 100644
--- a/apps/sim/lib/api/contracts/link-preview.ts
+++ b/apps/sim/lib/api/contracts/link-preview.ts
@@ -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(),
})
diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts
index e8ea4071577..ca44e901fed 100644
--- a/apps/sim/lib/core/security/input-validation.server.ts
+++ b/apps/sim/lib/core/security/input-validation.server.ts
@@ -462,6 +462,7 @@ export interface SecureFetchResponse {
statusText: string
headers: SecureFetchHeaders
body: ReadableStream | null
+ url: string
text: () => Promise
json: () => Promise
arrayBuffer: () => Promise
@@ -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),
@@ -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 () => {
diff --git a/apps/sim/lib/knowledge/documents/utils.test.ts b/apps/sim/lib/knowledge/documents/utils.test.ts
index afa2bf1483b..657f26b8b57 100644
--- a/apps/sim/lib/knowledge/documents/utils.test.ts
+++ b/apps/sim/lib/knowledge/documents/utils.test.ts
@@ -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),