git » sdk » main » tree

[main] / borogove / Caps.hx

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;

// exposed for IDB.js, not really part of public API
@:expose
@:nullSafety(StrictThreaded)
class Caps {
	public final node: String;
	public final identities: ReadOnlyArray<Identity>;
	public final features : ReadOnlyArray<String>;
	public final data: ReadOnlyArray<DataForm>;
	private var _ver : Null<Hash> = null;

	private static function filter(caps:KeyValueIterator<Null<String>, Caps>, predicate:Caps->Bool):KeyValueIterator<Null<String>, Caps> {
		var nextMatch:Null<{key:Null<String>, value:Caps}> = null;

		final findNext = () -> {
			while (caps.hasNext()) {
				var n = caps.next();
				if (predicate(n.value)) {
					nextMatch = n;
					return;
				}
			}
			nextMatch = null;
		};

		findNext();

		return {
			hasNext: () -> nextMatch != null,
			next: () -> {
				final r = nextMatch;
				if (r == null) throw "No more elements";

				findNext();
				return r;
			}
		};
	}

	@:allow(borogove)
	private static function withIdentity(caps:KeyValueIterator<Null<String>, Caps>, category:Null<String>, type:Null<String>):KeyValueIterator<Null<String>, Caps> {
		return filter(caps, (c) -> c.identities.exists((identity) -> (category == null || category == identity.category) && (type == null || type == identity.type)));
	}

	@:allow(borogove)
	private static function withFeature(caps:KeyValueIterator<Null<String>, Caps>, feature:String):KeyValueIterator<Null<String>, Caps> {
		return filter(caps, (c) -> c.features.contains(feature));
	}

	/**
		Create a capabilities description.

		@param node capability node identifier
		@param identities disco identities advertised by the entity
		@param features disco feature namespaces advertised by the entity
		@param data extended disco data forms
		@param ver optional precomputed capability hash bytes
	**/
	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 ?? []).join("\n"), (y.field("FORM_TYPE")?.value ?? []).join("\n")));
		}

		this.node = node;
		this.identities = identities;
		this.features = features;
		this.data = data;
		if (ver != null) {
			_ver = new Hash("sha-1", ver);
		}
	}

	/**
		Check whether these capabilities describe a channel-like chat target.

		@param chatId ID to evaluate against the capability set
		@returns true when the target looks like a MUC/channel
	**/
	public function isChannel(chatId: String) {
		if (chatId.indexOf("@") < 0) return false; // MUC must have a localpart
		return features.contains("http://jabber.org/protocol/muc") && identities.find((identity) -> identity.category == "conference") != null;
	}

	/**
		Build a disco#info query payload for this capability set.
	**/
	public function discoReply():Stanza {
		final query = new Stanza("query", { xmlns: "http://jabber.org/protocol/disco#info" });
		for (identity in identities) {
			identity.addToDisco(query);
		}
		for (feature in features) {
			query.tag("feature", { "var": feature }).up();
		}
		query.addChildren(data);
		return query;
	}

	/**
		Add capability advertisements to a stanza.

		@param stanza stanza to mutate
		@returns the same stanza for chaining
	**/
	public function addC(stanza: Stanza): Stanza {
		stanza.tag("c", {
			xmlns: "http://jabber.org/protocol/caps",
			hash: "sha-1",
			node: node,
			ver: ver()
		}).up();
		if (identities.length > 0 || features.length > 0 || data.length > 0) {
			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 {
		var s = "";
		for (identity in identities) {
			s += identity.ver() + "<";
		}
		for (feature in features) {
			s += feature + "<";
		}
		for (form in data) {
			final formType = form.field("FORM_TYPE");
			s += formType == null ? "" : formType.value[0];
			s += "<";
			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));
	}

	/**
		Get the raw XEP-0115 capability hash object for this capability set.
	**/
	public function verRaw(): Hash {
		final ver = _ver;
		if (ver != null) return ver;

		final newVer = computeVer();
		_ver = newVer;
		return newVer;
	}

	/**
		Get the XEP-0115 capability hash encoded in base64.
	**/
	public function ver(): String {
		return verRaw().toBase64();
	}
}

@:expose
class Identity {
	public final category:String;
	public final type:String;
	public final name:String;
	public final lang:String;

	/**
		Create a disco identity.
	**/
	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 ?? "";
	}

	/**
		Add this identity to a disco#info payload.
	**/
	public function addToDisco(stanza: Stanza) {
		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();
	}

	/**
		Get the identity string used when computing capability hashes.
	**/
	public function ver(): String {
		return category + "/" + type + "/" + (lang ?? "") + "/" + name;
	}

	/**
		Write the identity in canonical capability-hash form.
	**/
	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);
	}
}