From 0352f94c42b4f39401d67b63ba978491f2290ec5 Mon Sep 17 00:00:00 2001 From: Alexander PapaTutuWawa Date: Mon, 2 Nov 2020 17:07:50 +0100 Subject: [PATCH] mix: Implement a subset of MAM and refactor --- mod_mix/helpers.lib.lua | 23 +++ mod_mix/mix.lib.lua | 94 ++++++++++++ mod_mix/mod_mix.lua | 324 +++++++++++++++++++++++----------------- 3 files changed, 306 insertions(+), 135 deletions(-) create mode 100644 mod_mix/helpers.lib.lua create mode 100644 mod_mix/mix.lib.lua diff --git a/mod_mix/helpers.lib.lua b/mod_mix/helpers.lib.lua new file mode 100644 index 0000000..7749e88 --- /dev/null +++ b/mod_mix/helpers.lib.lua @@ -0,0 +1,23 @@ +-- Helper functions for mod_mix +function find(array, f) + -- Searches for an element for which f returns true. The first element + -- and its index are returned. If none are found, then -1, nil is returned. + -- + -- f is a function that takes the value and returns either true or false. + for i, v in pairs(array) do + if f(v) then return i, v; end + end + + return -1, nil; +end + +function find_str(array, str) + -- Returns the index of str in array. -1 if array does not contain str + local i, v = find(array, function(v) return v == str end); + return i; +end + +return { + find_str = find_str, + find = find, +}; diff --git a/mod_mix/mix.lib.lua b/mod_mix/mix.lib.lua new file mode 100644 index 0000000..0ac602e --- /dev/null +++ b/mod_mix/mix.lib.lua @@ -0,0 +1,94 @@ +local st = require("util.stanza"); +local helpers = module:require("mix/helpers"); + +Participant = {}; +Participant.__index = Participant; +function Participant:new(jid, nick) + return setmetatable({ + jid = jid, + nick = nick, + }, Participant); +end + +function Participant:from(config) + return setmetatable(config, Participant); +end + +Channel = {} +Channel.__index = Channel; +function Channel:new(jid, name, description, participants, subscriptions, spid, contacts, adhoc) + return setmetatable({ + jid = jid, + name = name, + description = description, + participants = participants, + subscriptions = subscriptions, + spid = spid, + contacts = contacts, + adhoc = adhoc, + }, Channel); +end +function Channel:from(config) + -- Turn a channel into a Channel object + local o = setmetatable(config, Channel); + for i, _ in pairs(o.participants) do + o.participants[i] = Participant:from(o.participants[i]); + end + return o; +end + +function Channel:get_spid(jid) + -- Returns the Stable Participant ID for the *BARE* jid + return self.spid[jid]; +end + +function Channel:set_spid(jid, spid) + -- Sets the Stable Participant ID for the *BARE* jid + self.spid[jid] = spid; +end + +function Channel:find_participant(jid) + -- Returns the index of a participant in a channel. Returns -1 + -- if the participant is not found + return helpers.find(self.participants, function(p) return p.jid == jid end); +end + +function Channel:is_participant(jid) + -- Returns true if jid is a participant of the channel. False otherwise. + local i, _ = self:find_participant(jid); + return i ~= -1; +end + +function Channel:is_subscribed(jid, node) + -- Returns true of JID is subscribed to node on this channel. Returns false + -- otherwise. + return helpers.find_str(self.subscriptions[jid], node) ~= -1; +end + +function Channel:debug_print() + module:log("debug", "Channel %s (%s)", self.jid, self.name); + module:log("debug", "'%s'", self.description); + for _, p in pairs(self.participants) do + module:log("debug", "=> %s (%s)", p.jid, p.nick); + end + + module:log("debug", "Contacts:"); + for _, c in pairs(self.contacts) do + module:log("debug", "=> %s", c); + end + + if self.subscriptions then + module:log("debug", "Subscriptions:"); + for user, subs in pairs(self.subscriptions) do + module:log("debug", "[%s]", user); + for _, sub in pairs(self.subscriptions[user]) do + module:log("debug", "=> %s", sub); + end + end + end +end + +return { + Channel = Channel, + Participant = Participant +}; diff --git a/mod_mix/mod_mix.lua b/mod_mix/mod_mix.lua index dbb6901..2dda116 100644 --- a/mod_mix/mod_mix.lua +++ b/mod_mix/mod_mix.lua @@ -1,10 +1,6 @@ local host = module:get_host(); -local mm = require("core.modulemanger"); if module:get_host_type() ~= "component" then - error("mix should be loaded as a component", 0); -end -if mm.is_loaded(host, "mix_pam") then - error("mix and mix_pam shouldn't be loaded together on the same host"); + error("MIX should be loaded as a component", 0); end local st = require("util.stanza"); @@ -14,108 +10,51 @@ local id = require("util.id"); local datetime = require("util.datetime"); local time = require("util.time"); local serialization = require("util.serialization"); +local dataforms = require("util.dataforms"); local pep = module:depends("pep"); +local helpers = module:require("mix/helpers"); + +local mixlib = module:require("mix/mix"); +Channel = mixlib.Channel; +Participant = mixlib.Participant; -- XML namespaces local mix_core_xmlns = "urn:xmpp:mix:core:1"; +local mix_node_messages = "urn:xmpp:mix:nodes:messages"; +local mix_node_participants = "urn:xmpp:mix:nodes:participants"; +local mix_node_info = "urn:xmpp:mix:nodes:info"; +local mam_xmlns = "urn:xmpp:mam:2"; -- Persistent data local persistent_channels = module:open_store("mix_channels", "keyval"); local persistent_channel_data = module:open_store("mix_data", "keyval"); +local message_archive = module:open_store("mix_log", "archive"); -- Configuration local default_channel_description = module:get_option("default_description", "A MIX channel for chatting"); local default_channel_name = module:get_option("default_name", "MIX channel"); local restrict_channel_creation = module:get_option("restrict_local_channels", "local"); +-- MAM stuff +local mam_query_form = dataforms.new({ + { name = "FORM_TYPE", type = "hidden", value = mam_xmlns }, + { name = "with", type = "jid-single" }, + { name = "start", type = "text-single" }, + { name = "end", type = "text-single" }, +}); + module:depends("disco"); -- module:depends("mam"); TODO: Once message sending works module:add_identity("conference", "mix", module:get_option("name", "Prosody MIX service")); module:add_feature("http://jabber.org/protocol/disco#info"); module:add_feature(mix_core_xmlns); -Participant = {}; -Participant.__index = Participant; -function Participant:new(jid, nick) - return setmetatable({ - jid = jid, - nick = nick, - }, Participant); -end - -Channel = {} -Channel.__index = Channel; -function Channel:new(jid, name, description, participants, subscriptions, spid, contacts, adhoc) - return setmetatable({ - jid = jid, - name = name, - description = description, - participants = participants, - subscriptions = subscriptions, - spid = spid, - contacts = contacts, - adhoc = adhoc, - }, Channel); -end -function Channel:from(config) - return setmetatable(config, Channel); -end - -function Channel:get_spid(jid) - -- Returns the Stable Participant ID for the *BARE* jid - return self.spid[jid]; -end - -function Channel:set_spid(jid, spid) - -- Sets the Stable Participant ID for the *BARE* jid - self.spid[jid] = spid; -end - -function Channel:debug_print() - module:log("debug", "Channel %s (%s)", self.jid, self.name); - module:log("debug", "'%s'", self.description); - for _, p in pairs(self.participants) do - module:log("debug", "=> %s (%s)", p.jid, p.nick); - end - - module:log("debug", "Contacts:"); - for _, c in pairs(self.contacts) do - module:log("debug", "=> %s", c); - end - - if self.subscriptions then - module:log("debug", "Subscriptions:"); - for user, subs in pairs(self.subscriptions) do - module:log("debug", "[%s]", user); - for _, sub in pairs(self.subscriptions[user]) do - module:log("debug", "=> %s", sub); - end - end - end -end - local channels = {}; function get_channel(jid) -- Return the channel object from the channels array for which the -- JID matches. If none is found, returns -1, nil - for i, channel in pairs(channels) do - if channel.jid == jid then - return i, channel; - end - end - - return -1, nil; -end - -function publish_participant(service, spid, participant) - service:publish("urn:xmpp:mix:nodes:participants", - true, - spid, - st.stanza("item", { id = spid, xmlns = "http://jabber.org/protocol/pubsub" }) - :tag("participant", { xmlns = mix_core_xmlns }) - :tag("nick"):text(participant["nick"]):up() - :tag("jid"):text(participant["jid"])); + return helpers.find(channels, function(c) return c.jid == jid; end); end function save_channels() @@ -139,19 +78,31 @@ function Channel:save_state() module:log("debug", "Saving state done.", self.jid); end +function publish_participant(service, spid, participant) + -- Publish a new participant on the service + service:publish("urn:xmpp:mix:nodes:participants", + true, + spid, + st.stanza("item", { id = spid, xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("participant", { xmlns = mix_core_xmlns }) + :tag("nick"):text(participant["nick"]):up() + :tag("jid"):text(participant["jid"])); +end + function module.load() module:log("info", "Loading MIX channels..."); local channel_list = persistent_channels:get("channels"); if channel_list then for _, channel in pairs(channel_list) do - table.insert(channels, Channel:from(persistent_channel_data:get(channel))); - module:log("debug", "MIX channel %s loaded", channel); + local channel = Channel:from(persistent_channel_data:get(channel)); + table.insert(channels, channel); + module:log("debug", "MIX channel %s loaded", channel.jid); end else module:log("debug", "No MIX channels found."); end - module:log("debug", "Loading MIX channels done."); + module:log("info", "Loading MIX channels done."); end function channel_not_found(stanza) @@ -177,16 +128,27 @@ module:hook("iq/bare/http://jabber.org/protocol/disco#items:query", function(eve module:log("debug", "IQ-GET disco#items"); local origin, stanza = event.origin, event.stanza; + if stanza:get_child("query", "http://jabber.org/protocol/disco#items").attr.node ~= "mix" then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + end + + -- TODO: Maybe here we should check if the user has permissions to get infos + -- about the channel before saying that it doesn't exist to prevent creating + -- an oracle. local _, channel = get_channel(stanza.attr.to); if not channel then origin.send(channel_not_found(stanza)); return true; end - - -- TODO: Maybe check permissions + + if not channel:is_participant(jid.bare(stanza.attr.from)) then + origin.send(st.error_reply(stanza, "cancel", "forbidden")); + return true; + end local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#items", node = "mix" }); - for _, node in pairs({"urn:xmpp:mix:nodes:messages", "urn:xmpp:mix:nodes:participants", "urn:xmpp:mix:nodes:info"}) do + for _, node in pairs({mix_node_messages, mix_node_participants, mix_node_info}) do reply:tag("item", { jid = channel.jid, node = node }):up(); end @@ -196,9 +158,10 @@ end); function can_create_channels(user) -- Returns true when the jid is allowed to create MIX channels. False otherwise. + -- NOTE: Taken from plugins/muc/mod_muc.lua + local host_suffix = host:gsub("^[^%.]+%.", ""); + if restrict_channel_creation == "local" then - -- NOTE: Taken from plugins/muc/mod_muc.lua - local host_suffix = host:gsub("^[^%.]+%.", ""); module:log("debug", "Comparing %s (Sender) to %s (Host)", jid.host(user), host_suffix); if jid.host(user) == host_suffix then @@ -206,6 +169,16 @@ function can_create_channels(user) else return false; end + elseif type(restrict_channel_creation) == "table" then + if helpers.find_str(restrict_channel_creation, user) ~= -1 then + -- User was specifically listed + return true; + elseif helpers.find_str(restrict_channel_creation, jid.host(user)) then + -- User's host was allowed + return true; + end + + return false; end -- TODO: Handle also true/"admin" (See mod_muc) @@ -221,11 +194,14 @@ module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function( -- TODO: Name :tag("identity", { category = "conference", type = "mix", name = "" }):up() :tag("feature", { var = "http://jabber.org/protocol/disco#info" }):up() + :tag("feature", { var = "http://jabber.org/protocol/disco#items" }):up() :tag("feature", { var = mix_core_xmlns }):up(); if can_create_channels(stanza.attr.from) then reply:tag("feature", { var = mix_core_xmlns.."#create-channel" }):up(); end + origin.send(reply); + return true; end); module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event) @@ -242,23 +218,84 @@ module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function( reply:tag("identity", { category = "conference", name = channel.name, type = "mix" }):up(); reply:tag("feature", { var = mix_core_xmlns }):up(); - -- TODO: Once message sending works, uncomment this - -- reply:tag("feature", { var = "urn:xmpp:mam:2" }):up(); + reply:tag("feature", { var = "urn:xmpp:mam:2" }):up(); origin.send(reply); return true; end); --- TODO: Make this a function of Channel: -function find_participant(table, jid) - for i, v in pairs(table) do - if v.jid == jid then - return i; - end +module:hook("iq-set/bare/"..mam_xmlns..":query", function(event) + local stanza, origin = event.stanza, event.origin; + local channel_jid = stanza.attr.to; + local j, channel = get_channel(channel_jid); + if j == -1 then + -- TODO: Is this correct? + origin.send(channel_not_found(stanza)); + return true; end - return -1; -end + -- Check if the user is subscribed to the messages node + if not channel:is_subscribed(stanza.attr.from, mix_node_messages) then + origin.send(st.error_reply(stanza, "cancel", "forbidden")); + return true; + end + + local query = stanza:get_child("query", mam_xmlns); + local filter = {}; + local query_id = query.attr.queryid; + local x = query:get_child("x", "jabber:x:data"); + if x ~= nil then + local form, err = mam_query_form:data(x); + + -- Validate + if (form["start"] and not form["end"]) or (not form["start"] and form["end"]) then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp")); + return true; + end + + module:log("debug", "Got a MAM query between %s and %s", form["start"], form["end"]); + filter = { + start = datetime.parse(form["start"]); ["end"] = datetime.parse(form["end"]) + }; + end + local data, err = message_archive:find(channel_jid, filter); + if not data then + module:log("debug", "MAM error: %s", err); + + if err == "item-not-found" then + origin.send(st.error_reply(stanza, "modify", "item-not-found")); + else + origin.send(st.error_reply(stanza, "cancel", "internal-server-error")); + end + return true; + end + for id, item, when in data do + local msg = st.stanza("message", { from = channel_jid, to = stanza.attr.from, type = "groupchat" }) + :tag("result", { xmlns = mam_xmlns, queryid = query_id, id = id }) + :tag("forwarded", { xmlns = "urn:xmpp:forward:0" }) + :tag("delay", { xmlns = "urn:xmpp:delay", stamp = datetime.datetime(when) }):up(); + msg:add_child(item); + origin.send(msg); + end + return true; +end); + +module:hook("iq-get/bare/"..mam_xmlns..":query", function(event) + if event.stanza.attr.id ~= "form1" then return; end + + module:log("debug", "Got a MAM query for supported fields"); + + local ret = st.reply(event.stanza) + :tag("query", { xmlns = mam_xmlns }) + :tag("x", { xmlns = "jabber:x:data", type = "form"}) + :tag("field", { type = "hidden", var = "FORM_TYPE" }) + :tag("value"):text(mam_xmlns):up():up() + :tag("field", { type = "jid-single", var = "with" }):up() + :tag("field", { type = "text-single", var = "start" }):up() + :tag("field", { type = "text-single", var = "end" }); + module:send(ret); + return true; +end); module:hook("iq-set/bare/"..mix_core_xmlns..":leave", function(event) module:log("debug", "MIX leave received"); @@ -270,8 +307,8 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":leave", function(event) return true; end - local participant_index = find_participant(channel.participants, from); - if participant_index == -1 then + local j, _ = channel:find_participant(from); + if j == -1 then origin.send(st.error_reply(stanza, "cancel", "forbidden", @@ -289,10 +326,10 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":leave", function(event) module:log("debug", "Unsubscribed %s from %s on %s", from, node, channel.jid); end channels[i].subscriptions[from] = nil; - -- Retracting the participatio) + -- Retracting the participation srv:retract("urn:xmpp:mix:nodes:participants", true, channel:get_spid(from), true); -- Removing the user - table.remove(channels[i].participants, participant_index); + table.remove(channels[i].participants, j); channel:save_state(); origin.send(st.reply(stanza):tag("leave", { xmlns = mix_core_xmlns })); @@ -311,8 +348,8 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":join", function(event) end -- Prevent the user from joining multiple times - local participant_index = find_participant(channel.participants, from); - if participant_index ~= -1 then + local j, _ = channel:find_participant(from); + if j ~= -1 then module:send(st.error_reply(stanza, "cancel", "bad-request", "User already joined")); return true; end @@ -373,8 +410,8 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event) return true; end - local participant_index = find_participant(channel.participants, from); - if participant_index == -1 then + local j, participant = channel:find_participant(from); + if j == -1 then channel:debug_print(); module:log("debug", "%s is not a participant in %s", from, channel.jid); return true; @@ -389,10 +426,10 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event) end -- Change the nick - channels[i].participants[participant_index].nick = nick:get_text(); + channels[i].participants[j].nick = nick:get_text(); -- Inform all other members local srv = pep.get_pep_service(channel.jid); - local participant = channel.participants[participant_index]; + --local participant = channel.participants[participant_index]; publish_participant(srv, channel:get_spid(participant.jid), participant); origin.send(st.reply(stanza) @@ -403,15 +440,18 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event) end); function Channel:publish_info(srv) - srv:publish("urn:xmpp:mix:nodes:info", true, timestamp, - st.stanza("item", { id = timestamp, xmlns = "http://jabber.org/protocol/pubsub" }) + local timestamp = datetime.datetime(time.now()); + local info = st.stanza("item", { id = timestamp, xmlns = "http://jabber.org/protocol/pubsub" }) :tag("x", { xmlns = "jabber:x:data", type = "result" }) :tag("field", { var = "FORM_TYPE", type = "hidden" }) :tag("value"):text(mix_core_xmlns):up():up() :tag("field", { var = "Name" }) :tag("value"):text(self.name):up() - :tag("field", { var = "Contact" }) - :tag("value"):text(self.contacts)); + :tag("field", { var = "Contact" }); + for _, contact in pairs(self.contacts) do + info:add_child(st.stanza("value"):text(contact)); + end + srv:publish(mix_node_info, true, timestamp, info); end function create_channel(node, creator, adhoc) @@ -427,7 +467,7 @@ function create_channel(node, creator, adhoc) local srv = pep.get_pep_service(node); local timestamp = datetime.datetime(time.now()); local access_model = adhoc and "whitelist" or "open"; - for _, psnode in pairs({"urn:xmpp:mix:nodes:info", "urn:xmpp:mix:nodes:participants", "urn:xmpp:mix:nodes:messages" }) do + for _, psnode in pairs({ mix_node_info, mix_node_participants, mix_node_messages }) do srv:create(psnode, true, { ["access_model"] = access_model }); -- If the channel is an adhoc channel, then we need to make sure that @@ -455,9 +495,11 @@ module:hook("iq-set/host/"..mix_core_xmlns..":create", function(event) local node; if create.attr.channel ~= nil then -- Create non-adhoc channel + module:log("debug", "Attempting to create channel %s", create.attr.channel); node = create.attr.channel; - local _, channel = get_channel(stanza.attr.to); - if channel then + local i, _ = get_channel(create.attr.channel.."@"..stanza.attr.to); + module:log("debug", "Channel index %s", i); + if i ~= -1 then origin.send(st.error_reply(stanza, "cancel", "conflict", @@ -470,8 +512,8 @@ module:hook("iq-set/host/"..mix_core_xmlns..":create", function(event) -- Create adhoc channel while (true) do node = id.short(); - local _, channel = get_channel(string.format("%s@%s", node, host)); - if channel == nil then + local i, _ = get_channel(string.format("%s@%s", node, host)); + if i == -1 then break; end end @@ -499,11 +541,15 @@ module:hook("iq-set/host/"..mix_core_xmlns..":destroy", function(event) origin.send(channel_not_found(stanza)); return true; end - -- TODO: Check permissions: can_create_channels and maybe compare to the contact JIDs - + + -- TODO: Maybe make this configurable + if helpers.find_str(channel.contacts, from) == -1 then + origin.send(st.error_reply(stanza, "cancel", "forbidden")); + return true; + end -- Remove all registered nodes local srv = pep.get_pep_service(node); - for _, pep_node in pairs({ "urn:xmpp:mix:nodes:participants", "urn:xmpp:mix:nodes:info" }) do + for _, pep_node in pairs({ mix_node_participants, mix_node_info, mix_node_messages }) do srv:delete(pep_node, true); end table.remove(channels, i); @@ -516,12 +562,10 @@ module:hook("iq-set/host/"..mix_core_xmlns..":destroy", function(event) end); module:hook("message/bare", function(event) - module:log("debug", "MIX message detected"); + module:log("debug", "MIX message received"); local stanza, origin = event.stanza, event.origin; - if stanza.attr.type ~= "groupchat" then - -- TODO: Is this correct? - origin.send(st.error_reply(stanza, "cancel", "bad-request", "Non-groupchat message")); + origin.send(st.error_reply(stanza, "modify", "bad-request", "Non-groupchat message")); return true; end @@ -531,25 +575,35 @@ module:hook("message/bare", function(event) origin.send(channel_not_found(stanza)); return true; end - - local participant_index = find_participant(channel.participants, from); - if participant_index == -1 then + + local j, participant = channel:find_participant(from); + if j == -1 then origin.send(st.error_reply(stanza, "cancel", "forbidden", "Not a participant")); return true; end - local participant = channel.participants[participant_index]; local msg = st.clone(stanza); msg:add_child(st.stanza("mix", { xmlns = mix_core_xmlns }) :tag("nick"):text(participant.nick):up() :tag("jid"):text(participant.jid):up()); + + -- Put the message into the archive + local mam_id = uuid.generate(); + msg.attr.id = mam_id; + -- NOTE: The spec says to do so + msg.attr.from = channel.jid; + message_archive:append(stanza.attr.to, mam_id, msg, time.now()); + module:log("debug", "Message put into MAM archive!"); msg.attr.from = channel.jid.."/"..channel:get_spid(from); + for _, p in pairs(channel.participants) do - -- TODO: Add message to the MAM and return its ID - local tmp = st.clone(msg); - tmp.attr.to = p.jid; - module:send(tmp); - module:log("debug", "Message from %s sent to %s", participant.jid, p.jid); + -- Only users who subscribed to the messages node should receive + -- messages + if channel:is_subscribed(p.jid, mix_node_messages) then + local tmp = st.clone(msg); + tmp.attr.to = p.jid; + module:send(tmp); + end end return true; end);