PhoenixKitBoards.LinkPreview (PhoenixKitBoards v0.2.0)

Copy Markdown View Source

Turn a pasted URL into a preview card image.

Four steps, each of which can fail without taking the paste with it — the caller falls back to leaving the link on the canvas as text:

  1. fetch/1 — GET the page, under SSRF, size and time limits.
  2. metadata/2 — read its OpenGraph tags, falling back to <title>.
  3. scene/1 — lay the card out as an OpenFresco.Scene.
  4. svg/1 — emit it as a self-contained SVG.

Why SVG and not PNG

open_fresco can rasterise, but only through the optional :resvg NIF or a resvg / rsvg-convert / magick binary on PATH — and "magick is installed" is not the same as "magick can draw an SVG": without its rsvg delegate it falls back to an internal renderer that can't load fonts and emits zero bytes, while still reporting a rasterizer as available.

The browser already has a correct SVG renderer with the right fonts, so it does that half: the client draws this SVG onto a canvas, exports a PNG, and sends it through the ordinary image-upload path. No native dependency, and the card ends up in storage like any other pasted picture.

The hero image is inlined as a data: URL for the same reason it has to be: an SVG referencing a remote image would make the renderer fetch it, which is the hole the SSRF guard here exists to close.

Why the fetch is the careful part

The URL comes from whatever a user pasted, and the request is made by the server, from inside the network the server lives in. Unguarded that is a textbook SSRF: paste http://169.254.169.254/… and the reply is the cloud instance's credentials. So the host is resolved and every address it maps to is checked against the private, loopback, link-local and CGNAT ranges before a byte is sent — and again on each redirect, because a public host is free to redirect to a private one.

The hero image is fetched the same way, by the same walk — an og:image is as attacker-controlled as the page, and a hero that redirects into the private range would reach it just as surely.

The rest is proportion: a 12s budget for the whole unfurl, a 2MB cap enforced as the body streams rather than after it has all arrived, and HTML only. A link preview is a nicety, and none of it is worth a hung request or a page of arbitrary size buffered into memory.

Known limit

The guard resolves the host and then asks Req for the same host by name, so a DNS answer that changes between the two — rebinding — is not caught. Closing that means pinning the checked address and carrying the original host through as a header and SNI name, which is a lot of machinery for a surface only an admin can reach. It is a deliberate omission, not an oversight.

Summary

Functions

Unfurl url into {:ok, %{svg: binary, width: integer, height: integer}}.

Functions

unfurl(url)

@spec unfurl(String.t()) :: {:ok, map()} | {:error, term()}

Unfurl url into {:ok, %{svg: binary, width: integer, height: integer}}.

Any failure — unreachable, not HTML, no usable metadata — comes back as {:error, reason} and is the caller's cue to leave the pasted link as text.