Developer Docs

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:

  1. The frontend decrypts the message body locally
  2. A URL regex (/https?:\/\/[^\s<>"']+/gi) extracts up to 3 URLs per message
  3. The frontend fires a linkPreview(url) GraphQL query for each detected URL
  4. 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 + 24h are 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:

  1. URL validation โ€” Must start with http:// or https://, max 2048 characters
  2. Cache lookup โ€” SHA-256 hash of the URL checks uchat_link_previews (24h TTL)
  3. Rate limiting โ€” Redis INCR + EXPIRE pattern: 10 scrapes/min/user
  4. HTTP fetch โ€” reqwest with custom MallnlineBot/1.0 user agent, 5s timeout, max 3 redirects
  5. HTML parsing โ€” scraper crate extracts OG metadata from <meta> tags:
    • og:title (fallback: <title>)
    • og:description (fallback: <meta name="description">)
    • og:image
    • og:site_name
  6. Truncation โ€” Title โ‰ค 200 chars, description โ‰ค 500 chars, site name โ‰ค 100 chars
  7. Cache upsert โ€” INSERT ... ON CONFLICT DO UPDATE to 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: The scraper::Html type is !Send (it uses Cell<usize> internally). The resolver wraps all parsing in a block scope so the Html value is dropped before the next .await point (the database upsert). This pattern is required by async-graphql's ContainerType::resolve_field Send bound.

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 linkPreviews or linkPreviewLoading are 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