Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ Use this as a member convenience surface, not as a permanent document-management

## Link Previews

Chatto fetches link-preview metadata when a user composes a message with a URL. Preview images are persisted through the asset backend, so S3-backed deployments store preview media alongside uploaded files.
Chatto fetches link-preview metadata when a user composes a message with a URL. When the first URL directly returns a JPEG, PNG, GIF, or static WebP up to 5 MB, Chatto can import it as an ordinary room attachment. The URL remains in the message, and the imported image uses the normal attachment thumbnail, image viewer, limits, storage, deletion controls, and room Files index. Animated GIFs enter the same optional video-processing path as uploaded GIFs.

Direct image importing requires `message.attach`. Dismissing the imported image removes it from the draft; if the message is never sent, existing pending-attachment cleanup eventually removes the unclaimed asset. Repeated requests for the same actor, room, and source reuse the pending import, and each member can have at most 10 outstanding linked-image imports. OpenGraph and specialized link previews keep their separate preview-token and cache behavior.

The fetcher is designed to be SSRF-safe and caches successful and failed lookups for bounded periods. Operators do not need a separate preview worker.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Request to fetch server-side link preview metadata for a URL.
| Field | Type | Description |
| --- | --- | --- |
| `url` | `string` | URL to preview. |
| `room_id` | `optional string` | Room that will own a directly linked image imported as an attachment. When omitted, direct image URLs are not imported. |


<a id="chatto-api-v1-FetchLinkPreviewResponse"></a>
Expand All @@ -53,6 +54,7 @@ Result of fetching link preview metadata.
| --- | --- | --- |
| `preview` | [`LinkPreview`](/reference/connectrpc-api/types/#chatto-api-v1-LinkPreview) | Preview metadata, or absent when the URL cannot be previewed. |
| `preview_token` | `string` | Short-lived opaque token to pass to CreateMessage.link_preview_token when the user posts this preview. |
| `imported_attachment` | [`ImportedLinkAttachment`](/reference/connectrpc-api/types/#chatto-api-v1-ImportedLinkAttachment) | Pending room attachment imported when the URL directly serves an image. The client should include its ID in CreateMessage.attachment_asset_ids. |


<a id="chatto-api-v1-MessageService-CreateMessage"></a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ Current authenticated user's public profile plus self-only settings.
| `profile` | [`User`](#chatto-api-v1-User) | Public user fields for the authenticated user. |
| `has_password` | `bool` | Whether this account currently has a password sign-in credential. |

<a id="chatto-api-v1-ImportedLinkAttachment"></a>

### ImportedLinkAttachment

Pending room attachment imported from a directly linked image.

| Field | Type | Description |
| --- | --- | --- |
| `asset_id` | `string` | Stable pending attachment asset ID. |
| `filename` | `string` | Server-assigned filename for the imported image. |
| `content_type` | `string` | Detected image MIME type. |
| `size` | `int64` | Stored original size in bytes. |
| `width` | `int32` | Intrinsic image width. |
| `height` | `int32` | Intrinsic image height. |
| `preview_url` | `string` | Freshly authorized thumbnail URL for composer display. |

<a id="chatto-api-v1-LinkPreview"></a>

### LinkPreview
Expand Down
20 changes: 20 additions & 0 deletions apps/docs-website/src/generated/connectrpc-api/api.raw.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,7 @@ Request to fetch server-side link preview metadata for a URL.
| Field | Type | Description |
| --- | --- | --- |
| `url` | `string` | URL to preview. |
| `room_id` | `optional string` | Room that will own a directly linked image imported as an attachment. When omitted, direct image URLs are not imported. |


<a id="chatto-api-v1-FetchLinkPreviewResponse"></a>
Expand All @@ -1283,6 +1284,7 @@ Result of fetching link preview metadata.
| --- | --- | --- |
| `preview` | [`LinkPreview`](#chatto-api-v1-LinkPreview) | Preview metadata, or absent when the URL cannot be previewed. |
| `preview_token` | `string` | Short-lived opaque token to pass to CreateMessage.link_preview_token when the user posts this preview. |
| `imported_attachment` | [`ImportedLinkAttachment`](#chatto-api-v1-ImportedLinkAttachment) | Pending room attachment imported when the URL directly serves an image. The client should include its ID in CreateMessage.attachment_asset_ids. |


<a id="chatto-api-v1-MessageService-CreateMessage"></a>
Expand Down Expand Up @@ -4190,6 +4192,24 @@ Current authenticated user's public profile plus self-only settings.



<a id="chatto-api-v1-ImportedLinkAttachment"></a>

### ImportedLinkAttachment

Pending room attachment imported from a directly linked image.

| Field | Type | Description |
| --- | --- | --- |
| `asset_id` | `string` | Stable pending attachment asset ID. |
| `filename` | `string` | Server-assigned filename for the imported image. |
| `content_type` | `string` | Detected image MIME type. |
| `size` | `int64` | Stored original size in bytes. |
| `width` | `int32` | Intrinsic image width. |
| `height` | `int32` | Intrinsic image height. |
| `preview_url` | `string` | Freshly authorized thumbnail URL for composer display. |



<a id="chatto-api-v1-LinkPreview"></a>

### LinkPreview
Expand Down
7 changes: 7 additions & 0 deletions apps/frontend/e2e/fixtures/ogMockServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function ogPage(opts: {
* - GET /og-basic → OG page with title, description, site name (no image)
* - GET /og-with-image → OG page with title, description, site name, and og:image
* - GET /test-image.png → A minimal valid PNG image
* - GET /direct-image → PNG bytes with a deliberately misleading content type
*/
export function startOGMockServer(): Promise<OGMockServer> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -78,6 +79,12 @@ export function startOGMockServer(): Promise<OGMockServer> {
'Content-Length': String(TINY_PNG.length)
});
res.end(TINY_PNG);
} else if (url === '/direct-image') {
res.writeHead(200, {
'Content-Type': 'text/plain',
'Content-Length': String(TINY_PNG.length)
});
res.end(TINY_PNG);
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
Expand Down
50 changes: 50 additions & 0 deletions apps/frontend/e2e/link-previews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from './setup';
import { createAndLoginTestUser } from './fixtures/testUser';
import { startOGMockServer, type OGMockServer } from './fixtures/ogMockServer';
import { TIMEOUTS } from './constants';
import { ChatPage } from './pages';

let ogServer: OGMockServer;

Expand Down Expand Up @@ -51,6 +52,55 @@ test.describe('Bare-domain auto-linking', () => {
});

test.describe('Link previews', () => {
test('direct image becomes an attachment for the sender and another live client', async ({
page,
chatPage,
roomPage
}) => {
await createAndLoginTestUser(page);
await chatPage.goto();
await chatPage.enterRoom('general');

const receiverPage = await page.context().newPage();
const receiverChatPage = new ChatPage(receiverPage);
await receiverChatPage.goto();
await receiverChatPage.enterRoom('general');

const testUrl = `${ogServer.baseURL}/direct-image`;
const messageText = `Direct image ${testUrl}`;
await roomPage.waitForInputEditable();
await roomPage.messageInput.fill(messageText);
await expect(page.getByTestId('linked-image-attachment-preview')).toBeVisible({
timeout: TIMEOUTS.COMPLEX_OPERATION
});

await roomPage.messageInput.press('Control+Enter');

const receivedMessage = receiverPage.locator('[role="article"]', { hasText: messageText });
await expect(receivedMessage).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT });
const attachment = receivedMessage.getByRole('button', { name: 'View linked-image.png' });
await expect(attachment).toBeVisible({
timeout: TIMEOUTS.COMPLEX_OPERATION
});
await expect(receivedMessage.locator(`a[href="${testUrl}"]`)).toBeVisible();

await attachment.press('Enter');
const imageViewer = receiverPage.getByRole('dialog');
await expect(imageViewer.locator('img')).toBeVisible();
await expect(imageViewer.getByRole('link', { name: 'Open original' })).toHaveAttribute(
'href',
/\/assets\/files\//
);
await receiverPage.keyboard.press('Escape');

await receiverPage.getByRole('button', { name: 'Show files' }).click();
await expect(receiverPage.getByRole('navigation', { name: 'Files' })).toContainText(
'linked-image.png'
);

await receiverPage.close();
});

test('link preview card appears on posted message', async ({ page, chatPage, roomPage }) => {
await createAndLoginTestUser(page);
await chatPage.goto();
Expand Down
70 changes: 56 additions & 14 deletions apps/frontend/src/lib/api-client-tests/linkPreviews.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
SocialPostImage,
SocialPostPreview,
LinkPreview,
FetchLinkPreviewResponse
FetchLinkPreviewResponse,
ImportedLinkAttachment
} from '@chatto/api-types/api/v1/link_previews_pb';
import { Timestamp } from '@bufbuild/protobuf';
import { createLinkPreviewAPI } from '$lib/api-client/linkPreviews';
Expand Down Expand Up @@ -67,23 +68,61 @@ describe('createLinkPreviewAPI', () => {
bearerToken: 'remote-token'
});

await expect(api.fetchLinkPreview('https://example.com/story')).resolves.toMatchObject({
url: 'https://example.com/story',
previewToken: 'cht_LPpreviewtoken',
title: 'Story',
description: 'Description',
imageUrl: '/assets/preview.webp',
imageAssetId: 'asset_preview',
siteName: 'Example',
embedType: 'generic',
embedId: null
await expect(
api.fetchLinkPreview('https://example.com/story', 'room_1')
).resolves.toMatchObject({
kind: 'preview',
preview: {
url: 'https://example.com/story',
previewToken: 'cht_LPpreviewtoken',
title: 'Story',
description: 'Description',
imageUrl: '/assets/preview.webp',
imageAssetId: 'asset_preview',
siteName: 'Example',
embedType: 'generic',
embedId: null
}
});
expect(mocks.fetchLinkPreview).toHaveBeenCalledWith(
{ url: 'https://example.com/story' },
{ url: 'https://example.com/story', roomId: 'room_1' },
{ headers: { Authorization: 'Bearer remote-token' } }
);
});

it('maps a directly linked image to a pending attachment', async () => {
mocks.fetchLinkPreview.mockResolvedValue(
new FetchLinkPreviewResponse({
importedAttachment: new ImportedLinkAttachment({
assetId: 'asset_linked',
filename: 'linked-image.gif',
contentType: 'image/gif',
size: 1234n,
width: 320,
height: 180,
previewUrl: '/assets/files/asset_linked/image/600x314/contain?access=ticket'
})
})
);
const api = createLinkPreviewAPI({
baseUrl: 'https://remote.example.test/api/connect',
bearerToken: null
});

await expect(api.fetchLinkPreview('https://example.com/image', 'room_1')).resolves.toEqual({
kind: 'attachment',
attachment: {
assetId: 'asset_linked',
filename: 'linked-image.gif',
contentType: 'image/gif',
size: 1234n,
width: 320,
height: 180,
previewUrl: '/assets/files/asset_linked/image/600x314/contain?access=ticket'
}
});
});

it('returns null when the server has no preview', async () => {
mocks.fetchLinkPreview.mockResolvedValue(new FetchLinkPreviewResponse());

Expand Down Expand Up @@ -155,8 +194,10 @@ describe('createLinkPreviewAPI', () => {
await expect(
api.fetchLinkPreview('https://bsky.app/profile/bsky.app/post/example')
).resolves.toMatchObject({
embedType: 'bluesky',
socialPost: {
kind: 'preview',
preview: {
embedType: 'bluesky',
socialPost: {
provider: 'bluesky',
url: 'https://bsky.app/profile/bsky.app/post/example',
author: {
Expand All @@ -175,6 +216,7 @@ describe('createLinkPreviewAPI', () => {
text: 'Quoted words.',
images: [{ url: '/assets/quoted.webp', alt: 'Quoted attachment' }]
}
}
}
});
});
Expand Down
43 changes: 39 additions & 4 deletions apps/frontend/src/lib/api-client/linkPreviews.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { authHeaders, createChattoClient, handleAuthError } from './connect.js';
import { MessageService } from '@chatto/api-types/api/v1/messages_connect';
import type { LinkPreview } from '@chatto/api-types/api/v1/link_previews_pb';
import type {
ImportedLinkAttachment,
LinkPreview
} from '@chatto/api-types/api/v1/link_previews_pb';
import type { SocialPostPreviewView } from '$lib/render/linkPreviews';
export type LinkPreviewAPIConfig = {
serverId?: string;
Expand All @@ -22,21 +25,53 @@ export type ComposerLinkPreview = {
socialPost?: SocialPostPreviewView | null;
};

export type ComposerImportedAttachment = {
assetId: string;
filename: string;
contentType: string;
size: bigint;
width: number;
height: number;
previewUrl: string;
};

export type ComposerLinkResult =
| { kind: 'preview'; preview: ComposerLinkPreview }
| { kind: 'attachment'; attachment: ComposerImportedAttachment };

export function createLinkPreviewAPI(config: LinkPreviewAPIConfig) {
const client = createChattoClient(MessageService, config);
const headers = () => authHeaders(config);
return {
async fetchLinkPreview(url: string): Promise<ComposerLinkPreview | null> {
async fetchLinkPreview(url: string, roomId?: string): Promise<ComposerLinkResult | null> {
try {
const response = await client.fetchLinkPreview({ url }, { headers: headers() });
return composerLinkPreview(response.preview, response.previewToken);
const response = await client.fetchLinkPreview({ url, roomId }, { headers: headers() });
const attachment = composerImportedAttachment(response.importedAttachment);
if (attachment) return { kind: 'attachment', attachment };
const preview = composerLinkPreview(response.preview, response.previewToken);
return preview ? { kind: 'preview', preview } : null;
} catch (err) {
return handleAuthError(config, err);
}
}
};
}

function composerImportedAttachment(
asset: ImportedLinkAttachment | undefined
): ComposerImportedAttachment | null {
if (!asset?.assetId || !asset.previewUrl) return null;
return {
assetId: asset.assetId,
filename: asset.filename,
contentType: asset.contentType,
size: asset.size,
width: asset.width,
height: asset.height,
previewUrl: asset.previewUrl
};
}

function composerLinkPreview(
preview: LinkPreview | undefined,
previewToken: string
Expand Down
Loading