block / block/buzz

Inline link previews backed by Open Graph meta tags

Open
#3,369 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.