Skip to content

Inline link previews backed by Open Graph meta tags #3369

Description

@adamazad

Problem

Links posted in channels render as bare URLs. When someone shares a fig (an HTML mockup URL), teammates see only the raw link — no title, no image, no context. They have to open a browser tab to know whether the link is worth looking at.

From #general:

shouldn't figs show inline tho?

This applies to any shared link, not just figs: PRs, docs, dashboards, articles.

Proposal

Render an inline link preview card under a message that contains a URL, populated by inspecting the target page's Open Graph meta tags.

Fetch + parse

  • Server-side fetch of the URL (never from the client — avoids leaking member IPs to arbitrary hosts and lets us cache once per URL).
  • Parse <head> for, in priority order:
    • og:title → fall back to twitter:title<title>
    • og:descriptiontwitter:description<meta name="description">
    • og:imagetwitter:image<link rel="icon">
    • og:site_name, og:type, og:url (canonical)
  • Respect <meta name="robots" content="noindex">? No — OG tags are explicitly for this. But do respect an explicit opt-out if one is agreed.

Card shape

  • Favicon + site name, title (1 line), description (2 lines clamped), image thumbnail.
  • og:type of image or an image content-type → render the image directly instead of a card.
  • No OG tags and no <title> → render nothing, keep the plain link. Never show a broken/empty card.

Safety / limits (these are the parts that bite if skipped)

  • Block private address space: reject resolved IPs in 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1, unique-local — SSRF via a pasted link is the main risk here.
  • Cap redirects (~5), response size (~1 MB of HTML), and fetch timeout (~5s).
  • Only fetch http/https.
  • Proxy and re-encode preview images rather than hotlinking, so the recipient's client never talks to the origin.
  • Cache negative results too, so a dead link isn't refetched on every render.

Scope

  • First link in a message only, to start.
  • Sender can remove the preview from their own message.

Open questions

  • Cache TTL — figs change while you iterate on them, so a long TTL shows a stale thumbnail. Short TTL, or a ?v= cache-buster convention on fig URLs?
  • Do figs already emit OG tags? If not, that's a prerequisite on the fig-rendering side and worth splitting into its own issue.
  • Preview on the relay (once, shared) vs. per-client? Relay-side is cheaper and consistent, but means the relay makes outbound requests to arbitrary hosts.

Filed from #general at the request of @adamazad.

Prior art: how Slack does it

Worth copying, since Slack has run this at scale for a decade.

Two layers. Slack splits unfurling into classic and app unfurls (docs):

  • Classic unfurl — the default. Slack crawls the URL and builds the preview itself from OpenGraph / X (Twitter) Card metadata. This is the layer this issue is about.
  • App unfurl — an app registers up to five domains; when a matching link is posted, the app gets a link_shared event and calls chat.unfurl to attach custom preview content instead of the crawled one. Requires links:read + links:write. Domains must include a TLD, IP addresses can't be matched, and adding/removing domains requires app reinstall.

The app-unfurl layer is the interesting one for us: figs are a first-party surface, so rather than scraping our own OG tags we could register the fig domain and render a real preview (live thumbnail, dimensions, "open in Figura") from data we already hold. Same shape as Slack's own GitHub/Figma integrations. Worth splitting into a follow-up issue once classic unfurl lands.

Fetch behavior (api.slack.com/robots):

  • Three distinct user agents, so origins can identify and rate-limit them separately:
    • Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)
    • Slack-ImgProxy 0.19 (+https://api.slack.com/robots)
    • Slackbot 1.0 (+https://api.slack.com/robots)
  • Fetches "as little of the page as it can (using HTTP Range headers) to extract meta tags" — a ranged GET, not a full download. Better than the flat 1 MB cap proposed above; adopt this.
  • Metadata priority: oEmbed, Twitter Card, and Open Graph. Note oEmbed is in that list and is missing from the proposal above — it's how rich embeds (video players, live content) work, and it's a discovery <link rel="alternate" type="application/json+oembed"> in the head. Worth supporting in v1 or explicitly deferring.
  • A separate image proxy (Slack-ImgProxy) fetches preview images, confirming the "proxy, don't hotlink" call above.
  • Cached globally across the service for around 30 minutes.
  • Slack does not honor robots.txt — their stated reason: they tried, "received too many complaints from our users because a great portion of the Internet is inaccessible to crawlers," and they don't consider themselves a crawler since fetches are user-initiated. Opt-out is a manual request to Slack. We should make our own call here rather than inherit it by default.

Sender control. unfurl_links (text content) and unfurl_media (images/video/audio) are per-message params on send. Also: Slack never unfurls a link whose display label is a complete substring of the URL minus the protocol.

What this changes in the proposal above

Proposal said Slack does Take
Cap response at ~1 MB Ranged GET for just the <head> Adopt ranged GET
OG → Twitter → <title> oEmbed + Twitter Card + OG Add oEmbed, or defer explicitly
Cache TTL open question ~30 min, global 30 min is a reasonable default — and it bounds the stale-fig problem to half an hour
Proxy images Dedicated image-proxy service w/ own UA Confirms the call; use a distinct UA per fetch type
Two params: unfurl_links / unfurl_media Split the sender-side toggle in two rather than one on/off
Registered-domain app unfurls Follow-up issue: first-party fig previews

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions