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:
co-authored by
Claude Opus 4.8
parent
7f4fa1bf2b
commit
dc1be35816
@@ -1820,6 +1820,7 @@ private fun Bubble(
|
|||||||
else MaterialTheme.colorScheme.onSurface,
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
line.link?.let { LinkPreviewCard(it, mine = line.mine) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2708,6 +2709,7 @@ private fun ColumnScope.MediaQualitySection() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val settings = remember { MediaSettings(context) }
|
val settings = remember { MediaSettings(context) }
|
||||||
var full by remember { mutableStateOf(settings.fullQuality) }
|
var full by remember { mutableStateOf(settings.fullQuality) }
|
||||||
|
var previews by remember { mutableStateOf(settings.linkPreviews) }
|
||||||
|
|
||||||
Text("Photos", style = MaterialTheme.typography.titleMedium)
|
Text("Photos", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
@@ -2723,6 +2725,29 @@ private fun ColumnScope.MediaQualitySection() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("Preview links you send", fontSize = 16.sp, modifier = Modifier.weight(1f))
|
||||||
|
Switch(
|
||||||
|
checked = previews,
|
||||||
|
onCheckedChange = { on -> settings.linkPreviews = on; previews = on },
|
||||||
|
modifier = Modifier.testTag("link-previews"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Says exactly who learns what. The receiving side is the part people
|
||||||
|
// assume is leaky and is the part that is not.
|
||||||
|
Text(
|
||||||
|
"Link previews are fetched by THIS phone before the message is sent, and " +
|
||||||
|
"travel inside the encryption — whoever you send to never contacts the " +
|
||||||
|
"site, so the link cannot tell anyone that they read it. Turning this off " +
|
||||||
|
"stops this phone fetching anything either.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Palette.SecondaryText,
|
||||||
|
)
|
||||||
|
|
||||||
// ⚠️ Says what the ceiling is rather than implying there is none. "Full
|
// ⚠️ Says what the ceiling is rather than implying there is none. "Full
|
||||||
// quality" that silently failed on a large photo would be a worse answer
|
// quality" that silently failed on a large photo would be a worse answer
|
||||||
// than the compression it replaced.
|
// than the compression it replaced.
|
||||||
@@ -2903,3 +2928,52 @@ private fun ColumnScope.ScheduledBanner(
|
|||||||
) { Text("Cancel") }
|
) { Text("Cancel") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card under a message containing a link (spec §6.12).
|
||||||
|
*
|
||||||
|
* 🔴 **Drawn entirely from bytes the sender put in the ciphertext.** Nothing
|
||||||
|
* here fetches: a card each recipient fetched would tell the site the reader's
|
||||||
|
* IP, that they received a link, and roughly when they opened it — once per
|
||||||
|
* member on a group message.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LinkPreviewCard(
|
||||||
|
preview: io.flamenet.e2e.Content.LinkPreview,
|
||||||
|
mine: Boolean,
|
||||||
|
) {
|
||||||
|
val onBubble = if (mine) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurface
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = Space.xs)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("link-preview"),
|
||||||
|
) {
|
||||||
|
// 🔴 **The host, always, and separately from the title.** Title and
|
||||||
|
// description come from a page the sender did not write, so a card
|
||||||
|
// claiming one destination while the link points at another is a
|
||||||
|
// phishing primitive. The host is the part that cannot lie.
|
||||||
|
Text(preview.host, fontSize = 11.sp, color = onBubble.copy(alpha = 0.7f))
|
||||||
|
// ⚠️ Plain text, never markup, and line-limited. Both fields are
|
||||||
|
// attacker-controlled; they are bounded on the way in as well, so this
|
||||||
|
// is the second of two limits rather than the only one.
|
||||||
|
Text(
|
||||||
|
preview.title,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = onBubble,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (preview.desc.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
preview.desc,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = onBubble.copy(alpha = 0.7f),
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,8 +24,21 @@ class MediaSettings(context: Context) {
|
|||||||
get() = prefs.getBoolean(FULL_QUALITY, false)
|
get() = prefs.getBoolean(FULL_QUALITY, false)
|
||||||
set(value) = prefs.edit().putBoolean(FULL_QUALITY, value).apply()
|
set(value) = prefs.edit().putBoolean(FULL_QUALITY, value).apply()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a preview card when sending a link.
|
||||||
|
*
|
||||||
|
* ⚠️ Fetching happens on THIS device before the message goes, so the site
|
||||||
|
* learns somebody is about to share that URL, from this address. The
|
||||||
|
* recipient never fetches anything (§6.12) — this end does, and that is
|
||||||
|
* what the setting is for.
|
||||||
|
*/
|
||||||
|
var linkPreviews: Boolean
|
||||||
|
get() = prefs.getBoolean(LINK_PREVIEWS, true)
|
||||||
|
set(value) = prefs.edit().putBoolean(LINK_PREVIEWS, value).apply()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS = "media-settings"
|
private const val PREFS = "media-settings"
|
||||||
private const val FULL_QUALITY = "full_quality"
|
private const val FULL_QUALITY = "full_quality"
|
||||||
|
private const val LINK_PREVIEWS = "link_previews"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ private const val CALL_PRIVACY_ACKNOWLEDGED = "call.privacy.acknowledged"
|
|||||||
|
|
||||||
class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
/** Photo quality and link-preview preferences. */
|
||||||
|
private val mediaSettings = MediaSettings(app)
|
||||||
|
|
||||||
sealed interface Phase {
|
sealed interface Phase {
|
||||||
/** Reading the vault. Brief, but it decides which screen is first. */
|
/** Reading the vault. Brief, but it decides which screen is first. */
|
||||||
data object Starting : Phase
|
data object Starting : Phase
|
||||||
@@ -821,6 +824,19 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview for the first link in [text], when the user wants them.
|
||||||
|
*
|
||||||
|
* ⚠️ Runs on the caller's IO context — never the main thread. It is a
|
||||||
|
* network fetch, and a preview must never be able to delay a send from the
|
||||||
|
* user's point of view, let alone block the UI.
|
||||||
|
*/
|
||||||
|
private fun linkPreviewFor(text: String): io.flamenet.e2e.Content.LinkPreview? {
|
||||||
|
if (!mediaSettings.linkPreviews) return null
|
||||||
|
val url = io.flamenet.client.LinkPreviewFetcher.firstUrl(text) ?: return null
|
||||||
|
return io.flamenet.client.LinkPreviewFetcher.fetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
// -- Scheduled send -------------------------------------------------------
|
// -- Scheduled send -------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1752,7 +1768,14 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val group = chat.group
|
val group = chat.group
|
||||||
if (group != null) current.sendGroup(group, text)
|
if (group != null) current.sendGroup(group, text)
|
||||||
else current.send(chat.peerId!!, text)
|
// §6.12 — fetched HERE, on the sender's device, and carried
|
||||||
|
// inside the ciphertext. The recipient never touches the URL: a
|
||||||
|
// card each of them fetched would tell the site who read the
|
||||||
|
// link and when, once per member of a group.
|
||||||
|
//
|
||||||
|
// ⚠️ Best-effort. Every failure path ends as a message with a
|
||||||
|
// plain URL in it, which is a perfectly good thing to be.
|
||||||
|
else current.send(chat.peerId!!, text, linkPreviewFor(text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.onSuccess { sent ->
|
result.onSuccess { sent ->
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ class Session(
|
|||||||
* field existed — absence of a claim, not a claim of failure.
|
* field existed — absence of a claim, not a claim of failure.
|
||||||
*/
|
*/
|
||||||
val delivery: Delivery? = null,
|
val delivery: Delivery? = null,
|
||||||
|
/** A sender-generated preview of a link in this message (§6.12). */
|
||||||
|
val link: Content.LinkPreview? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Where an outbound message got to. */
|
/** Where an outbound message got to. */
|
||||||
@@ -102,6 +104,7 @@ class Session(
|
|||||||
},
|
},
|
||||||
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
|
delivery = m.optString("delivery").takeIf { it.isNotBlank() }
|
||||||
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
|
?.let { runCatching { Delivery.valueOf(it) }.getOrNull() },
|
||||||
|
link = m.optJSONObject("lnk")?.let { Content.LinkPreview.fromJson(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +118,7 @@ class Session(
|
|||||||
author: Long = 0,
|
author: Long = 0,
|
||||||
at: Long = System.currentTimeMillis(),
|
at: Long = System.currentTimeMillis(),
|
||||||
delivery: Delivery? = null,
|
delivery: Delivery? = null,
|
||||||
|
link: Content.LinkPreview? = null,
|
||||||
) {
|
) {
|
||||||
vault.append(threadId, JSONObject()
|
vault.append(threadId, JSONObject()
|
||||||
.put("author", author)
|
.put("author", author)
|
||||||
@@ -125,7 +129,8 @@ class Session(
|
|||||||
it.put("stk", JSONObject().put("pack", sticker.pack).put("id", sticker.id))
|
it.put("stk", JSONObject().put("pack", sticker.pack).put("id", sticker.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) })
|
.also { if (attachment != null) it.put("kind", attachment.kind).put("att", attachment.toJson()) }
|
||||||
|
.also { if (link != null) it.put("lnk", link.toJson()) })
|
||||||
|
|
||||||
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
// 🔴 A reply arriving cancels the reminder, and it is done HERE because
|
||||||
// this is the one place every inbound message passes through. Hanging it
|
// this is the one place every inbound message passes through. Hanging it
|
||||||
@@ -273,10 +278,26 @@ class Session(
|
|||||||
* Recording with the outcome is what makes neither possible. See
|
* Recording with the outcome is what makes neither possible. See
|
||||||
* [Line.delivered].
|
* [Line.delivered].
|
||||||
*/
|
*/
|
||||||
fun send(peerUserId: Long, text: String): Conversations.Sent {
|
fun send(
|
||||||
val sent = conversations.send(peerUserId, text)
|
peerUserId: Long,
|
||||||
|
text: String,
|
||||||
|
/**
|
||||||
|
* A preview the CALLER already fetched (§6.12).
|
||||||
|
*
|
||||||
|
* ⚠️ Passed in rather than fetched here: the fetch needs the user's
|
||||||
|
* setting, which needs an Android context, and this layer deliberately
|
||||||
|
* has none. It also keeps a network round trip out of the send path
|
||||||
|
* for every message that has no link in it.
|
||||||
|
*/
|
||||||
|
link: Content.LinkPreview? = null,
|
||||||
|
): Conversations.Sent {
|
||||||
|
// With no preview this is the raw body, byte for byte, exactly as
|
||||||
|
// before — `Content.text` only builds an envelope when it has to.
|
||||||
|
val plaintext = Content.text(text, link = link)
|
||||||
|
val sent = conversations.send(peerUserId, plaintext)
|
||||||
val at = System.currentTimeMillis()
|
val at = System.currentTimeMillis()
|
||||||
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
record(Vault.threadForUser(peerUserId), text, mine = true, at = at,
|
||||||
|
link = link,
|
||||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||||
// 🔴 Queued, not abandoned. A friend who is simply signed out should
|
// 🔴 Queued, not abandoned. A friend who is simply signed out should
|
||||||
// not cost the sender a reminder to try again later.
|
// not cost the sender a reminder to try again later.
|
||||||
|
|||||||
@@ -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 `©` is a cosmetic flaw, and expanding
|
||||||
|
* arbitrary entities is how a parser grows teeth.
|
||||||
|
*/
|
||||||
|
private fun clean(s: String): String = s
|
||||||
|
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
.replace(""", "\"").replace("'", "'").replace("'", "'")
|
||||||
|
.replace(" ", " ")
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotEmpty() } ?: ""
|
||||||
|
}
|
||||||
@@ -51,6 +51,14 @@ data class Content(
|
|||||||
val call: CallSignal? = null,
|
val call: CallSignal? = null,
|
||||||
/** Set iff this is a sticker: which pack, and which one in it. */
|
/** Set iff this is a sticker: which pack, and which one in it. */
|
||||||
val sticker: Sticker? = null,
|
val sticker: Sticker? = null,
|
||||||
|
/**
|
||||||
|
* A preview of a link in this message, generated by the SENDER (§6.12).
|
||||||
|
*
|
||||||
|
* ⚠️ Rendered from these bytes and nothing else. The receiver never fetches
|
||||||
|
* the URL — that would tell the site who read the link and when, once per
|
||||||
|
* member on a group message.
|
||||||
|
*/
|
||||||
|
val link: LinkPreview? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,6 +165,37 @@ data class Content(
|
|||||||
val needsNewerClient: Boolean
|
val needsNewerClient: Boolean
|
||||||
get() = kind !in Kind.KNOWN && !isControl
|
get() = kind !in Kind.KNOWN && !isControl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link preview the sender generated (§6.12).
|
||||||
|
*
|
||||||
|
* ⚠️ [title] and [desc] come from a page the sender did not write. Bounded
|
||||||
|
* here rather than at render, so the cap does not depend on every future
|
||||||
|
* renderer remembering one, and shown beside [host] — a card claiming one
|
||||||
|
* destination while [url] points at another is a phishing primitive.
|
||||||
|
*/
|
||||||
|
data class LinkPreview(val url: String, val title: String, val desc: String) {
|
||||||
|
val host: String
|
||||||
|
get() = runCatching { java.net.URI(url).host }.getOrNull() ?: url
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_TITLE = 120
|
||||||
|
const val MAX_DESC = 200
|
||||||
|
|
||||||
|
fun of(url: String, title: String, desc: String) =
|
||||||
|
LinkPreview(url, title.take(MAX_TITLE), desc.take(MAX_DESC))
|
||||||
|
|
||||||
|
fun fromJson(o: JSONObject): LinkPreview? {
|
||||||
|
val url = o.optString("url").takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val title = o.optString("title")
|
||||||
|
if (title.isBlank()) return null
|
||||||
|
return of(url, title, o.optString("desc"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toJson(): JSONObject = JSONObject()
|
||||||
|
.put("url", url).put("title", title).put("desc", desc)
|
||||||
|
}
|
||||||
|
|
||||||
object Kind {
|
object Kind {
|
||||||
const val TEXT = "text"
|
const val TEXT = "text"
|
||||||
const val REACT = "react"
|
const val REACT = "react"
|
||||||
@@ -234,9 +273,15 @@ data class Content(
|
|||||||
* an envelope as literal JSON, so the safe default for the simplest
|
* an envelope as literal JSON, so the safe default for the simplest
|
||||||
* message is the format every client has always understood.
|
* message is the format every client has always understood.
|
||||||
*/
|
*/
|
||||||
fun text(body: String, messageId: String? = null, about: Reference? = null): String =
|
fun text(
|
||||||
if (messageId == null && about == null) body
|
body: String,
|
||||||
else assemble("text", caption = body, messageId = messageId, about = about)
|
messageId: String? = null,
|
||||||
|
about: Reference? = null,
|
||||||
|
link: LinkPreview? = null,
|
||||||
|
): String =
|
||||||
|
if (messageId == null && about == null && link == null) body
|
||||||
|
else assemble("text", caption = body, messageId = messageId, about = about,
|
||||||
|
link = link)
|
||||||
|
|
||||||
fun groupText(
|
fun groupText(
|
||||||
body: String,
|
body: String,
|
||||||
@@ -300,6 +345,7 @@ data class Content(
|
|||||||
attachment: Attachment.Ref? = null,
|
attachment: Attachment.Ref? = null,
|
||||||
messageId: String? = null,
|
messageId: String? = null,
|
||||||
about: Reference? = null,
|
about: Reference? = null,
|
||||||
|
link: LinkPreview? = null,
|
||||||
): String {
|
): String {
|
||||||
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
val parts = mutableListOf("\"fnc\":1", "\"kind\":${quote(kind)}")
|
||||||
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
messageId?.let { parts += "\"mid\":${quote(it)}" }
|
||||||
@@ -308,6 +354,7 @@ data class Content(
|
|||||||
expires?.let { parts += "\"exp\":$it" }
|
expires?.let { parts += "\"exp\":$it" }
|
||||||
groupId?.let { parts += "\"gid\":${quote(it)}" }
|
groupId?.let { parts += "\"gid\":${quote(it)}" }
|
||||||
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
attachment?.let { parts += "\"att\":${it.toJson()}" }
|
||||||
|
link?.let { parts += "\"lnk\":${it.toJson()}" }
|
||||||
return parts.joinToString(",", prefix = "{", postfix = "}")
|
return parts.joinToString(",", prefix = "{", postfix = "}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +445,8 @@ data class Content(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (kind == Kind.TEXT) {
|
if (kind == Kind.TEXT) {
|
||||||
return Content(caption, kind = kind, raw = json, groupId = groupId,
|
val link = json.optJSONObject("lnk")?.let { LinkPreview.fromJson(it) }
|
||||||
|
return Content(caption, kind = kind, raw = json, groupId = groupId, link = link,
|
||||||
messageId = messageId, about = about)
|
messageId = messageId, about = about)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,57 @@ class ContentTest {
|
|||||||
assertNull(Content.disappearSeconds(Content.parse(Content.text("hello"))))
|
assertNull(Content.disappearSeconds(Content.parse(Content.text("hello"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link previews (§6.12).
|
||||||
|
*
|
||||||
|
* 🔴 The SENDER generates these and they ride inside the ciphertext. A card
|
||||||
|
* each recipient fetched would tell the site the reader's IP, that they
|
||||||
|
* received a link, and roughly when they opened it — once per member on a
|
||||||
|
* group message.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a link preview round-trips`() {
|
||||||
|
val preview = Content.LinkPreview.of("https://example.com/x", "A title", "A desc")
|
||||||
|
val parsed = Content.parse(Content.text("look https://example.com/x", link = preview))
|
||||||
|
assertEquals("https://example.com/x", parsed.link?.url)
|
||||||
|
assertEquals("A title", parsed.link?.title)
|
||||||
|
assertEquals("look https://example.com/x", parsed.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 The host comes from the URL, never the page. A card whose title claims
|
||||||
|
* one destination while the link points at another is a phishing primitive.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the host comes from the url not the title`() {
|
||||||
|
val preview = Content.LinkPreview.of(
|
||||||
|
"https://evil.example/login", "Sign in — yourbank.com", "")
|
||||||
|
assertEquals("evil.example", preview.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ⚠️ Bounded on the way in, so no renderer has to remember a cap. */
|
||||||
|
@Test
|
||||||
|
fun `title and description are bounded`() {
|
||||||
|
val preview = Content.LinkPreview.of(
|
||||||
|
"https://example.com", "A".repeat(5_000), "B".repeat(5_000))
|
||||||
|
assertEquals(Content.LinkPreview.MAX_TITLE, preview.title.length)
|
||||||
|
assertEquals(Content.LinkPreview.MAX_DESC, preview.desc.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A preview with no title is not a preview — it would render as a blank card. */
|
||||||
|
@Test
|
||||||
|
fun `a preview without a title is refused`() {
|
||||||
|
val json = org.json.JSONObject().put("url", "https://example.com").put("title", "")
|
||||||
|
assertNull(Content.LinkPreview.fromJson(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional: a plain message must be byte-identical to before. */
|
||||||
|
@Test
|
||||||
|
fun `a message without a preview stays raw text`() {
|
||||||
|
assertEquals("plain", Content.text("plain"))
|
||||||
|
assertNull(Content.parse("plain").link)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unknown attachment kind shows a label rather than disappearing`() {
|
fun `an unknown attachment kind shows a label rather than disappearing`() {
|
||||||
val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""")
|
val parsed = Content.parse("""{"fnc":1,"kind":"hologram"}""")
|
||||||
|
|||||||
Reference in New Issue
Block a user