| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-03-31 16:21:10 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-04-03 02:34:23 UTC |
| parent | ed4a968d6e6fd46791836a878168e177667722eb |
| HaxeCBridge.hx | +100 | -9 |
| HaxeSwiftBridge.hx | +19 | -5 |
| borogove/ChatMessage.hx | +55 | -38 |
| borogove/Html.hx | +81 | -0 |
| borogove/Stanza.hx | +7 | -0 |
| browserjs.hxml | +1 | -0 |
| cpp.hxml | +1 | -0 |
| nodejs.hxml | +1 | -0 |
| npm/index.ts | +1 | -0 |
| test/TestAll.hx | +1 | -0 |
| test/TestHtml.hx | +95 | -0 |
diff --git a/HaxeCBridge.hx b/HaxeCBridge.hx index 70ee58a..5d2bd0e 100644 --- a/HaxeCBridge.hx +++ b/HaxeCBridge.hx @@ -209,6 +209,14 @@ class HaxeCBridge { if (field.access.contains(APublic) && !field.access.contains(AOverride) && !field.meta.exists((m) -> m.name == "HaxeCBridge.noemit")) { switch field.kind { case FFun(fun): + final funParams = fun.params ?? []; + final funArgs = fun.args.map(arg -> ({ + name: arg.name, + opt: arg.opt, + type: eraseTypeParams(arg.type, funParams), + value: arg.value, + meta: arg.meta + })); var wrapper = { name: field.name + "__fromC", doc: field.doc, @@ -216,15 +224,15 @@ class HaxeCBridge { access: field.access.filter(a -> a != AAbstract), kind: null, pos: field.pos, - ret: fun.ret + ret: eraseTypeParams(fun.ret, funParams) }; - var wrap = field.access.contains(AAbstract); + var wrap = field.access.contains(AAbstract) || funParams.length > 0; var args = []; var passArgs = []; var outPtr = false; var promisify = []; var promisifyE = []; - for (arg in fun.args) { + for (arg in funArgs) { switch Context.toComplexType(TypeTools.followWithAbstracts(Context.resolveType(arg.type, Context.currentPos()), false)) { case TFunction(taargs, aret): wrap = true; @@ -265,11 +273,13 @@ class HaxeCBridge { if (gcFree.contains(arg.name)) lambdabody.push(macro cpp.NativeGc.exitGCFreeZone()); switch (aret) { case TPath(_.sub => "Void"): + case TPath(p) if (p.sub == "Opaque"): + lambdabody.push(macro return ret.toDynamic()); default: - lambdabody.push(macro return ret); + lambdabody.push(macro return ret); } final lambdafargs = aargs.mapi((i, a) -> {name: "a" + i, meta: null, opt: false, type: null, value: null}); - passArgs.push({expr: EFunction(null, { args: lambdafargs, expr: macro $b{lambdabody} }), pos: field.pos}); + passArgs.push({expr: EFunction(FArrow, { args: lambdafargs, expr: macro $b{lambdabody} }), pos: field.pos}); case TPath(path) if (path.name == "Array"): wrap = true; final isString = switch path.params[0] { @@ -336,14 +346,22 @@ class HaxeCBridge { } else { [field.name]; } + var retExpr = macro $p{pth}($a{passArgs}); + if (wrapper.ret != null) { + switch (wrapper.ret) { + case TPath(p) if (p.sub == "Opaque"): + retExpr = macro cast Opaque.fromDynamic(($retExpr : Any)); + default: + } + } final expr = if (outPtr) { macro { final out = $p{pth}($a{passArgs}); if (outPtr != null) { cpp.Pointer.fromRaw(outPtr).set_ref(out); } return out.length; }; } else if (promisify.length > 0) { macro if (handler == null) $p{pth}($a{passArgs}); else $p{pth}($a{passArgs}).then(v->handler($a{promisify}), e->handler($a{promisifyE})); } else { - macro return $p{pth}($a{passArgs}); + macro return $retExpr; } - wrapper.kind = FFun({ret: wrapper.ret, params: fun.params, expr: expr, args: args}); + wrapper.kind = FFun({ret: wrapper.ret, params: [], expr: expr, args: args}); fields.insert(insertTo, wrapper); insertTo++; field.meta.push({name: "HaxeCBridge.noemit", params: [{ pos: field.pos, expr: EConst(CString("wrapped")) }], pos: field.pos}); @@ -527,6 +545,11 @@ class HaxeCBridge { switch (pth.name) { case "Int16": "HaxeShortArray"; case "ConstCharStar": "HaxeStringArray"; + case "HaxeCBridge" if (pth.sub == "Opaque"): + return {retainType: "Array", args: [ + TPath({name: "HaxeOpaqueArray", pack: [], params: []}), + TPath(nullable ? {name: "PtrDiffT", pack: []} : {name: "SizeT", pack: ["cpp"]}) + ]}; default: "HaxeArray"; } default: @@ -683,6 +706,39 @@ class HaxeCBridge { } } + static function eraseTypeParams(ct: ComplexType, params: Array<TypeParamDecl>): ComplexType { + if (params == null || params.length == 0 || ct == null) return ct; + final names = params.map(p -> p.name); + function loop(ct: ComplexType): ComplexType { + return switch (ct) { + case TPath({pack: [], name: name}) if (names.contains(name)): + macro :HaxeCBridge.Opaque; + case TPath(p): + TPath({ + pack: p.pack, + name: p.name, + sub: p.sub, + params: p.params == null ? null : p.params.map(param -> switch (param) { + case TPType(t): TPType(loop(t)); + case TPExpr(_): param; + }) + }); + case TFunction(args, ret): + TFunction(args.map(loop), loop(ret)); + case TParent(t): + TParent(loop(t)); + case TIntersection(tl): + TIntersection(tl.map(loop)); + case TOptional(t): + TOptional(loop(t)); + case TNamed(n, t): + TNamed(n, loop(t)); + default: ct; + } + } + return loop(ct); + } + static macro function runUserMain() { var mainClassPath = getMainFromHaxeArgs(Sys.args()); if (mainClassPath == null) { @@ -1597,7 +1653,7 @@ class CConverterContext { } } } - + case TType(_.get() => t, params): var keyCType = tryConvertKeyType(type, allowNonTrivial, allowBareFnTypes, pos); if (keyCType != null) { @@ -1626,7 +1682,7 @@ class CConverterContext { } else { Context.error('Any and Dynamic are not supported as secondary type for C export, use HaxeCBridge.HaxeObject<Any> instead', pos); } - + case TMono(t): Context.error("Explicit type is required when exposing to C", pos); @@ -1652,6 +1708,11 @@ class CConverterContext { Return CType if Type was a key type and null otherwise **/ public function tryConvertKeyType(type: Type, allowNonTrivial:Bool, allowBareFnTypes: Bool, pos: Position): Null<CType> { + switch (Context.follow(type)) { + case TInst(_.get() => {kind: KTypeParameter(_)}, _): + return getHaxeObjectCType(type); + default: + } var base = asBaseType(type); return if (base != null) { switch base { @@ -2072,6 +2133,18 @@ import sys.thread.Lock; import sys.thread.Mutex; import sys.thread.Thread; +abstract Opaque(cpp.RawPointer<cpp.Void>) from cpp.RawPointer<cpp.Void> to cpp.RawPointer<cpp.Void> { + @:to + public inline function toDynamic(): Dynamic { + return cpp.Pointer.fromRaw(this); + } + + @:from + public static inline function fromDynamic(x: Dynamic): Opaque { + return (x : cpp.Pointer<cpp.Void>).raw; + } +} + abstract HaxeObject<T>(cpp.RawPointer<cpp.Void>) from cpp.RawPointer<cpp.Void> to cpp.RawPointer<cpp.Void> { public var value(get, never): T; @@ -2122,6 +2195,24 @@ abstract HaxeStringArray<T>(cpp.RawPointer<cpp.ConstCharStar>) from cpp.RawPoint } } +abstract HaxeOpaqueArray(cpp.RawPointer<cpp.RawPointer<cpp.Void>>) from cpp.RawPointer<cpp.RawPointer<cpp.Void>> to cpp.RawPointer<cpp.RawPointer<cpp.Void>> { + @:from + public static function fromNullableArrayT<T>(x: Null<Array<T>>): Null<HaxeOpaqueArray> { + if (x == null) return null; + + return fromArrayT(cast x); + } + + @:from + public static inline function fromArrayT<T>(x: Array<T>): HaxeOpaqueArray { + final arr: Array<cpp.SizeT> = cpp.NativeArray.create(x.length); + for (i => el in x) { + arr[i] = untyped __cpp__('reinterpret_cast<size_t>({0})', Opaque.fromDynamic(el)); + } + return cast HaxeCBridge.retainHaxeArray(arr); + } +} + abstract HaxeArray<T>(cpp.RawPointer<cpp.RawPointer<cpp.Void>>) from cpp.RawPointer<cpp.RawPointer<cpp.Void>> to cpp.RawPointer<cpp.RawPointer<cpp.Void>> { @:from public static inline function fromReadOnlyArrayT<T>(x: haxe.ds.ReadOnlyArray<T>): HaxeArray<HaxeObject<T>> { diff --git a/HaxeSwiftBridge.hx b/HaxeSwiftBridge.hx index 62315aa..dfd222f 100644 --- a/HaxeSwiftBridge.hx +++ b/HaxeSwiftBridge.hx @@ -232,7 +232,12 @@ class HaxeSwiftBridge { "}()"; } case TInst(_.get() => t, params): - final wrapper = t.isInterface ? 'Any${t.name}' : t.name; + final wrapper = switch (Context.follow(type)) { + case TInst(_.get() => {kind: KTypeParameter(_)}, _): + ""; + default: + t.isInterface ? 'Any${t.name}' : t.name; + }; if (canNull) { return "(" + item + ").map({ " + wrapper + "($0) })"; } else { @@ -363,6 +368,15 @@ class HaxeSwiftBridge { if (genAccess) builder.add("public "); builder.add("func "); builder.add(funcName); + switch (fld?.kind) { + case FFun(func): + if (func.params.length > 0) { + builder.add("<"); + builder.add(func.params.map(p -> p.name).join(",")); + builder.add(">"); + } + default: + } builder.add("("); convertArgs(builder, targs, fld?.kind); builder.add(") "); @@ -402,7 +416,7 @@ class HaxeSwiftBridge { case TAbstract(_.get().name => "Null", [param]): true; default: false; }; - switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) { + switch TypeTools.followWithAbstracts(arg.t, false) { case TInst(_.get().name => "Array", [TInst(_.get().name => "String", _)]): builder.add("with" + (allowNull ? "Optional" : "") + "ArrayOfCStrings(" + arg.name + ") { __" + arg.name + " in "); default: @@ -419,7 +433,7 @@ class HaxeSwiftBridge { case TAbstract(_.get().name => "Null", [param]): true; default: false; }; - switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) { + switch TypeTools.followWithAbstracts(arg.t, false) { case TFun(fargs, fret): ibuilder.add("{ ("); for (i => farg in fargs) { @@ -474,7 +488,7 @@ class HaxeSwiftBridge { builder.add("let __result = "); builder.add(castToSwift(ibuilder.toString(), finalTret, false, true)); for (arg in targs) { - switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) { + switch TypeTools.followWithAbstracts(arg.t, false) { case TFun(fargs, fret): final contextLifetime = fld.meta.filter(meta -> meta.name == ":HaxeSwiftBridge.contextLifetime").map(meta -> meta.params.map(identToStr)).find(params -> params[0] == arg.name); if (contextLifetime != null) { @@ -493,7 +507,7 @@ class HaxeSwiftBridge { } builder.add("\n\t\treturn __result"); for (arg in targs) { - switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) { + switch TypeTools.followWithAbstracts(arg.t, false) { case TInst(_.get().name => "Array", [TInst(_.get().name => "String", _)]): builder.add("}"); default: diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx index aa0d223..6f634cd 100644 --- a/borogove/ChatMessage.hx +++ b/borogove/ChatMessage.hx @@ -373,46 +373,12 @@ class ChatMessage { return result; } - /** - Get HTML version 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):String { + private function htmlBody(): Array<Node> { final htmlBody = payloads.find((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html")?.getChild("body", "http://www.w3.org/1999/xhtml"); - var htmlSource = ""; - var isAction = false; if (htmlBody != null) { - htmlSource = htmlBody.children.map((el: NodeInterface) -> el.traverse(child -> { - if (child.name == "img") { - final src = child.attr.get("src"); - if (src != null) { - final hash = Hash.fromUri(src); - if (hash != null) { - child.attr.set("src", hash.toUri()); - } - } - return true; - } - final senderP = sender; - if (senderP != null && child.getFirstChild() == null) { - final txt = child.getText(); - if (txt.startsWith("/me")) { - isAction = true; - child.removeChildren(); - child.text(senderP.displayName + txt.substr(3)); - } - } - return false; - }).serialize()).join(""); + return htmlBody.children; } else { var bodyText = text ?? ""; - if (sender != null && bodyText.startsWith("/me")) { - isAction = true; - bodyText = sender.displayName + bodyText.substr(3); - } final codepoints = StringUtil.codepointArray(bodyText); // TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle? final fallbacks: Array<{start: Int, end: Int}> = cast payloads.filter( @@ -426,9 +392,60 @@ class ChatMessage { codepoints.splice(fallback.start, (fallback.end - fallback.start)); } final body = codepoints.join(""); - htmlSource = payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled") == null ? XEP0393.parse(body).map((s) -> s.toString()).join("") : StringTools.htmlEscape(body); + return payloads.find((p) -> p.attr.get("xmlns") == "urn:xmpp:styling:0" && p.name == "unstyled") == null ? XEP0393.parse(body).map(s -> Element(s)) : [CData(new TextNode(body))]; } - return isAction ? '<div class="action">${htmlSource}</div>' : htmlSource; + } + + /** + Walk 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; } /** diff --git a/borogove/Html.hx b/borogove/Html.hx new file mode 100644 index 0000000..eb78af2 --- /dev/null +++ b/borogove/Html.hx @@ -0,0 +1,81 @@ +package borogove; + +#if cpp +import HaxeCBridge; +#end + +@:expose +@:nullSafety(StrictThreaded) +#if cpp +@:build(HaxeCBridge.expose()) +@:build(HaxeSwiftBridge.expose()) +#end +class Html { + private static final HTML_EMPTY = [ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "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]); + } + + final start = el.toString(); + final buffer = new StringBuf(); + buffer.addSub(start, 0, start.length-2); + + 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"; + } + + #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]); + } + if (kids != null) el.append(...kids); + return el; + } + + throw "Invalid arguments"; + } +#end +} diff --git a/borogove/Stanza.hx b/borogove/Stanza.hx index af76f7e..fb9a555 100644 --- a/borogove/Stanza.hx +++ b/borogove/Stanza.hx @@ -383,6 +383,13 @@ class Stanza { return this; } + public function reduce<T>(stanza: (Stanza, Array<T>)->T, text: String->T):T { + return stanza(this, children.map(c -> switch (c) { + case Element(st): st.reduce(stanza, text); + case CData(txt): text(txt.content); + })); + } + public function getError():Null<StanzaError> { final errorTag = this.getChild("error"); if(errorTag == null) { diff --git a/browserjs.hxml b/browserjs.hxml index 7e849e0..08fead8 100644 --- a/browserjs.hxml +++ b/browserjs.hxml @@ -14,6 +14,7 @@ borogove.Register borogove.Push borogove.Version borogove.persistence.Sqlite +borogove.Html -D analyzer-optimize -D js-es=6 diff --git a/cpp.hxml b/cpp.hxml index 4b0954e..ff2a2a0 100644 --- a/cpp.hxml +++ b/cpp.hxml @@ -15,6 +15,7 @@ borogove.Push borogove.persistence.Dummy borogove.persistence.Sqlite borogove.persistence.MediaStoreFS +borogove.Html --cpp cpp -D analyzer-optimize diff --git a/nodejs.hxml b/nodejs.hxml index aca615d..e9108ee 100644 --- a/nodejs.hxml +++ b/nodejs.hxml @@ -15,6 +15,7 @@ borogove.Register borogove.Push borogove.Version borogove.persistence.Sqlite +borogove.Html -D analyzer-optimize -D js-es=6 diff --git a/npm/index.ts b/npm/index.ts index f18a5ce..26a701f 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -27,6 +27,7 @@ export { borogove_FormField as FormField, borogove_FormOption as FormOption, borogove_Hash as Hash, + borogove_Html as Html, borogove_Identicon as Identicon, borogove_LinkMetadata as LinkMetadata, borogove_Notification as Notification, diff --git a/test/TestAll.hx b/test/TestAll.hx index a6e8db5..535cc9b 100644 --- a/test/TestAll.hx +++ b/test/TestAll.hx @@ -18,6 +18,7 @@ class TestAll { new TestUtil(), new TestReaction(), new TestSortId(), + new TestHtml(), ]); } } diff --git a/test/TestHtml.hx b/test/TestHtml.hx new file mode 100644 index 0000000..ff4e06a --- /dev/null +++ b/test/TestHtml.hx @@ -0,0 +1,95 @@ +package test; + +import utest.Assert; +import utest.Async; + +import borogove.ChatMessageBuilder; +import borogove.JID; +import borogove.Participant; + +@:access(borogove) +class TestHtml extends utest.Test { + public function testHtmlAsString() { + final msg = new ChatMessageBuilder(); + msg.localId = "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>"); + Assert.equals( + "Hello <div class=\"sup&2\"><img src=\"hai\" /><br /><p></p></div>", + msg.build().html(borogove.Html.asString).join("") + ); + } + + public function testHashRewrite() { + final msg = new ChatMessageBuilder(); + msg.localId = "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'/>"); + Assert.equals( + "<img src=\"ni:///sha-1;Ry4iB1Gfglwq_8Y2VQojy88e9aw\" />", + msg.build().html(borogove.Html.asString).join("") + ); + } + + public function testXEP0245() { + final msg = new ChatMessageBuilder(); + msg.localId = "test"; + msg.to = JID.parse("alice@example.com"); + msg.from = JID.parse("hatter@example.com"); + msg.sender = msg.from; + msg.text = "/me says hello"; + + final participant = new Participant("hatter", null, "", false, msg.from, null); + + Assert.equals( + "<div class=\"action\"><div>hatter says hello</div></div>", + msg.build().html(borogove.Html.asString, participant).join("") + ); + } + + public function testRichXEP0245() { + final msg = new ChatMessageBuilder(); + msg.localId = "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>"); + + 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("") + ); + } + + public function testRemoveEventAttr() { + final msg = new ChatMessageBuilder(); + msg.localId = "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>"); + Assert.equals( + "<a>hello</a>", + msg.build().html(borogove.Html.asString).join("") + ); + } + + public function testRemoveStyleScript() { + final msg = new ChatMessageBuilder(); + msg.localId = "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"); + Assert.equals( + "hai", + msg.build().html(borogove.Html.asString).join("") + ); + } +}