Link previews the recipient never fetches (§6.12)

🔴 **The obvious implementation is the one that must not be built.** Having each
recipient fetch the URL to draw a card tells the site the reader's IP address,
that they received a link, and roughly when they opened it — once per member on
a group message. A messenger that hides the message and then has every reader
announce its contents to a third party has given away what it was protecting.

So the SENDER fetches, and the preview travels inside the ciphertext. The
receiver renders from those bytes and contacts nobody.

⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is about
to share that URL, from this address. The setting exists for that, and the copy
says who learns what rather than implying it is free.

🔴 **The host is shown separately and derived from the URL**, because title and
description come from a page the sender did not write — a card claiming one
destination while the link points at another is a phishing primitive. Both
fields are bounded on the way IN as well as line-limited at render, so the cap
does not depend on a future renderer remembering one.

⚠️ `Content.text` still returns the raw body byte-for-byte when there is no
preview, so an ordinary message is unchanged on the wire and older clients see
exactly what they saw before.

⚠️ The fetch is short-timeout and best-effort, and runs on the IO context. A
preview is a decoration and must never be able to delay a send — every failure
path ends as a message with a plain URL in it.

engine 168, client 96, app 40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 12:45:09 -04:00
co-authored by Claude Opus 4.8
parent 7f4fa1bf2b
commit dc1be35816
7 changed files with 334 additions and 8 deletions
@@ -0,0 +1,96 @@
package io.flamenet.client
import io.flamenet.e2e.Content
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Fetches a preview for a link the user is about to send (spec §6.12).
*
* 🔴 **This runs on the SENDER's device, and the result travels inside the
* ciphertext.** The obvious implementation — each recipient fetching the URL to
* draw a card — is the one that must not be built: it tells the site the
* reader's IP address, that they received a link, and roughly when they opened
* it, once per member on a group message. A messenger that hides the message
* and then has every reader announce its contents to a third party has given
* away what it was protecting.
*
* ⚠️ Sender-side is a smaller leak, not no leak: the site learns somebody is
* about to share that URL, from this address, before the message is sent. That
* is what the setting is for.
*/
object LinkPreviewFetcher {
/** A short timeout: a preview is a decoration and must never delay a send. */
private val http = OkHttpClient.Builder()
.connectTimeout(4, TimeUnit.SECONDS)
.readTimeout(4, TimeUnit.SECONDS)
.callTimeout(6, TimeUnit.SECONDS)
.build()
/** 64 KiB is plenty for a `<head>`, and a cap is what bounds a hostile page. */
private const val MAX_BYTES = 64 * 1024
private val URL_RE = Regex("""https?://[^\s<>"']+""", RegexOption.IGNORE_CASE)
/** The first http(s) URL in a message, or null. */
fun firstUrl(text: String): String? = URL_RE.find(text)?.value?.trimEnd('.', ',', ')')
/**
* Fetch and parse Open Graph tags. Null for anything unusual — no title, a
* refusal, a timeout — each of which leaves a message with a plain URL in
* it, which is a perfectly good thing to be.
*/
fun fetch(url: String): Content.LinkPreview? = runCatching {
val request = Request.Builder()
.url(url)
.header("Accept", "text/html")
// Identifies as a link preview rather than impersonating a browser:
// a site that wants to refuse these should be able to.
.header("User-Agent", "FlamenetLinkPreview/1.0")
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
val head = response.body?.source()?.let { source ->
source.request(MAX_BYTES.toLong())
source.buffer.snapshot(minOf(MAX_BYTES.toLong(), source.buffer.size).toInt())
.utf8()
} ?: return null
val title = meta(head, "og:title") ?: titleTag(head) ?: return null
val desc = meta(head, "og:description") ?: metaName(head, "description") ?: ""
Content.LinkPreview.of(url, title, desc)
}
}.getOrNull()
// ⚠️ Regex over a bounded prefix, deliberately. A full HTML parser for two
// attributes is a dependency and an attack surface; this reads what it
// recognises and gives up otherwise, which is the correct failure here.
private fun meta(html: String, property: String): String? =
Regex("""<meta[^>]+property=["']${Regex.escape(property)}["'][^>]+content=["']([^"']*)["']""",
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
?: Regex("""<meta[^>]+content=["']([^"']*)["'][^>]+property=["']${Regex.escape(property)}["']""",
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
private fun metaName(html: String, name: String): String? =
Regex("""<meta[^>]+name=["']${Regex.escape(name)}["'][^>]+content=["']([^"']*)["']""",
RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1)?.let(::clean)
private fun titleTag(html: String): String? =
Regex("""<title[^>]*>([^<]*)</title>""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.let(::clean)
/**
* The handful of entities that actually appear in titles. Anything else is
* left alone — a literal `&copy;` is a cosmetic flaw, and expanding
* arbitrary entities is how a parser grows teeth.
*/
private fun clean(s: String): String = s
.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", "\"").replace("&#39;", "'").replace("&apos;", "'")
.replace("&nbsp;", " ")
.trim()
.takeIf { it.isNotEmpty() } ?: ""
}