Inline link previews backed by Open Graph meta tags
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## 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 `` for, in priority order:
- `og:title` → fall back to `twitter:title` → ``
- `og:description` → `twitter:description` → `<meta name="description">`
- `og:image` → `twitter: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](https://docs.slack.dev/messaging/unfurling-links-in-messages/)):
- **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](https://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 |
Contributor guide
Research direction
The issue names no files, tests, or entry points. Begin by identifying the message-rendering and relay/server-fetch paths, then resolve the open questions around oEmbed, caching, opt-out behavior, and sender controls. Done means a scoped design and implementation plan covering safe fetching, parsing, rendering, proxying, limits, and tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend, frontend, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100