| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2025-11-05 05:37:28 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2025-11-05 05:37:44 UTC |
| parent | 95a83ef9c14e72c0df9e8e90fbf3739430ed28b3 |
| borogove/Caps.hx | +88 | -11 |
| borogove/Chat.hx | +3 | -3 |
| borogove/Client.hx | +5 | -3 |
| borogove/Util.hx | +11 | -0 |
| borogove/persistence/IDB.js | +10 | -5 |
| borogove/persistence/Sqlite.hx | +15 | -5 |
| borogove/queries/DiscoInfoGet.hx | +10 | -3 |
| npm/index.ts | +0 | -2 |
| test/TestAll.hx | +1 | -0 |
| test/TestCaps.hx | +112 | -0 |
diff --git a/borogove/Caps.hx b/borogove/Caps.hx index 3df6ee0..b9223f3 100644 --- a/borogove/Caps.hx +++ b/borogove/Caps.hx @@ -1,20 +1,22 @@ package borogove; import haxe.crypto.Base64; +import haxe.ds.ReadOnlyArray; import haxe.io.Bytes; import haxe.io.BytesData; using Lambda; +using borogove.Util; +import borogove.DataForm; import borogove.Hash; import borogove.Util; -@:expose class Caps { public final node: String; - public final identities: Array<Identity>; - public final features : Array<String>; + public final identities: ReadOnlyArray<Identity>; + public final features : ReadOnlyArray<String>; + public final data: ReadOnlyArray<DataForm>; private var _ver : Null<Hash> = null; - // TODO: data forms @:allow(borogove) private static function withIdentity(caps:KeyValueIterator<String, Null<Caps>>, category:Null<String>, type:Null<String>):Array<String> { @@ -46,10 +48,18 @@ class Caps { return result; } - public function new(node: String, identities: Array<Identity>, features: Array<String>, ?ver: BytesData) { + public function new(node: String, identities: Array<Identity>, features: Array<String>, data: Array<DataForm>, ?ver: BytesData) { + if (ver == null) { + // If we won't need to generate ver we don't actually need to sort + features.sort((x, y) -> x == y ? 0 : (x < y ? -1 : 1)); + identities.sort((x, y) -> x.ver() == y.ver() ? 0 : (x.ver() < y.ver() ? -1 : 1)); + data.sort((x, y) -> Reflect.compare(x.field("FORM_TYPE")?.value, y.field("FORM_TYPE")?.value)); + } + this.node = node; this.identities = identities; this.features = features; + this.data = data; if (ver != null) { _ver = new Hash("sha-1", ver); } @@ -68,6 +78,7 @@ class Caps { for (feature in features) { query.tag("feature", { "var": feature }).up(); } + query.addChildren(data); return query; } @@ -78,12 +89,48 @@ class Caps { node: node, ver: ver() }).up(); + stanza.tag("c", { + xmlns: "urn:xmpp:caps", + }).textTag( + "hash", + Hash.sha256(hashInput()).toBase64(), + { xmlns: "urn:xmpp:hashes:2", algo: "sha-256" } + ).up(); return stanza; } + private function hashInput(): Bytes { + var s = new haxe.io.BytesOutput(); + for (feature in features) { + s.writeS(feature); + s.writeByte(0x1f); + } + s.writeByte(0x1c); + for (identity in identities) { + identity.writeTo(s); + } + s.writeByte(0x1c); + for (form in data) { + final fields = form.fields(); + fields.sort((x, y) -> Reflect.compare([x.name].concat(x.value).join("\x1f"), [y.name].concat(y.value).join("\x1f"))); + for (field in fields) { + final values = field.value; + values.sort(Reflect.compare); + s.writeS(field.name); + s.writeByte(0x1f); + for (value in values) { + s.writeS(value); + s.writeByte(0x1f); + } + s.writeByte(0x1e); + } + s.writeByte(0x1d); + } + s.writeByte(0x1c); + return s.getBytes(); + } + private function computeVer(): Hash { - features.sort((x, y) -> x == y ? 0 : (x < y ? -1 : 1)); - identities.sort((x, y) -> x.ver() == y.ver() ? 0 : (x.ver() < y.ver() ? -1 : 1)); var s = ""; for (identity in identities) { s += identity.ver() + "<"; @@ -91,6 +138,21 @@ class Caps { for (feature in features) { s += feature + "<"; } + for (form in data) { + s += form.field("FORM_TYPE").value[0] + "<"; + final fields = form.fields(); + fields.sort((x, y) -> Reflect.compare(x.name, y.name)); + for (field in fields) { + if (field.name != "FORM_TYPE") { + s += field.name + "<"; + final values = field.value; + values.sort(Reflect.compare); + for (value in values) { + s += value + "<"; + } + } + } + } return Hash.sha1(bytesOfString(s)); } @@ -104,23 +166,38 @@ class Caps { } } -@:expose class Identity { public final category:String; public final type:String; public final name:String; + public final lang:String; - public function new(category:String, type: String, name: String) { + public function new(category:String, type: String, name: String, lang: Null<String> = null) { this.category = category; this.type = type; this.name = name; + this.lang = lang ?? ""; } public function addToDisco(stanza: Stanza) { - stanza.tag("identity", { category: category, type: type, name: name }).up(); + var attrs: haxe.DynamicAccess<String> = { category: category, type: type, name: name }; + if (lang != null && lang != "") attrs.set("xml:lang", lang); + stanza.tag("identity", attrs).up(); } public function ver(): String { - return category + "/" + type + "//" + name; + return category + "/" + type + "/" + (lang ?? "") + "/" + name; + } + + public function writeTo(out: haxe.io.Output) { + out.writeS(category); + out.writeByte(0x1f); + out.writeS(type); + out.writeByte(0x1f); + out.writeS(lang ?? ""); + out.writeByte(0x1f); + out.writeS(name); + out.writeByte(0x1f); + out.writeByte(0x1e); } } diff --git a/borogove/Chat.hx b/borogove/Chat.hx index 3781554..90067cb 100644 --- a/borogove/Chat.hx +++ b/borogove/Chat.hx @@ -547,7 +547,7 @@ abstract class Chat { @:allow(borogove) private function getResourceCaps(resource:String):Caps { - return presence[resource]?.caps ?? new Caps("", [], []); + return presence[resource]?.caps ?? new Caps("", [], [], []); } @:allow(borogove) @@ -1046,7 +1046,7 @@ class DirectChat extends Chat { #end class Channel extends Chat { @:allow(borogove) - private var disco: Caps = new Caps("", [], ["http://jabber.org/protocol/muc"]); + private var disco: Caps = new Caps("", [], ["http://jabber.org/protocol/muc"], []); private var inSync = true; private var sync = null; private var forceLive = false; @@ -1680,7 +1680,7 @@ class SerializedChat { new DirectChat(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy, omemoContactDeviceIDs); } else if (klass == "Channel") { final channel = new Channel(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy); - channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"]); + channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"], []); if (notificationsFiltered == null && !channel.isPrivate()) { mention = filterN = true; } diff --git a/borogove/Client.hx b/borogove/Client.hx index 777abcf..c750fd3 100644 --- a/borogove/Client.hx +++ b/borogove/Client.hx @@ -70,6 +70,7 @@ class Client extends EventEmitter { [ "http://jabber.org/protocol/disco#info", "http://jabber.org/protocol/caps", + "urn:xmpp:caps", "urn:xmpp:avatar:metadata+notify", "http://jabber.org/protocol/nick+notify", "urn:xmpp:bookmarks:1+notify", @@ -86,7 +87,8 @@ class Client extends EventEmitter { #if !NO_OMEMO "eu.siacs.conversations.axolotl.devicelist+notify" #end - ] + ], + [] ); private var _displayName: String; private var fastMechanism: Null<String> = null; @@ -889,7 +891,7 @@ class Client extends EventEmitter { if (resultCaps == null) { final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas"); if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") { - add(new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), new Caps("", [], []))); + add(new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), new Caps("", [], [], []))); } } else { persistence.storeCaps(resultCaps); @@ -915,7 +917,7 @@ class Client extends EventEmitter { if (chat.chatId != jid.asBare().asString()) { if (chat.chatId.contains(query.toLowerCase()) || chat.getDisplayName().toLowerCase().contains(query.toLowerCase())) { final channel = Util.downcast(chat, Channel); - results.push(new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, channel == null || channel.disco == null ? new Caps("", [], []) : channel.disco)); + results.push(new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, channel == null || channel.disco == null ? new Caps("", [], [], []) : channel.disco)); } } if (chat.isTrusted()) { diff --git a/borogove/Util.hx b/borogove/Util.hx index 4a54a35..f6bdf5a 100644 --- a/borogove/Util.hx +++ b/borogove/Util.hx @@ -85,3 +85,14 @@ macro function getGitVersion():haxe.macro.Expr.ExprOf<String> { return macro $v{commitHash}; #end } + +class Util { + inline static public function at<T>(arr: Array<T>, i: Int): T { + return arr[i]; + } + + inline static public function writeS(o: haxe.io.Output, s: String) { + final b = bytesOfString(s); + o.writeBytes(b, 0, b.length); + } +} diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js index 4c7c3ba..0fd229c 100644 --- a/borogove/persistence/IDB.js +++ b/borogove/persistence/IDB.js @@ -280,9 +280,9 @@ export default async (dbname, media, tokenize, stemmer) => { readUpToId: chat.readUpToId, readUpToBy: chat.readUpToBy, notificationSettings: chat.notificationsFiltered() ? { mention: chat.notifyMention(), reply: chat.notifyReply() } : null, - disco: chat.disco, + disco: { ...chat.disco, data: chat.disco?.data?.map(d => d.toString()) }, omemoDevices: chat.omemoContactDeviceIDs, - class: chat instanceof borogove.DirectChat ? "DirectChat" : (chat instanceof borogove.Channel ? "Channel" : "Chat") + class: chat instanceof borogove.DirectChat ? "DirectChat" : (chat instanceof borogove.Channel ? "Channel" : "Chat") }); } }, @@ -308,7 +308,7 @@ export default async (dbname, media, tokenize, stemmer) => { r.notificationSettings === undefined ? null : r.notificationSettings != null, r.notificationSettings?.mention, r.notificationSettings?.reply, - r.disco ? new borogove.Caps(r.disco.node, r.disco.identities, r.disco.features) : null, + r.disco ? new borogove.Caps(r.disco.node, r.disco.identities, r.disco.features, (r.disco.data || []).map(s => borogove.Stanza.parse(s))) : null, r.omemoDevices || [], r.class ))); @@ -624,7 +624,7 @@ export default async (dbname, media, tokenize, stemmer) => { storeCaps: function(caps) { const tx = db.transaction(["keyvaluepairs"], "readwrite"); const store = tx.objectStore("keyvaluepairs"); - store.put(caps, "caps:" + caps.ver()).onerror = console.error; + store.put({ ...caps, data: caps.data.map(d => d.toString()) }, "caps:" + caps.ver()).onerror = console.error; }, getCaps: async function(ver) { @@ -632,7 +632,12 @@ export default async (dbname, media, tokenize, stemmer) => { const store = tx.objectStore("keyvaluepairs"); const raw = await promisifyRequest(store.get("caps:" + ver)); if (raw) { - return new borogove.Caps(raw.node, raw.identities.map((identity) => new borogove.Identity(identity.category, identity.type, identity.name)), raw.features); + return new borogove.Caps( + raw.node, + raw.identities.map((identity) => new borogove.Identity(identity.category, identity.type, identity.name)), + raw.features, + (raw.data || []).map(s => borogove.Stanza.parse(s)) + ); } return null; diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx index 7b0884a..4d2005d 100644 --- a/borogove/persistence/Sqlite.hx +++ b/borogove/persistence/Sqlite.hx @@ -241,7 +241,7 @@ class Sqlite implements Persistence implements KeyValueStore { final chats: Array<Dynamic> = []; for (row in result) { final capsJson = row.caps == null ? null : Json.parse(row.caps); - row.capsObj = capsJson == null ? null : new Caps(capsJson.node, capsJson.identities.map(i -> new Identity(i.category, i.type, i.name), row.caps_ver), capsJson.features); + row.capsObj = capsJson == null ? null : hydrateCaps(capsJson, row.caps_ver); final presenceJson: DynamicAccess<Dynamic> = Json.parse(row.presence); row.presenceJson = presenceJson; for (resource => presence in presenceJson) { @@ -258,7 +258,7 @@ class Sqlite implements Persistence implements KeyValueStore { final capsMap: Map<String, Caps> = []; for (row in result.caps) { final json = Json.parse(row.caps); - capsMap[Base64.encode(Bytes.ofData(row.sha1))] = new Caps(json.node, json.identities.map(i -> new Identity(i.category, i.type, i.name), row.sha1), json.features); + capsMap[Base64.encode(Bytes.ofData(row.sha1))] = hydrateCaps(json, row.sha1); } result.caps = null; final chats = []; @@ -571,7 +571,7 @@ class Sqlite implements Persistence implements KeyValueStore { if (!first) q.add(","); q.add("(?,jsonb(?))"); params.push(ver); - params.push(Json.stringify({ node: caps.node, identities: caps.identities, features: caps.features })); + params.push(Json.stringify({ node: caps.node, identities: caps.identities, features: caps.features, data: caps.data.map(d -> d.toString()) })); first = false; } if (params.length < 1) return; @@ -591,7 +591,7 @@ class Sqlite implements Persistence implements KeyValueStore { ).then(result -> { for (row in result) { final json = Json.parse(row.caps); - return new Caps(json.node, json.identities.map(i -> new Identity(i.category, i.type, i.name)), json.features, verData); + return hydrateCaps(json, verData); } return null; }); @@ -723,7 +723,7 @@ class Sqlite implements Persistence implements KeyValueStore { serviceId: row.service_id, name: row.name, node: row.node, - caps: new Caps(json.node, (json.identities ?? []).map(i -> new Identity(i.category, i.type, i.name)), features) + caps: hydrateCaps(json) }); } } @@ -871,6 +871,16 @@ class Sqlite implements Persistence implements KeyValueStore { })); } + private function hydrateCaps(o: { node: Null<String>, identities: Array<{category: String, type: String, name: String}>, features: Array<String>, ?data: Array<String> }, ver: Null<BytesData> = null) { + return new Caps( + o.node, + (o.identities ?? []).map(i -> new Identity(i.category, i.type, i.name)), + o.features ?? [], + (o.data ?? []).map(d -> Stanza.parse(d)), + ver + ); + } + #if !NO_OMEMO // OMEMO // TODO diff --git a/borogove/queries/DiscoInfoGet.hx b/borogove/queries/DiscoInfoGet.hx index 4eeff2d..366d725 100644 --- a/borogove/queries/DiscoInfoGet.hx +++ b/borogove/queries/DiscoInfoGet.hx @@ -6,7 +6,6 @@ import haxe.Exception; import borogove.ID; import borogove.ResultSet; import borogove.Stanza; -import borogove.Stream; import borogove.queries.GenericQuery; import borogove.Caps; @@ -46,8 +45,16 @@ class DiscoInfoGet extends GenericQuery { final features = q.allTags("feature"); result = new Caps( q.attr.get("node"), - identities.map((identity) -> new Identity(identity.attr.get("category"), identity.attr.get("type"), identity.attr.get("name"))), - features.map((feature) -> feature.attr.get("var")) + identities.map((identity) -> + new Identity( + identity.attr.get("category"), + identity.attr.get("type"), + identity.attr.get("name"), + identity.attr.get("xml:lang") + ) + ), + features.map((feature) -> feature.attr.get("var")), + q.allTags("x", "jabber:x:data") ); } return result; diff --git a/npm/index.ts b/npm/index.ts index 9ceb01f..101a04e 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -5,7 +5,6 @@ import { borogove } from "./borogove.js"; // TODO: should we autogenerate this? export import AvailableChat = borogove.AvailableChat; -export import Caps = borogove.Caps; export import Channel = borogove.Channel; export import Chat = borogove.Chat; export import ChatAttachment = borogove.ChatAttachment; @@ -18,7 +17,6 @@ export import DirectChat = borogove.DirectChat; export import EventEmitter = borogove.EventEmitter; export import Hash = borogove.Hash; export import Identicon = borogove.Identicon; -export import Identity = borogove.Identity; export import Notification = borogove.Notification; export import Participant = borogove.Participant; export import Persistence = borogove.Persistence; diff --git a/test/TestAll.hx b/test/TestAll.hx index b0e51ac..6d7c949 100644 --- a/test/TestAll.hx +++ b/test/TestAll.hx @@ -9,6 +9,7 @@ class TestAll { new TestSessionDescription(), new TestChatMessageBuilder(), new TestStanza(), + new TestCaps(), ]); } } diff --git a/test/TestCaps.hx b/test/TestCaps.hx new file mode 100644 index 0000000..bcd3901 --- /dev/null +++ b/test/TestCaps.hx @@ -0,0 +1,112 @@ +package test; + +import utest.Assert; +import utest.Async; +import borogove.Caps; +import borogove.Hash; +import borogove.Stanza; +import borogove.queries.DiscoInfoGet; + +@:access(borogove.Caps.hashInput) +@:access(borogove.Hash.sha256) +class TestCaps extends utest.Test { + final example = ' + <iq><query xmlns="http://jabber.org/protocol/disco#info" node="somenode"> + <identity category="client" name="Tkabber" type="pc" xml:lang="en"/> + <identity category="client" name="Ткаббер" type="pc" xml:lang="ru"/> + <feature var="games:board"/> + <feature var="http://jabber.org/protocol/activity"/> + <feature var="http://jabber.org/protocol/activity+notify"/> + <feature var="http://jabber.org/protocol/bytestreams"/> + <feature var="http://jabber.org/protocol/chatstates"/> + <feature var="http://jabber.org/protocol/commands"/> + <feature var="http://jabber.org/protocol/disco#info"/> + <feature var="http://jabber.org/protocol/disco#items"/> + <feature var="http://jabber.org/protocol/evil"/> + <feature var="http://jabber.org/protocol/feature-neg"/> + <feature var="http://jabber.org/protocol/geoloc"/> + <feature var="http://jabber.org/protocol/geoloc+notify"/> + <feature var="http://jabber.org/protocol/ibb"/> + <feature var="http://jabber.org/protocol/iqibb"/> + <feature var="http://jabber.org/protocol/mood"/> + <feature var="http://jabber.org/protocol/mood+notify"/> + <feature var="http://jabber.org/protocol/rosterx"/> + <feature var="http://jabber.org/protocol/si"/> + <feature var="http://jabber.org/protocol/si/profile/file-transfer"/> + <feature var="http://jabber.org/protocol/tune"/> + <feature var="http://www.facebook.com/xmpp/messages"/> + <feature var="http://www.xmpp.org/extensions/xep-0084.html#ns-metadata+notify"/> + <feature var="jabber:iq:avatar"/> + <feature var="jabber:iq:browse"/> + <feature var="jabber:iq:dtcp"/> + <feature var="jabber:iq:filexfer"/> + <feature var="jabber:iq:ibb"/> + <feature var="jabber:iq:inband"/> + <feature var="jabber:iq:jidlink"/> + <feature var="jabber:iq:last"/> + <feature var="jabber:iq:oob"/> + <feature var="jabber:iq:privacy"/> + <feature var="jabber:iq:roster"/> + <feature var="jabber:iq:time"/> + <feature var="jabber:iq:version"/> + <feature var="jabber:x:data"/> + <feature var="jabber:x:event"/> + <feature var="jabber:x:oob"/> + <feature var="urn:xmpp:avatar:metadata+notify"/> + <feature var="urn:xmpp:ping"/> + <feature var="urn:xmpp:receipts"/> + <feature var="urn:xmpp:time"/> + <x xmlns="jabber:x:data" type="result"> + <field type="hidden" var="FORM_TYPE"> + <value>urn:xmpp:dataforms:softwareinfo</value> + </field> + <field var="software"> + <value>Tkabber</value> + </field> + <field var="software_version"> + <value>0.11.1-svn-20111216-mod (Tcl/Tk 8.6b2)</value> + </field> + <field var="os"> + <value>Windows</value> + </field> + <field var="os_version"> + <value>XP</value> + </field> + </x> + </query></iq> + '; + + public function caps(): Caps { + final dig = new DiscoInfoGet(""); + dig.handleResponse(Stanza.parse(example)); + return dig.getResult(); + } + + public function testCaps2() { + final sha256 = Hash.sha256(caps().hashInput()).toBase64(); + Assert.equals("u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=", sha256); + } + + public function testCaps1() { + final sha1 = caps().ver(); + Assert.equals("cePxJUNNZuDoNDbCMqs2VNEcJeY=", sha1); + } + + public function testRoundTrip() { + final dig = new DiscoInfoGet(""); + final iq = new Stanza("iq"); + iq.addChild(caps().discoReply()); + dig.handleResponse(iq); + final sha256 = Hash.sha256(dig.getResult().hashInput()).toBase64(); + Assert.equals("u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=", sha256); + } + + public function testAddC() { + final s = new Stanza("presence"); + caps().addC(s); + Assert.equals( + '<presence><c xmlns="http://jabber.org/protocol/caps" ver="cePxJUNNZuDoNDbCMqs2VNEcJeY=" node="somenode" hash="sha-1"/><c xmlns="urn:xmpp:caps"><hash xmlns="urn:xmpp:hashes:2" algo="sha-256">u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=</hash></c></presence>', + s.toString() + ); + } +}