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),