git » sdk » commit 12360ae

Remove access to raw body

author Stephen Paul Weber
2026-04-11 02:50:38 UTC
committer Stephen Paul Weber
2026-04-11 03:36:47 UTC
parent 5c5334cd01d16fa76e9063c887317c06f5a275a5

Remove access to raw body

borogove/Chat.hx +6 -8
borogove/ChatMessage.hx +5 -5
borogove/ChatMessageBuilder.hx +26 -14
borogove/Html.hx +22 -0
borogove/Notification.hx +1 -1
borogove/calls/Session.hx +1 -1
test/TestChatMessageBuilder.hx +28 -3
test/TestHtml.hx +19 -11

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 50e08a5..8d0337c 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -251,12 +251,12 @@ abstract class Chat {
 		toSend.localId = ID.unique();
 		reaction.render(
 			(text) -> {
-				toSend.text = text.replace("\u{fe0f}", "");
+				toSend.setBody(Html.text(text.replace("\u{fe0f}", "")));
 				return "";
 			},
 			(text, uri) -> {
 				final hash = Hash.fromUri(uri);
-				toSend.setHtml(
+				toSend.setBody(
 					new Html([Element(new Stanza("img", { alt: text, src: hash == null ? uri : hash.bobUri() }))], null)
 				);
 				return "";
@@ -539,7 +539,8 @@ abstract class Chat {
 			case MessageCall:
 				lastMessage.isIncoming() ? "Incoming Call" : "Outgoing Call";
 			default:
-				lastMessage.text.split("\n").find(line -> !~/(^[ \n]*$)|(^>)/.match(line)) ?? lastMessage.text ?? "";
+				final txt = lastMessage.body().toPlainText() ?? "";
+				txt.split("\n").find(line -> !~/(^[ \n]*$)|(^>)/.match(line)) ?? txt;
 		}
 	}
 
@@ -1115,8 +1116,7 @@ class DirectChat extends Chat {
 			if (reaction.envelopeId == null) throw "Cannot remove custom emoji reaction without envelopeId";
 			final correct = m.reply();
 			correct.localId = ID.unique();
-			correct.setHtml(new Html([], null));
-			correct.text = null;
+			correct.setBody(null);
 
 			final fakeEnvelope = new ChatMessageBuilder();
 			fakeEnvelope.localId = reaction.envelopeId;
@@ -1845,9 +1845,7 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 			if (reaction.envelopeId == null) throw "Cannot remove custom emoji reaction without envelopeId";
 			final correct = m.reply();
 			correct.localId = ID.unique();
-			correct.setHtml(new Html([], null));
-			correct.text = null;
-
+			correct.setBody(null);
 
 			final fakeEnvelope = new ChatMessageBuilder();
 			fakeEnvelope.localId = reaction.envelopeId;
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index e9e52f5..affe8c9 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -201,12 +201,12 @@ class ChatMessage {
 	#end
 
 	/**
-		Body text of this message or NULL
+		Raw body text of this message or NULL
 	**/
-	public final text: Null<String>;
+	private final text: Null<String>;
 
 	/**
-		Language code for the body text
+		Language code for the body
 	**/
 	public final lang: Null<String>;
 
@@ -397,13 +397,13 @@ class ChatMessage {
 	}
 
 	/**
-		The HTML version of the message body
+		HTML representation of the message body
 
 		WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately!
 
 		@param sender optionally specify the full details of the sender
 	**/
-	public function html(sender: Null<Participant> = null):Html {
+	public function body(sender: Null<Participant> = null):Html {
 		return new Html(htmlBody(), sender);
 	}
 
diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx
index ac3e730..bb17666 100644
--- a/borogove/ChatMessageBuilder.hx
+++ b/borogove/ChatMessageBuilder.hx
@@ -103,10 +103,11 @@ class ChatMessageBuilder {
 	/**
 		Body text of this message or NULL
 	**/
-	public var text: Null<String> = null;
+	@:allow(borogove.Message)
+	private var text: Null<String> = null;
 
 	/**
-		Language code for the body text
+		Language code for the body
 	**/
 	public var lang: Null<String> = null;
 
@@ -198,15 +199,16 @@ class ChatMessageBuilder {
 		this.threadId = params?.threadId;
 		this.attachments = params?.attachments ?? [];
 		this.reactions = params?.reactions ?? ([] : Map<String, Array<Reaction>>);
-		this.text = params?.text;
 		this.lang = params?.lang;
 		this.direction = params?.direction ?? MessageSent;
 		this.status = params?.status ?? MessagePending;
 		this.versions = params?.versions ?? [];
 		this.payloads = params?.payloads ?? [];
 		this.encryption = params?.encryption;
+		final text = params?.text;
+		if (text != null) setBody(Html.text(text));
 		final html = params?.html;
-		if (html != null) setHtml(html);
+		if (html != null) setBody(html);
 	}
 	#end
 
@@ -268,21 +270,31 @@ class ChatMessageBuilder {
 	}
 
 	/**
-		Set rich text using HTML
-
-		Also sets the plain text body appropriately
+		Set body from Html
 
 		@param html rich text body to attach to the message
 	**/
-	public function setHtml(html: Html) {
-		final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" });
-		final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" });
-		htmlEl.addChild(body);
-		body.addChildNodes(html.xml);
+	public function setBody(html: Null<Html>) {
 		final htmlIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html");
 		if (htmlIdx >= 0) payloads.splice(htmlIdx, 1);
-		payloads.push(htmlEl);
-		text = html.toPlainText();
+
+		final unstyledIdx = payloads.findIndex((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled");
+		if (unstyledIdx >= 0) payloads.splice(unstyledIdx, 1);
+
+		if (html == null) {
+			text = null;
+		} else {
+			if (html.isPlainText()) {
+				payloads.push(new Stanza("unstyled", { xmlns: "urn:xmpp:styling:0" }));
+			} else {
+				final htmlEl = new Stanza("html", { xmlns: "http://jabber.org/protocol/xhtml-im" });
+				final body = new Stanza("body", { xmlns: "http://www.w3.org/1999/xhtml" });
+				htmlEl.addChild(body);
+				body.addChildNodes(html.xml);
+				payloads.push(htmlEl);
+			}
+			text = html.toPlainText();
+		}
 	}
 
 	/**
diff --git a/borogove/Html.hx b/borogove/Html.hx
index e90f403..df22eb6 100644
--- a/borogove/Html.hx
+++ b/borogove/Html.hx
@@ -3,6 +3,7 @@ package borogove;
 import haxe.DynamicAccess;
 import haxe.ds.ReadOnlyArray;
 using StringTools;
+using Lambda;
 
 import borogove.Stanza;
 
@@ -128,6 +129,27 @@ class Html {
 		throw "node was neither text nor element?";
 	}
 
+	@:allow(borogove)
+	private function isPlainText() {
+		// Don't use our own reduce because we want to check the raw nodes
+		return !xml.map(item -> switch (item) {
+			case Element(el):
+				el.reduce(
+					(st, kids) -> {
+						final attrs = st.attr.keys();
+
+						if (["div", "span", "p", "br"].contains(st.name)) {
+							return attrs.length < 1 && !kids.exists(plain -> !plain);
+						}
+
+						return false;
+					},
+					txt -> true
+				);
+			case CData(txt): true;
+		}).exists(plain -> !plain);
+	}
+
 	/**
 		Walk the HTML tree to produce a new value
 	**/
diff --git a/borogove/Notification.hx b/borogove/Notification.hx
index fe91a4e..36d2527 100644
--- a/borogove/Notification.hx
+++ b/borogove/Notification.hx
@@ -88,7 +88,7 @@ class Notification {
 		}
 		return new Notification(
 			m.type == MessageCall ? "Incoming Call" : "New Message",
-			m.text,
+			m.body().toPlainText(),
 			m.account(),
 			m.chatId(),
 			m.senderId,
diff --git a/borogove/calls/Session.hx b/borogove/calls/Session.hx
index 9409794..438e5a9 100644
--- a/borogove/calls/Session.hx
+++ b/borogove/calls/Session.hx
@@ -60,7 +60,7 @@ private function mkCallMessage(to: JID, client: Client, event: Stanza) {
 	m.sender = m.from.asBare();
 	m.replyTo = [m.sender];
 	m.direction = MessageSent;
-	m.text = "call " + event.name;
+	m.setBody(Html.text("call " + event.name));
 	m.timestamp = Date.format(std.Date.now());
 	m.payloads.push(event);
 	m.localId = ID.unique();
diff --git a/test/TestChatMessageBuilder.hx b/test/TestChatMessageBuilder.hx
index 8a9980f..9da21d1 100644
--- a/test/TestChatMessageBuilder.hx
+++ b/test/TestChatMessageBuilder.hx
@@ -10,7 +10,7 @@ import borogove.ChatMessageBuilder;
 class TestChatMessageBuilder extends utest.Test {
 	public function testConvertHtmlToXHTML() {
 		final msg = new ChatMessageBuilder();
-		msg.setHtml(Html.fromString("Hello <div><img src='hai'><br>"));
+		msg.setBody(Html.fromString("Hello <div><img src='hai'><br>"));
 		Assert.equals(
 			"<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\">Hello <div><img src=\"hai\"/><br/></div></body></html>",
 			msg.payloads[0].toString()
@@ -19,7 +19,7 @@ class TestChatMessageBuilder extends utest.Test {
 
 	public function testConvertHtmlToText() {
 		final msg = new ChatMessageBuilder();
-		msg.setHtml(Html.fromString("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you"));
+		msg.setBody(Html.fromString("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you"));
 		Assert.equals(
 			"> Hello\n> you\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```",
 			msg.text
@@ -28,11 +28,36 @@ class TestChatMessageBuilder extends utest.Test {
 
 	public function testConvertHtmlToXHTMLIgnoresBody() {
 		final msg = new ChatMessageBuilder();
-		msg.setHtml(Html.fromString("<body>Hello <div><img src='hai'><br></body>"));
+		msg.setBody(Html.fromString("<body>Hello <div><img src='hai'><br></body>"));
 		Assert.equals(
 			"<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\">Hello <div><img src=\"hai\"/><br/></div></body></html>",
 			msg.payloads[0].toString()
 		);
 	}
 
+	public function testSetBodyNull() {
+		final msg = new ChatMessageBuilder();
+		msg.setBody(Html.text("hello"));
+		Assert.equals("hello", msg.text);
+		msg.setBody(null);
+		Assert.isNull(msg.text);
+		Assert.equals(0, msg.payloads.length);
+	}
+
+	public function testSetBodyPlainText() {
+		final msg = new ChatMessageBuilder();
+		msg.setBody(Html.text("hello"));
+		Assert.equals("hello", msg.text);
+		Assert.equals("<unstyled xmlns=\"urn:xmpp:styling:0\"/>", msg.payloads[0].toString());
+	}
+
+	public function testConstructor() {
+		final msgText = new ChatMessageBuilder({ text: "hello" });
+		Assert.equals("hello", msgText.text);
+		Assert.equals("<unstyled xmlns=\"urn:xmpp:styling:0\"/>", msgText.payloads[0].toString());
+
+		final msgHtml = new ChatMessageBuilder({ html: Html.fromString("<b>hello</b>") });
+		Assert.equals("*hello*", msgHtml.text);
+		Assert.equals("<html xmlns=\"http://jabber.org/protocol/xhtml-im\"><body xmlns=\"http://www.w3.org/1999/xhtml\"><b>hello</b></body></html>", msgHtml.payloads[0].toString());
+	}
 }
diff --git a/test/TestHtml.hx b/test/TestHtml.hx
index c72c3a1..c2c495d 100644
--- a/test/TestHtml.hx
+++ b/test/TestHtml.hx
@@ -16,10 +16,10 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml(Html.fromString("Hello <div class='sup&amp;2'><img src='hai'><br><p></p>"));
+		msg.setBody(Html.fromString("Hello <div class='sup&amp;2'><img src='hai'><br><p></p>"));
 		Assert.equals(
 			"Hello <div class=\"sup&amp;2\"><img src=\"hai\" /><br /><p></p></div>",
-			msg.build().html().toString()
+			msg.build().body().toString()
 		);
 	}
 
@@ -29,10 +29,10 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml(Html.fromString("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>"));
+		msg.setBody(Html.fromString("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>"));
 		Assert.equals(
 			"<img src=\"ni:///sha-1;Ry4iB1Gfglwq_8Y2VQojy88e9aw\" />",
-			msg.build().html().toString()
+			msg.build().body().toString()
 		);
 	}
 
@@ -48,7 +48,7 @@ class TestHtml extends utest.Test {
 
 		Assert.equals(
 			"<div class=\"action\"><div>hatter says hello</div></div>",
-			msg.build().html(participant).toString()
+			msg.build().body(participant).toString()
 		);
 	}
 
@@ -58,13 +58,13 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml(Html.fromString("/me says <div class='sup&amp;2'><img src='hai'><br><p></p>"));
+		msg.setBody(Html.fromString("/me says <div class='sup&amp;2'><img src='hai'><br><p></p>"));
 
 		final participant = new Participant("hatter", null, "", false, msg.from, null);
 
 		Assert.equals(
 			"<div class=\"action\">hatter says <div class=\"sup&amp;2\"><img src=\"hai\" /><br /><p></p></div></div>",
-			msg.build().html(participant).toString()
+			msg.build().body(participant).toString()
 		);
 	}
 
@@ -74,10 +74,10 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml(Html.fromString("<a onclick='alert();'>hello</a>"));
+		msg.setBody(Html.fromString("<a onclick='alert();'>hello</a>"));
 		Assert.equals(
 			"<a>hello</a>",
-			msg.build().html().toString()
+			msg.build().body().toString()
 		);
 	}
 
@@ -87,10 +87,18 @@ class TestHtml extends utest.Test {
 		msg.to = JID.parse("alice@example.com");
 		msg.from = JID.parse("hatter@example.com");
 		msg.sender = msg.from;
-		msg.setHtml(Html.fromString("<style>hai</style><script>hai</script>hai"));
+		msg.setBody(Html.fromString("<style>hai</style><script>hai</script>hai"));
 		Assert.equals(
 			"hai",
-			msg.build().html().toString()
+			msg.build().body().toString()
 		);
 	}
+
+	public function testIsPlainText() {
+		Assert.isTrue(Html.text("hello").isPlainText());
+		Assert.isTrue(Html.fromString("<div>hello</div>").isPlainText());
+		Assert.isTrue(Html.fromString("<p>hello</p><p>world</p>").isPlainText());
+		Assert.isTrue(Html.fromString("hello<br>world").isPlainText());
+		Assert.isFalse(Html.fromString("hello <b>world</b>").isPlainText());
+	}
 }