When a uChat message contains a URL, the platform automatically generates a rich preview card showing the linked page's title, description, image, and site name. This document covers the full-stack architecture of the link preview system โ from the backend OG scraper through the frontend card renderer.
E2EE-Safe Design
uChat messages are end-to-end encrypted. The server never reads the message body, which means URL detection cannot happen server-side. The link preview flow is client-triggered:
- The frontend decrypts the message body locally
- A URL regex (
/https?:\/\/[^\s<>"']+/gi) extracts up to 3 URLs per message - The frontend fires a
linkPreview(url)GraphQL query for each detected URL - The backend scrapes the URL, caches the result, and returns OG metadata
This design ensures the server has no visibility into message content โ it only receives the URLs that the client explicitly requests previews for.
Backend Architecture
Database Schema
The uchat_link_previews table stores scraped OG metadata with a 24-hour TTL:
CREATE TABLE IF NOT EXISTS uchat_link_previews (
url_hash VARCHAR(64) PRIMARY KEY, -- SHA-256 of normalized URL
url TEXT NOT NULL,
title TEXT,
description TEXT,
image_url TEXT,
site_name TEXT,
scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- Migration:
014_create_link_previews.sql - Cache Key: SHA-256 hash of the normalized URL (lowercased, trimmed, max 2048 chars)
- TTL: 24 hours โ entries older than
scraped_at + 24hare re-scraped on next request
GraphQL API
type Query {
linkPreview(url: String!): LinkPreview
}
type LinkPreview {
url: String!
title: String
description: String
imageUrl: String
siteName: String
}
The resolver handles the full lifecycle:
- URL validation โ Must start with
http://orhttps://, max 2048 characters - Cache lookup โ SHA-256 hash of the URL checks
uchat_link_previews(24h TTL) - Rate limiting โ Redis
INCR+EXPIREpattern: 10 scrapes/min/user - HTTP fetch โ
reqwestwith customMallnlineBot/1.0user agent, 5s timeout, max 3 redirects - HTML parsing โ
scrapercrate extracts OG metadata from<meta>tags:og:title(fallback:<title>)og:description(fallback:<meta name="description">)og:imageog:site_name
- Truncation โ Title โค 200 chars, description โค 500 chars, site name โค 100 chars
- Cache upsert โ
INSERT ... ON CONFLICT DO UPDATEto persist results
Security Guardrails
| Guardrail | Value | Purpose |
|---|---|---|
| HTTP timeout | 5 seconds | Prevent slow scrapes from blocking resolvers |
| HTML parse limit | 100 KB | Prevent memory exhaustion on huge pages |
| Max redirects | 3 | Mitigate SSRF redirect chains |
| Rate limit | 10/min/user | Prevent abuse and resource exhaustion |
| URL length | 2,048 chars | Prevent oversized cache keys |
| Content-Type check | text/html required |
Skip non-HTML responses |
| User agent | MallnlineBot/1.0 |
Identify the scraper for robots.txt compliance |
Dependencies
Added to apps/uchat/Cargo.toml:
scraper = "0.21" # HTML parsing & CSS selector queries
sha2 = "0.10" # SHA-256 URL hashing for cache keys
urlencoding = "2" # URL-safe encoding for proxy patterns
Note on
scraper: Thescraper::Htmltype is!Send(it usesCell<usize>internally). The resolver wraps all parsing in a block scope so theHtmlvalue is dropped before the next.awaitpoint (the database upsert). This pattern is required byasync-graphql'sContainerType::resolve_fieldSendbound.
Redis Rate Limiting
Rate limiting uses a simple INCR + EXPIRE pattern via the Redis connection:
let rate_key = format!("uchat:linkpreview:rate:{}", user_id);
let count: i64 = redis::cmd("INCR").arg(&rate_key).query_async(&mut conn).await?;
if count == 1 {
redis::cmd("EXPIRE").arg(&rate_key).arg(60).query_async(&mut conn).await?;
}
if count > 10 {
return Err("Rate limit exceeded");
}
Frontend Architecture
Store Integration
The uchat store manages link preview state:
// State
let linkPreviews = $state<Map<string, LinkPreview>>(new Map());
let linkPreviewLoading = $state<Set<string>>(new Set());
// Action
async function fetchLinkPreview(url: string): Promise<LinkPreview | null> {
if (linkPreviews.has(url)) return linkPreviews.get(url);
if (linkPreviewLoading.has(url)) return null;
// ... fetch and cache
}
Key behaviors:
- Deduplication: URLs already in
linkPreviewsorlinkPreviewLoadingare skipped - Reactivity: Both Maps are reassigned (
new Map(...)) after mutation to trigger Svelte 5 reactivity - Graceful failure: Failed fetches log a warning and return
nullโ no error state shown to the user
URL Detection
A Svelte 5 $effect watches uchatStore.messages and auto-fetches previews:
$effect(() => {
const msgs = uchatStore.messages;
for (const msg of msgs) {
if (msg.decryptedBody && msg.msgType !== 'FILE') {
const urls = extractUrls(msg.decryptedBody);
for (const url of urls) {
if (!uchatStore.linkPreviews.has(url)) {
uchatStore.fetchLinkPreview(url);
}
}
}
}
});
Card Rendering
Preview cards render below the message body using a vertical layout:
- OG image (if available) โ full-width, max 160px height,
object-fit: cover - Site name โ uppercase, small text, muted
- Title โ bold, max 2 lines, clamped
- Description โ muted, max 2 lines, clamped
The card is wrapped in an <a> tag pointing to the original URL, with rel="noopener noreferrer".
URLs in the message text are auto-linked via {@html} regex replacement, styled with the platform accent color.
CSS
The preview card uses an accent border-left (3px, --mall-accent) and adapts to dark/light mode:
| Mode | Background | Border |
|---|---|---|
| Dark | rgba(0,0,0,0.15) |
rgba(255,255,255,0.06) |
| Light | rgba(0,0,0,0.04) |
rgba(0,0,0,0.08) |
Configuration
| Variable | Default | Description |
|---|---|---|
REDIS_URL |
redis://localhost:6379 |
Redis instance for rate limiting |
DATABASE_URL |
โ | Postgres connection for uchat_link_previews cache |
No additional environment variables are required โ link previews are enabled by default for all authenticated users.
Future: Image Proxy
Currently, OG images are loaded directly from their source URLs. A future iteration will route images through /api/media/proxy?url=<encoded_url> to prevent IP leakage to third-party servers. This endpoint will:
- Validate the URL is an image (
Content-Type: image/*) - Cache the image in the media service
- Serve it from the platform's CDN
Related
- uChat โ E2EE Messenger โ Parent subgraph documentation covering the full messaging architecture
- uChat Client SDK & Interface โ Frontend store, WebSocket, and crypto integration
- Media Infrastructure โ Future image proxy endpoint for OG images
- Search Engine Administration โ Platform-wide content scraping and indexing patterns
- User Guide: Link Previews in uChat โ Visitor-facing guide to URL previews in messages