| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-07 19:55:56 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-07 19:59:29 UTC |
| parent | 391d85d120a6d655c03f1c7bb3e608bfdc0640b1 |
| HaxeCBridge.hx | +10 | -5 |
| borogove/Chat.hx | +5 | -3 |
| borogove/ChatMessage.hx | +3 | -44 |
| borogove/ChatMessageBuilder.hx | +4 | -33 |
| borogove/Html.hx | +194 | -33 |
| test/TestChatMessageBuilder.hx | +5 | -3 |
| test/TestHtml.hx | +12 | -11 |
diff --git a/HaxeCBridge.hx b/HaxeCBridge.hx index 5d2bd0e..abcf8ef 100644 --- a/HaxeCBridge.hx +++ b/HaxeCBridge.hx @@ -286,16 +286,21 @@ class HaxeCBridge { case TPType(TPath(_.name => "String")): true; default: false; } - if (isString) { - passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).map(cpp.NativeString.fromPointer).copy()); - } else { - passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).copy()); - } args.push({ name: arg.name, type: TPath({name: "ConstPointer", pack: ["cpp"], params: path.params.map(tp -> convertSecondaryTP(tp))}) }); switch (arg.type) { case TPath(path) if (path.name == "Null" || path.sub == "Null"): true; + if (isString) { + passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).map(cpp.NativeString.fromPointer).copy()); + } else { + passArgs.push(macro $i{arg.name} == null ? ($i{arg.name + "__len"} < 0 ? null : []) : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).copy()); + } args.push({ name: arg.name + "__len", type: TPath({name: "PtrDiffT", pack: []}) }); default: + if (isString) { + passArgs.push(macro $i{arg.name} == null ? [] : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).map(cpp.NativeString.fromPointer).copy()); + } else { + passArgs.push(macro $i{arg.name} == null ? [] : $i{arg.name}.reinterpret().toUnmanagedArray($i{arg.name + "__len"}).copy()); + } args.push({ name: arg.name + "__len", type: TPath({name: "SizeT", pack: ["cpp"]}) }); } default: diff --git a/borogove/Chat.hx b/borogove/Chat.hx index 6221fed..50e08a5 100644 --- a/borogove/Chat.hx +++ b/borogove/Chat.hx @@ -256,7 +256,9 @@ abstract class Chat { }, (text, uri) -> { final hash = Hash.fromUri(uri); - toSend.setHtml('<img alt="' + Util.xmlEscape(text) + '" src="' + Util.xmlEscape(hash == null ? uri : hash.bobUri()) + '" />'); + toSend.setHtml( + new Html([Element(new Stanza("img", { alt: text, src: hash == null ? uri : hash.bobUri() }))], null) + ); return ""; } ); @@ -1113,7 +1115,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(""); + correct.setHtml(new Html([], null)); correct.text = null; final fakeEnvelope = new ChatMessageBuilder(); @@ -1843,7 +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(""); + correct.setHtml(new Html([], null)); correct.text = null; diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx index 6f634cd..e9e52f5 100644 --- a/borogove/ChatMessage.hx +++ b/borogove/ChatMessage.hx @@ -397,55 +397,14 @@ class ChatMessage { } /** - Walk the HTML version of the message body + The HTML version of the message body WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately! - @param f callback taking tag or text, attribute names, attribute values, and transformed children, and returning the transformation of this element or text @param sender optionally specify the full details of the sender **/ - public function html<T>(f: (String, Null<Array<String>>, Null<Array<String>>, Null<Array<T>>)->T, sender: Null<Participant> = null):Array<T> { - var isAction = false; - - function mkTxt(txt: String) { - final senderP = sender; - return if (!isAction && txt.startsWith("/me ") && senderP != null) { - isAction = true; - f(senderP.displayName + txt.substr(3), null, null, null); - } else { - f(txt, null, null, null); - }; - } - - final fragment = htmlBody().map(item -> switch (item) { - case Element(el): - el.reduce( - (st, kids) -> { - // We don't deeply sanitize but we can remove some obvious dumb stuff - if (st.name == "style" || st.name == "script") return mkTxt(""); - - final keys = st.attr.keys().filter(k -> !k.startsWith("on")); - return f( - st.name, - keys, - keys.map(k -> { - final v = st.attr.get(k) ?? ""; - if (st.name == "img" && k == "src" && v != "") { - final hash = Hash.fromUri(v); - hash == null ? v : hash.toUri(); - } else { - v; - } - }), - kids - ); - }, - txt -> mkTxt(txt) - ); - case CData(txt): - mkTxt(txt.content); - }); - return isAction ? [f("div", ["class"], ["action"], fragment)] : fragment; + public function html(sender: Null<Participant> = null):Html { + return new Html(htmlBody(), sender); } /** diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx index db7a9e6..eb0963c 100644 --- a/borogove/ChatMessageBuilder.hx +++ b/borogove/ChatMessageBuilder.hx @@ -177,7 +177,7 @@ class ChatMessageBuilder { ?versions: Array<ChatMessage>, ?payloads: Array<Stanza>, ?encryption: Null<EncryptionInfo>, - ?html: Null<String>, + ?html: Null<Html>, }) { this.localId = params?.localId; this.serverId = params?.serverId; @@ -261,49 +261,20 @@ class ChatMessageBuilder { } /** - Set rich text using an HTML string + Set rich text using HTML Also sets the plain text body appropriately **/ - public function setHtml(html: String) { + 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); - final nodes = htmlparser.HtmlParser.run(html, true); - for (node in nodes) { - final el = Util.downcast(node, htmlparser.HtmlNodeElement); - if (el != null && (el.name == "html" || el.name == "body")) { - for (inner in el.nodes) { - body.addDirectChild(htmlToNode(inner)); - } - } else { - body.addDirectChild(htmlToNode(node)); - } - } + body.addChildNodes(html.xml); 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 = ~/\n$/.replace(XEP0393.render(body), ""); } - private function htmlToNode(node: htmlparser.HtmlNode) { - final txt = Util.downcast(node, htmlparser.HtmlNodeText); - if (txt != null) { - return CData(new TextNode(txt.toText())); - } - final el = Util.downcast(node, htmlparser.HtmlNodeElement); - if (el != null) { - final s = new Stanza(el.name, {}); - for (attr in el.attributes) { - s.attr.set(attr.name, attr.value); - } - for (child in el.nodes) { - s.addDirectChild(htmlToNode(child)); - } - return Element(s); - } - throw "node was neither text nor element?"; - } - /** The ID of the Chat this message is associated with **/ diff --git a/borogove/Html.hx b/borogove/Html.hx index eb78af2..8fca3e6 100644 --- a/borogove/Html.hx +++ b/borogove/Html.hx @@ -1,5 +1,11 @@ package borogove; +import haxe.DynamicAccess; +import haxe.ds.ReadOnlyArray; +using StringTools; + +import borogove.Stanza; + #if cpp import HaxeCBridge; #end @@ -10,6 +16,11 @@ import HaxeCBridge; @:build(HaxeCBridge.expose()) @:build(HaxeSwiftBridge.expose()) #end +/** + Rich text + + WARNING: this is possibly untrusted HTML. You must render or sanitize appropriately! +**/ class Html { private static final HTML_EMPTY = [ "area", @@ -28,54 +39,204 @@ class Html { "wbr" ]; - public static function asString(tag: String, attr: Null<Array<String>>, attrValue: Null<Array<String>>, kids: Null<Array<String>>): String { - if (attr == null && kids == null) { - return StringTools.htmlEscape(tag); - } else if (attr != null && attrValue != null) { - final el = Xml.createElement(tag); - for (i => attr_k in attr) { - el.set(attr_k, attrValue[i]); + @:allow(borogove) + private final xml: ReadOnlyArray<Node>; + private final sender: Null<Participant>; + + @:allow(borogove) + private function new(xml: Array<Node>, sender: Null<Participant>) { + this.xml = xml; + this.sender = sender; + } + + #if js + /** + HTML builder, make an element + **/ + public static function element(tag: String, attrs: DynamicAccess<String>, children: Array<Html>) { + final s = new Stanza(tag, attrs); + for (c in children) { + for (n in c.xml) { + s.addDirectChild(n); } + } - final start = el.toString(); - final buffer = new StringBuf(); - buffer.addSub(start, 0, start.length-2); + return new Html([Element(s)], null); + } + #else + /** + HTML builder, make an element + **/ + public static function element(tag: String, attr: Array<String>, attrValues: Array<String>, children: Array<Html>) { + final attrs: DynamicAccess<String> = {}; + for (i => a in attr) { + attrs[a] = attrValues[i]; + } - if (HTML_EMPTY.contains(tag)) { - buffer.add(" />"); - return buffer.toString(); + final s = new Stanza(tag, attrs); + for (c in children) { + for (n in c.xml) { + s.addDirectChild(n); } + } + + return new Html([Element(s)], null); + } + #end + + /** + HTML builder, make some text + **/ + public static function text(text: String) { + return new Html([CData(new TextNode(text))], null); + } - buffer.add(">"); - if (kids != null) { - for (kid in kids) { - buffer.add(kid); + /** + Build HTML payload from source + **/ + public static function fromString(html: String): Html { + final nodes = []; + for (node in htmlparser.HtmlParser.run(html, true)) { + final el = Util.downcast(node, htmlparser.HtmlNodeElement); + if (el != null && (el.name == "html" || el.name == "body")) { + for (inner in el.nodes) { + nodes.push(htmlToNode(inner)); } + } else { + nodes.push(htmlToNode(node)); } + } + return new Html(nodes, null); + } - buffer.add("</"); - buffer.add(tag); - buffer.add(">"); - return buffer.toString(); + private static function htmlToNode(node: htmlparser.HtmlNode) { + final txt = Util.downcast(node, htmlparser.HtmlNodeText); + if (txt != null) { + return CData(new TextNode(txt.toText())); + } + final el = Util.downcast(node, htmlparser.HtmlNodeElement); + if (el != null) { + final s = new Stanza(el.name, {}); + for (attr in el.attributes) { + s.attr.set(attr.name, attr.value); + } + for (child in el.nodes) { + s.addDirectChild(htmlToNode(child)); + } + return Element(s); } + throw "node was neither text nor element?"; + } + + /** + Walk the HTML tree to produce a new value + **/ + public function reduce<T>(f: (String, Null<Array<String>>, Null<Array<String>>, Null<Array<T>>)->T):Array<T> { + var isAction = false; + + function mkTxt(txt: String) { + final senderP = sender; + return if (!isAction && txt.startsWith("/me ") && senderP != null) { + isAction = true; + f(senderP.displayName + txt.substr(3), null, null, null); + } else { + f(txt, null, null, null); + }; + } + + final fragment = xml.map(item -> switch (item) { + case Element(el): + el.reduce( + (st, kids) -> { + // We don't deeply sanitize but we can remove some obvious dumb stuff + if (st.name == "style" || st.name == "script") return mkTxt(""); + + final keys = st.attr.keys().filter(k -> !k.startsWith("on")); + return f( + st.name, + keys, + keys.map(k -> { + final v = st.attr.get(k) ?? ""; + if (st.name == "img" && k == "src" && v != "") { + final hash = Hash.fromUri(v); + hash == null ? v : hash.toUri(); + } else { + v; + } + }), + kids + ); + }, + txt -> mkTxt(txt) + ); + case CData(txt): + mkTxt(txt.content); + }); + return isAction ? [f("div", ["class"], ["action"], fragment)] : fragment; + } + + /** + Get HTML source as a string + **/ + public function toString(): String { + return reduce((tag, attr, attrValue, kids) -> { + if (attr == null && kids == null) { + return StringTools.htmlEscape(tag); + } else if (attr != null && attrValue != null) { + final el = Xml.createElement(tag); + for (i => attr_k in attr) { + el.set(attr_k, attrValue[i]); + } + + final start = el.toString(); + final buffer = new StringBuf(); + buffer.addSub(start, 0, start.length-2); - throw "Invalid arguments"; + if (HTML_EMPTY.contains(tag)) { + buffer.add(" />"); + return buffer.toString(); + } + + buffer.add(">"); + if (kids != null) { + for (kid in kids) { + buffer.add(kid); + } + } + + buffer.add("</"); + buffer.add(tag); + buffer.add(">"); + return buffer.toString(); + } + + throw "Invalid arguments"; + }).join(""); } #if js - public static function asDOM(tag: String, attr: Null<Array<String>>, attrValue: Null<Array<String>>, kids: Null<Array<js.html.Node>>): js.html.Node { - if (attr == null && kids == null) { - return js.Browser.document.createTextNode(tag); - } else if (attr != null && attrValue != null) { - final el = js.Browser.document.createElement(tag); - for (i => attr_k in attr) { - el.setAttribute(attr_k, attrValue[i]); + /** + Get HTML as a DocumentFragment + **/ + public function asDOM(): js.html.DocumentFragment { + final nodes = reduce((tag, attr, attrValue, kids) -> { + if (attr == null && kids == null) { + return (js.Browser.document.createTextNode(tag) : js.html.Node); + } else if (attr != null && attrValue != null) { + final el = js.Browser.document.createElement(tag); + for (i => attr_k in attr) { + el.setAttribute(attr_k, attrValue[i]); + } + if (kids != null) el.append(...kids); + return el; } - if (kids != null) el.append(...kids); - return el; - } - throw "Invalid arguments"; + throw "Invalid arguments"; + }); + + final frag = new js.html.DocumentFragment(); + frag.append(...nodes); + return frag; } #end } diff --git a/test/TestChatMessageBuilder.hx b/test/TestChatMessageBuilder.hx index 0daf802..8a9980f 100644 --- a/test/TestChatMessageBuilder.hx +++ b/test/TestChatMessageBuilder.hx @@ -2,13 +2,15 @@ package test; import utest.Assert; import utest.Async; + +import borogove.Html; import borogove.ChatMessageBuilder; @:access(borogove) class TestChatMessageBuilder extends utest.Test { public function testConvertHtmlToXHTML() { final msg = new ChatMessageBuilder(); - msg.setHtml("Hello <div><img src='hai'><br>"); + msg.setHtml(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() @@ -17,7 +19,7 @@ class TestChatMessageBuilder extends utest.Test { public function testConvertHtmlToText() { final msg = new ChatMessageBuilder(); - msg.setHtml("<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.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")); Assert.equals( "> Hello\n> you\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```", msg.text @@ -26,7 +28,7 @@ class TestChatMessageBuilder extends utest.Test { public function testConvertHtmlToXHTMLIgnoresBody() { final msg = new ChatMessageBuilder(); - msg.setHtml("<body>Hello <div><img src='hai'><br></body>"); + msg.setHtml(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() diff --git a/test/TestHtml.hx b/test/TestHtml.hx index ff4e06a..c72c3a1 100644 --- a/test/TestHtml.hx +++ b/test/TestHtml.hx @@ -3,6 +3,7 @@ package test; import utest.Assert; import utest.Async; +import borogove.Html; import borogove.ChatMessageBuilder; import borogove.JID; import borogove.Participant; @@ -15,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("Hello <div class='sup&2'><img src='hai'><br><p></p>"); + msg.setHtml(Html.fromString("Hello <div class='sup&2'><img src='hai'><br><p></p>")); Assert.equals( "Hello <div class=\"sup&2\"><img src=\"hai\" /><br /><p></p></div>", - msg.build().html(borogove.Html.asString).join("") + msg.build().html().toString() ); } @@ -28,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("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>"); + msg.setHtml(Html.fromString("<img src='cid:sha1+472e2207519f825c2affc636550a23cbcf1ef5ac@bob.xmpp.org'/>")); Assert.equals( "<img src=\"ni:///sha-1;Ry4iB1Gfglwq_8Y2VQojy88e9aw\" />", - msg.build().html(borogove.Html.asString).join("") + msg.build().html().toString() ); } @@ -47,7 +48,7 @@ class TestHtml extends utest.Test { Assert.equals( "<div class=\"action\"><div>hatter says hello</div></div>", - msg.build().html(borogove.Html.asString, participant).join("") + msg.build().html(participant).toString() ); } @@ -57,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("/me says <div class='sup&2'><img src='hai'><br><p></p>"); + msg.setHtml(Html.fromString("/me says <div class='sup&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&2\"><img src=\"hai\" /><br /><p></p></div></div>", - msg.build().html(borogove.Html.asString, participant).join("") + msg.build().html(participant).toString() ); } @@ -73,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("<a onclick='alert();'>hello</a>"); + msg.setHtml(Html.fromString("<a onclick='alert();'>hello</a>")); Assert.equals( "<a>hello</a>", - msg.build().html(borogove.Html.asString).join("") + msg.build().html().toString() ); } @@ -86,10 +87,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("<style>hai</style><script>hai</script>hai"); + msg.setHtml(Html.fromString("<style>hai</style><script>hai</script>hai")); Assert.equals( "hai", - msg.build().html(borogove.Html.asString).join("") + msg.build().html().toString() ); } }