From fd9735faba00286d4bf8c52d7c8c93bd06f0911f Mon Sep 17 00:00:00 2001 From: Alexander PapaTutuWawa Date: Sun, 28 Feb 2021 11:29:21 +0100 Subject: [PATCH] mod_mix: Various changes - Refactor and clean code - Implement lots of MIX-ADMIN - Beginning of MIX-ANON --- mod_mix/anon.lib.lua | 14 + mod_mix/helpers.lib.lua | 9 +- mod_mix/mix.lib.lua | 339 ++++++++++++++++++- mod_mix/mod_mix.lua | 664 ++++++++++++++++++++++++++----------- mod_mix/namespaces.lib.lua | 19 ++ 5 files changed, 846 insertions(+), 199 deletions(-) create mode 100644 mod_mix/anon.lib.lua create mode 100644 mod_mix/namespaces.lib.lua diff --git a/mod_mix/anon.lib.lua b/mod_mix/anon.lib.lua new file mode 100644 index 0000000..aa35500 --- /dev/null +++ b/mod_mix/anon.lib.lua @@ -0,0 +1,14 @@ +local dataforms = require("util.dataforms"); +local namespaces = module:require("mix/namespaces"); + +local mix_anon_form = dataforms.new({ + { name = "FORM_TYPE", type = "hidden", value = namespaces.anon }, + { name = "JID Visibility", type = "text-single" }, + { name = "Private Messages", type = "text-single" }, + { name = "Presence", type = "text-single" }, + { name = "vCard", type = "text-single" }, +}); + +return { + form = mix_anon_form; +}; diff --git a/mod_mix/helpers.lib.lua b/mod_mix/helpers.lib.lua index 0f89aed..ee9cc05 100644 --- a/mod_mix/helpers.lib.lua +++ b/mod_mix/helpers.lib.lua @@ -13,11 +13,18 @@ end local function find_str(array, str) -- Returns the index of str in array. -1 if array does not contain str - local i, _ = find(array, function(v) return v == str end); + local i, _ = find(array, function(v) return v == str; end); return i; end +local function in_array(array, element) + -- Returns true if element is in array. False otherwise. + local i, _ = find(array, function(v) return v == element; end); + return i ~= -1; +end + return { find_str = find_str, find = find, + in_array = in_array, }; diff --git a/mod_mix/mix.lib.lua b/mod_mix/mix.lib.lua index 484c68f..199d447 100644 --- a/mod_mix/mix.lib.lua +++ b/mod_mix/mix.lib.lua @@ -1,12 +1,20 @@ local st = require("util.stanza"); +local array = require("util.array"); +local jid_lib = require("util.jid"); +local uuid = require("util.uuid"); +local time = require("util.time"); local helpers = module:require("mix/helpers"); +local namespaces = module:require("mix/namespaces"); +local pep = module:depends("pep"); -Participant = {}; + +local Participant = {}; Participant.__index = Participant; -function Participant:new(jid, nick) +function Participant:new(jid, nick, config) return setmetatable({ jid = jid, nick = nick, + config = config, }, Participant); end @@ -14,9 +22,9 @@ function Participant:from(config) return setmetatable(config, Participant); end -Channel = {} +local Channel = {}; Channel.__index = Channel; -function Channel:new(jid, name, description, participants, subscriptions, spid, contacts, adhoc) +function Channel:new(jid, name, description, participants, administrators, owners, subscriptions, spid, contacts, adhoc, allowed, banned, config, nodes) return setmetatable({ jid = jid, name = name, @@ -26,6 +34,12 @@ function Channel:new(jid, name, description, participants, subscriptions, spid, spid = spid, contacts = contacts, adhoc = adhoc, + administrators = administrators, + owners = owners, + config = config, + nodes = nodes, + allowed = allowed, + banned = banned, }, Channel); end function Channel:from(config) @@ -88,7 +102,322 @@ function Channel:debug_print() end end +function Channel:allow_jid(jid) + local srv = pep.get_pep_service(jid_lib.node(self.jid)); + array.push(self.allowed, jid); + srv:set_node_config(namespaces.allowed, + true, + { ["max_items"] = #self.allowed + 1 }); + srv:publish(namespaces.allowed, true, jid, nil); +end + +function Channel:ban_jid(jid) + local srv = pep.get_pep_service(jid_lib.node(self.jid)); + self.banned:push(jid); + srv:set_node_config(namespaces.banned, + true, + { ["max_items"] = #self.banned + 1 }); + srv:publish(namespaces.banned, true, jid, nil); + + local i = helpers.find_str(jid, "@"); + if i ~= nil then + -- "@" in JID => We're banning a real JID + if self:is_participant(jid) then + self:remove_participant(jid); + end + else + -- No "@" in JID => We're banning a host + for _, participant in self.participants do + if jid_lib.host(participant.jid) == jid then + self:remove_participant(participant.jid); + end + end + end +end + +function Channel:remove_participant(jid) + -- Removes a user form the channel. May be a kick, may be a leave. + local srv = pep.get_pep_service(jid_lib.node(self.jid)); + + -- Step 1: Unsubscribe from all subscribed nodes + for _, node in ipairs(self.subscriptions[jid]) do + srv:remove_subscription(node, true, jid); + end + self.subscriptions[jid] = nil; + + -- Step 2: Remove affiliations to all nodes + for _, node in ipairs(self.nodes) do + srv:set_affiliation(node, true, jid, "outcast"); + end + + -- Step 3: Remove jid as participant + local i, _ = self:find_participant(jid); + table.remove(self.participants, i); + + -- Step 4: Retract jid from participants node + local spid = self:get_spid(jid); + local notifier = st.stanza("retract", { id = spid }); + srv:retract(namespaces.participants, true, jid, notifier); + self:save_state(); +end + +function Channel:is_allowed(jid) + local i, _ = helpers.find(self.allowed, jid); + local j, _ = helpers.find(self.allowed, jid_lib.host(jid)); + + return i ~= -1 and j ~= -1; +end + +function Channel:is_admin(jid) + return helpers.in_array(self.administrators, jid); +end + +function Channel:is_owner(jid) + return helpers.in_array(self.owners, jid); +end + +function Channel:may_subscribe(jid, node, joining) + -- Returns true when a JID is allowed to subscribe to a node. + -- If joining is true and the node is set to "participants", then + -- true will be returned as the user will be a participant. + -- NOTE: When using this function directly, be careful to + -- check first whether the JID is allowed to join. + -- TODO: Presence + local group = ""; + if node == namespaces.info then + group = self.config["Information Node Subscription"]; + elseif node == namespaces.participants then + group = self.config["Participants Node Subscription"]; + elseif node == namespaces.messages then + group = self.config["Messages Node Subscription"]; + elseif node == namespaces.allowed then + group = self.config["Allowed Node Subscription"]; + elseif node == namespaces.banned then + group = self.config["Banned Node Subscription"]; + elseif node == namespaces.config then + group = "admins"; + elseif node == namespaces.avatar or node == namespaces.avatar_metadata then + group = "participants"; + end + + module:log("debug", "may_subscribe: Group: %s", group); + module:log("debug", "Is Owner: %s", self:is_owner(jid)); + module:log("debug", "Is Admin: %s", self:is_admin(jid)); + module:log("debug", "Is Participant: %s", self:is_participant(jid)); + module:log("debug", "Is allowed: %s", self:is_allowed(jid)); + + if group == "anyone" then + return true; + elseif group == "allowed" then + return self:is_allowed(jid); + elseif group == "nobody" then + return false; + elseif group == "admins" then + return self:is_admin(jid) or self:is_owner(jid); + elseif group == "owners" then + return self:is_owner(jid); + elseif group == "participants" then + return self:is_participant(jid) or self:is_admin(jid) or self:is_owner(jid) or joining; + end + + return false; +end + +function Channel:may_access(jid, node) + -- Access means read and subscribe + local group = ""; + if node == namespaces.config then + -- TODO: For some reason, this is failing + --group = self.config["Configuration Node Access"]; + group = "owners"; + end + + if group == "nobody" then + return false; + elseif group == "allowed" then + return self:is_allowed(jid); + elseif group == "admins" then + return self:is_admin(jid) or self:is_owner(jid); + elseif group == "owners" then + return self:is_owner(jid); + elseif group == "participants" then + return self:is_participant(jid) or self:is_admin(jid) or self:is_owner(jid); + end + + return false; +end + +function Channel:may_update(jid, node) + module:log("debug", "Update node %s", node); + local group = ""; + -- TODO: Deal with avatars + if node == namespaces.info then + group = self.config["Information Node Update Rights"]; + elseif node == namespaces.config then + -- TODO: Make this configurable + group = "admins"; + elseif node == namespaces.avatar or node == namespaces.avatar_metadata then + group = self.config["Avatar Nodes Update Rights"]; + end + + if group == "admins" then + return self:is_admin(jid) or self:is_owner(jid); + elseif group == "owners" then + return self:is_owner(jid); + elseif group == "participants" then + return self:is_participant(jid) or self:is_admin(jid) or self:is_owner(jid); + elseif group == "allowed" then + return self:is_allowed(jid); + end + + return false; +end + +function Channel:may_join(jid) + -- Does the banned node exist? + if helpers.in_array(self.nodes, namespaces.banned) then + local host = jid_lib.host(jid); + if helpers.in_array(self.banned, host) or helpers.in_array(self.banned, jid) then + -- User or host is banned + return false; + end + end + + -- Does the allowed node exist? + if helpers.in_array(self.nodes, namespaces.allowed) then + local host = jid_lib.host(jid); + if helpers.in_array(self.allowed, host) or helpers.in_array(self.allowed, jid) then + -- User or host is allowed + return true; + else + -- "Whitelist" is on but user or host is not on it. + return false; + end + end + + return true; +end + +function Channel:may(jid, action, data) + if action == "subscribe" then + return self:may_subscribe(jid, data, false); + elseif action == "access" then + return self:may_access(jid, data); + elseif action == "update" then + return self:may_update(jid, data); + elseif action == "pm" then + return self.config["Private Messages"]; + elseif action == "join" then + return self:may_join(jid); + elseif action == "invite" then + if not (self:is_admin(jid) or self:is_owner(jid)) then + return self.config["Participation Addition by Invitation from Participant"]; + else + return true; + end + elseif action == "retract" then + -- Is this the same user + -- TODO: jid == data may not work when using SPIDs + if jid == data then + return self.config["User Message Retraction"]; + else + local group = self.config["Administrator Message Retraction Rights"]; + if group == "nobody" then + return false; + elseif group == "admins" then + return self:is_admin(jid) or self:is_owner(jid); + elseif group == "owners" then + return self:is_owner(jid); + end + + return false; + end + end +end + +function Channel:broadcast_message(message, participant, archive) + -- Broadcast a message stanza according to rules layed out by + -- XEP-0369 + local msg = st.clone(message); + msg:add_child(st.stanza("mix", { xmlns = namespaces.mix_core }) + :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 = self.jid; + archive:append(message.attr.to, mam_id, msg, time.now()); + msg.attr.from = self.jid.."/"..self:get_spid(participant.jid); + + if module:fire_event("mix-broadcast-message", { message = msg, channel = self }) then + return; + end + + for _, p in pairs(self.participants) do + -- Only users who subscribed to the messages node should receive + -- messages + if self:is_subscribed(p.jid, namespaces.messages) then + local tmp = st.clone(msg); + tmp.attr.to = p.jid; + module:send(tmp); + end + end +end + +function Channel:publish_participant(spid, participant) + -- Publish a new participant on the service + local srv = pep.get_pep_service(jid_lib.node(self.jid)); + + -- NOTE: This function has be to called *after* the new participant + -- has been added to the channel.participants array + srv:set_node_config(namespaces.participants, + true, + { ["max_items"] = #self.participants }); + srv:publish(namespaces.participants, + true, + spid, + st.stanza("item", { id = spid, xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("participant", { xmlns = namespaces.mix_core }) + :tag("nick"):text(participant["nick"]):up() + :tag("jid"):text(participant["jid"])); +end + +local default_channel_configuration = { + ["Last Change Made By"] = ""; + ["Owner"] = {}; -- Filled in during creation + ["Administrator"] = {}; + ["End of Life"] = ""; + ["Nodes Present"] = {}; -- Filled in during creation + ["Messages Node Subscription"] = "participants"; + ["Presence Node Subscription"] = "participants"; + ["Participants Node Subscription"] = "participants"; + ["Information Node Subscription"] = "participants"; + ["Allowed Node Subscription"] = "admins"; + ["Banned Node Subscription"] = "admins"; + ["Configuration Node Access"] = "owners"; + ["Information Node Update Rights"] = "admins"; + ["Avatar Node Update Rights"] = "admins"; + ["Open Presence"] = false; + ["Participants Must Provide Presence"] = false; + ["User Message Retraction"] = false; + ["Administrator Message Retraction Rights"] = "owners"; + ["Participation Addition by Invitation from Participant"] = false; + ["Private Messages"] = true; + ["Mandatory Nicks"] = true; +}; + +local default_participant_configuration = { + ["JID Visibility"] = "never"; + ["Private Messages"] = "allow"; + ["Presence"] = "share"; + ["vCard"] = "block"; +}; + return { Channel = Channel, - Participant = Participant + Participant = Participant, + default_channel_configuration = default_channel_configuration, + default_participant_configuration = default_participant_configuration, }; diff --git a/mod_mix/mod_mix.lua b/mod_mix/mod_mix.lua index 834a725..b70e390 100644 --- a/mod_mix/mod_mix.lua +++ b/mod_mix/mod_mix.lua @@ -1,5 +1,6 @@ --- Big TODOlist --- TODO: Channel:is_subscribed could be replaced by get_pep_service(...):get_subscription +-- TODO: Channel info's Contacts is empty +-- TODO: Maybe reset affiliations to none instead of outcast +-- TODO: Handle creation and deletion of avatar nodes when publishing to :config local host = module:get_host(); if module:get_host_type() ~= "component" then @@ -12,27 +13,18 @@ local uuid = require("util.uuid"); 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 array = require("util.array"); +local set = require("util.set"); local pep = module:depends("pep"); + local helpers = module:require("mix/helpers"); +local namespaces = module:require("mix/namespaces"); +local anon = module:require("mix/anon"); 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_anon_xmlns = "urn:xmpp:mix:anon:0"; ---local mix_admin_xmlns = "urn:xmpp:mix:admin:0"; -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 mix_node_allowed = "urn:xmpp:mix:nodes:allowed"; ---local mix_node_banned = "urn:xmpp:mix:nodes:banned"; ---local mix_node_config = "urn:xmpp:mix:nodes:config"; - -local mam_xmlns = "urn:xmpp:mam:2"; +local Channel = mixlib.Channel; +local Participant = mixlib.Participant; -- Persistent data local persistent_channels = module:open_store("mix_channels", "keyval"); @@ -43,29 +35,29 @@ local message_archive = module:open_store("mix_log", "archive"); 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"); +local service_name = module:get_option("service_name", "Prosody MIX service"); + +-- MIX configuration +local default_mix_nodes = array { namespaces.info, namespaces.participants, namespaces.messages, namespaces.config }; -- Dataforms -- MAM local mam_query_form = dataforms.new({ - { name = "FORM_TYPE", type = "hidden", value = mam_xmlns }, + { name = "FORM_TYPE", type = "hidden", value = namespaces.mam }, { name = "with", type = "jid-single" }, { name = "start", type = "text-single" }, { name = "end", type = "text-single" }, }); --- MIX Anon -local mix_anon_form = dataforms.new({ - { name = "FORM_TYPE", type = "hidden", value = mix_anon_xmlns }, - { name = "JID Visibility", type = "text-single" }, - { name = "Private Messages", type = "text-single" }, - { name = "Presence", type = "text-single" }, - { name = "vCard", type = "text-single" }, +-- MIX Core +local mix_info_form = dataforms.new({ + { name = "FORM_TYPE", type = "hidden", value = namespaces.mix_core }, + { name = "Name", type = "text-single" }, + { name = "Description", type = "text-single" }, + { name = "Contact", type = "jid-multi" } }); - - -- MIX-ADMIN stuff ---[[ local mix_config_form = dataforms.new({ - { name = "FORM_TYPE", type = "hidden", value = mix_admin_xmlns }, + { name = "FORM_TYPE", type = "hidden", value = namespaces.mix_admin }, { name = "Last Change Made By", type = "jid-single" }, { name = "Owner", type = "jid-multi" }, { name = "Administrator", type = "jid-multi" }, @@ -78,13 +70,45 @@ local mix_config_form = dataforms.new({ { name = "Allowed Node Subscription", type = "list-single" }, { name = "Banned Node Subscription", type = "list-single" }, { name = "Configuration Node Access", type = "list-single" }, + { name = "Information Node Update Rights", type = "list-single" }, + { name = "Avatar Node Update Rights", type = "list-single" }, + { name = "Open Presence", type = "boolean" }, + { name = "Participants Must Provide Presence", type = "boolean" }, + { name = "User Message Retraction", type = "boolean" }, + { name = "Administrator Message Retraction Rights", type = "list-single" }, + { name = "Participation Addition by Invitation from Participant", type = "boolean" }, + { name = "Private Messages", type = "boolean" }, + { name = "Mandatory Nicks", type = "boolean "}, }); -]]-- + +local function mix_admin_node_to_value(node) + local map = { + [namespaces.messages] = "messages"; + [namespaces.participants] = "participants"; + [namespaces.info] = "info"; + [namespaces.allowed] = "allowed"; + [namespaces.banned] = "banned"; + }; + + return "'"..map[node].."'"; +end + +local function mix_admin_value_to_node(value) + local map = { + ["'messages'"] = namespaces.messages; + ["'participants'"] = namespaces.participants; + ["'info'"] = namespaces.info; + ["'allowed'"] = namespaces.allowed; + ["'banned'"] = namespaces.banned; + }; + + return map[value] +end module:depends("disco"); -module:add_identity("conference", "mix", module:get_option("name", "Prosody MIX service")); +module:add_identity("conference", "mix", service_name); module:add_feature("http://jabber.org/protocol/disco#info"); -module:add_feature(mix_core_xmlns); +module:add_feature(namespaces.mix_core); local channels = {}; @@ -108,27 +132,10 @@ local function save_channels() end function Channel:save_state() - -- Saving the entire state everything one channel seems stupid, - -- so we just save the changed channel - module:log("debug", "Saving state of channel %s...", self.jid); + -- Store the channel in the persistent channel store + module:log("debug", "Saving channel %s...", self.jid); persistent_channel_data:set(self.jid, self); - module:log("debug", "Saving state done.", self.jid); -end - -local function publish_participant(service, channel, spid, participant) - -- Publish a new participant on the service - -- NOTE: This function has be to called *after* the new participant - -- has been added to the channel.participants attay - service:set_node_config(mix_node_participants, - true, - { ["max_items"] = #channel.participants }); - service:publish(mix_node_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"])); + module:log("debug", "Saving done.", self.jid); end function module.load() @@ -190,7 +197,7 @@ module:hook("iq/bare/http://jabber.org/protocol/disco#items:query", function(eve end local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#items", node = "mix" }); - for _, node in pairs({mix_node_messages, mix_node_participants, mix_node_info}) do + for _, node in pairs(channel.nodes) do reply:tag("item", { jid = channel.jid, node = node }):up(); end @@ -233,14 +240,13 @@ module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function( local origin, stanza = event.origin, event.stanza; local reply = st.reply(stanza) :tag("query", { xmlns = "http://jabber.org/protocol/disco#info" }) - -- TODO: Name - :tag("identity", { category = "conference", type = "mix", name = "" }):up() + :tag("identity", { category = "conference", type = "mix", name = service_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(); + :tag("feature", { var = namespaces.mix_core }):up(); if can_create_channels(stanza.attr.from) then - reply:tag("feature", { var = mix_core_xmlns.."#create-channel" }):up(); + reply:tag("feature", { var = namespaces.mix_core.."#create-channel" }):up(); end origin.send(reply); return true; @@ -259,14 +265,14 @@ module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function( reply:tag("feature", { var = "http://jabber.org/protocol/disco#info" }):up(); reply:tag("identity", { category = "conference", name = channel.name, type = "mix" }):up(); - reply:tag("feature", { var = mix_core_xmlns }):up(); + reply:tag("feature", { var = namespaces.mix_core }):up(); reply:tag("feature", { var = "urn:xmpp:mam:2" }):up(); origin.send(reply); return true; end); -module:hook("iq-set/bare/"..mam_xmlns..":query", function(event) +module:hook("iq-set/bare/"..namespaces.mam..":query", function(event) local stanza, origin = event.stanza, event.origin; local channel_jid = stanza.attr.to; local j, channel = get_channel(channel_jid); @@ -277,12 +283,12 @@ module:hook("iq-set/bare/"..mam_xmlns..":query", function(event) end -- Check if the user is subscribed to the messages node - if not channel:is_subscribed(stanza.attr.from, mix_node_messages) then + if not channel:is_subscribed(stanza.attr.from, namespaces.messages) then origin.send(st.error_reply(stanza, "cancel", "forbidden")); return true; end - local query = stanza:get_child("query", mam_xmlns); + local query = stanza:get_child("query", namespaces.mam); local filter = {}; local query_id = query.attr.queryid; local x = query:get_child("x", "jabber:x:data"); @@ -314,7 +320,7 @@ module:hook("iq-set/bare/"..mam_xmlns..":query", function(event) end for message_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 = message_id }) + :tag("result", { xmlns = namespaces.mam, queryid = query_id, id = message_id }) :tag("forwarded", { xmlns = "urn:xmpp:forward:0" }) :tag("delay", { xmlns = "urn:xmpp:delay", stamp = datetime.datetime(when) }):up(); msg:add_child(item); @@ -323,16 +329,17 @@ module:hook("iq-set/bare/"..mam_xmlns..":query", function(event) return true; end); -module:hook("iq-get/bare/"..mam_xmlns..":query", function(event) +module:hook("iq-get/bare/"..namespaces.mam..":query", function(event) if event.stanza.attr.id ~= "form1" then return; end module:log("debug", "Got a MAM query for supported fields"); + -- TODO: Use dataforms:... local ret = st.reply(event.stanza) - :tag("query", { xmlns = mam_xmlns }) + :tag("query", { xmlns = namespaces.mam }) :tag("x", { xmlns = "jabber:x:data", type = "form"}) :tag("field", { type = "hidden", var = "FORM_TYPE" }) - :tag("value"):text(mam_xmlns):up():up() + :tag("value"):text(namespaces.mam):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" }); @@ -340,7 +347,7 @@ module:hook("iq-get/bare/"..mam_xmlns..":query", function(event) return true; end); -module:hook("iq-set/bare/"..mix_core_xmlns..":leave", function(event) +module:hook("iq-set/bare/"..namespaces.mix_core..":leave", function(event) module:log("debug", "MIX leave received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); @@ -361,38 +368,22 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":leave", function(event) return true; end - -- Remove the user as a participant by... - -- Unsubscribing - local srv = pep.get_pep_service(jid.node(channel.jid)); - for _, node in pairs(channel.subscriptions[from]) do - srv:set_affiliation(node, true, from, "outcast"); - srv:remove_subscription(node, true, from); - module:log("debug", "Unsubscribed %s from %s on %s", from, node, channel.jid); - end - channel.subscriptions[from] = nil; - -- Retracting the participation - local spid = channel:get_spid(from); - local notifier = st.stanza("retract", { id = spid }); - -- TODO: Maybe error handling - srv:retract(mix_node_participants, true, spid, notifier); - -- Removing the user - table.remove(channel.participants, j); - channel:save_state(); + channel:remove_participant(from); module:fire_event("mix-channel-leave", { channel = channel, participant = participant }); - origin.send(st.reply(stanza):tag("leave", { xmlns = mix_core_xmlns })); + origin.send(st.reply(stanza):tag("leave", { xmlns = namespaces.mix_core })); return true; end); -module:hook("iq-set/bare/"..mix_core_xmlns..":join", function(event) +module:hook("iq-set/bare/"..namespaces.mix_core..":join", function(event) module:log("debug", "MIX join received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); local _, channel = get_channel(stanza.attr.to); if not channel then - origin.send(channel_not_found(stanza)); + origin:send(channel_not_found(stanza)); return true; end @@ -403,12 +394,24 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":join", function(event) return true; end + -- Is the user allowed to join? + if not channel:may(from, "join", nil) then + origin:send(st.error_reply(stanza, "cancel", "forbidden", "User or host is banned")); + return true; + end + local spid = channel:get_spid(from) or uuid.generate(); -- Stable Participant ID local reply = st.reply(stanza) - :tag("join", { xmlns = mix_core_xmlns, id = spid }); - local srv = pep.get_pep_service(jid.node(stanza.attr.to)); - local join = stanza:get_child("join", mix_core_xmlns); + :tag("join", { xmlns = namespaces.mix_core, id = spid }); + local join = stanza:get_child("join", namespaces.mix_core); local nick_tag = join:get_child("nick"); + + -- Check if the channel has mandatory nicks + if channel.config["Mandatory Nicks"] and not nick_tag then + origin:send(st.error_reply(stanza, "modify", "forbidden", "Nicks are mandatory")); + return true; + end + local nick; if not nick_tag then nick = jid.node(from); @@ -417,86 +420,93 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":join", function(event) end module:log("debug", "User joining as nick %s", nick); + local srv = pep.get_pep_service(jid.node(channel.jid)); local nodes = {}; local has_subscribed_once = false; local first_error = nil; + local owner_or_admin = channel:is_admin(from) or channel:is_owner(from); for subscribe in join:childtags("subscribe") do + -- May the user subscribe to the node? module:log("debug", "Subscribing user to node %s", subscribe.attr.node); - -- TODO: Once MIX-ADMIN is implemented, we should check here what - -- affiliation we set, e.g. if the JID is the owner, then set owner. - srv:set_affiliation(subscribe.attr.node, true, from, "member"); - local ok, err = srv:add_subscription(subscribe.attr.node, true, from); - if not ok then - module:log("debug", "Error during subscription: %s", err); + if channel:may_subscribe(from, subscribe.attr.node, true) then + -- Set the correct affiliation + local affiliation = ""; + if owner_or_admin then + affiliation = "publisher"; + else + affiliation = "member"; + end + srv:set_affiliation(subscribe.attr.node, true, from, affiliation); - -- MIX-CORE says that the first error should be returned when - -- no of the requested nodes could be subscribed to - if first_error ~= nil then - first_error = err; + local ok, err = srv:add_subscription(subscribe.attr.node, true, from); + if not ok then + module:log("debug", "Error during subscription: %s", err); + + -- MIX-CORE says that the first error should be returned when + -- no of the requested nodes could be subscribed to + if first_error ~= nil then + first_error = err; + end + + -- Reset affiliation + srv:set_affiliation(subscribe.attr.node, true, from, "outcast"); + + else + table.insert(nodes, subscribe.attr.node); + reply:tag("subscribe", { node = subscribe.attr.node }):up(); + has_subscribed_once = true; end else - table.insert(nodes, subscribe.attr.node); - reply:tag("subscribe", { node = subscribe.attr.node }):up(); - has_subscribed_once = true; + module:log("debug", "Error during subscription: may_subscribe returned false"); + if first_error ~= nil then + first_error = "Channel does not allow subscribing"; + end end end if not has_subscribed_once then - origin.send(st.error_reply(stanza, "cancel", first_error)); + -- TODO: This does not work + origin:send(st.error_reply(stanza, "cancel", first_error)); return true; end -- TODO: Make the default configurable - local jid_visibility = "never"; -- default - local allow_pms = "allow"; - local allow_vcards = "block"; - local share_presence = "share"; - local x = join:get_child("x", "jabber:x:data"); + local config = mixlib.default_participant_configuration; + local x = join:get_child("x"); if x ~= nil then - -- TODO: Rethink naming -- TODO: Error handling? - local form, err = mix_anon_form:data(x); + local form, err = anon.form:data(x); if form["JID Visibility"] then - jid_visibility = form["JID Visibility"]; + config["JID Visibility"] = form["JID Visibility"]; end if form["Private Messages"] then - allow_pms = form["Private Messages"]; + config["Private Messages"] = form["Private Messages"]; end if form["Presence"] then - share_presence = form["Presence"]; + config["Presence"] = form["Presence"]; end if form["vCard"] then - allow_vcards = form["vCard"]; + config["vCard"] = form["vCard"]; end end - local participant = Participant:new(jid.bare(from), nick, jid_visibility, allow_pms, share_presence, allow_vcards); + local participant = Participant:new(jid.bare(from), nick, config); channel.subscriptions[from] = nodes; table.insert(channel.participants, participant) channel:set_spid(jid.bare(stanza.attr.from), spid); - publish_participant(srv, channel, spid, participant); + channel:publish_participant(spid, participant); channel:save_state(); module:fire_event("mix-channel-join", { channel = channel, participant = participant }); - reply:add_child(nick_tag); - reply:tag("x", { xmlns = "jabber:x:data", type = "result" }) - :tag("field", { var = "FORM_TYPE", type = "hidden" }) - :tag("value"):text(mix_anon_xmlns):up():up() - :tag("field", { var = "JID Visibility"}) - :tag("value"):text(jid_visibility):up():up() - :tag("field", { var = "Private Messages"}) - :tag("value"):text(allow_pms):up():up() - :tag("field", { var = "Presence"}) - :tag("value"):text(share_presence):up():up() - :tag("field", { var = "vCard"}) - :tag("value"):text(allow_vcards):up():up(); - + -- We do not reuse nick_tag as it might be nil + reply:tag("nick"):text(nick):up(); + reply:add_child(anon.form:form(config, "result")); origin.send(reply); return true end); -module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event) +module:hook("iq-set/bare/"..namespaces.mix_core..":setnick", function(event) module:log("debug", "MIX setnick received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); @@ -514,7 +524,7 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event) end -- NOTE: Prosody should guarantee us that the setnick stanza exists - local setnick = stanza:get_child("setnick", mix_core_xmlns); + local setnick = stanza:get_child("setnick", namespaces.mix_core); local nick = setnick:get_child("nick"); if nick == nil then origin.send(st.error_reply(stanza, "cancel", "bad-request", "Missing ")); @@ -524,14 +534,12 @@ module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event) -- Change the nick channel.participants[j].nick = nick:get_text(); -- Inform all other members - local srv = pep.get_pep_service(jid.node(channel.jid)); - --local participant = channel.participants[participant_index]; - publish_participant(srv, channel, channel:get_spid(participant.jid), participant); + channel:publish_participant(channel:get_spid(participant.jid), participant); module:fire_event("mix-change-nick", { channel = channel, participant = participant }); origin.send(st.reply(stanza) - :tag("setnick", { xmlns = mix_core_xmlns }) + :tag("setnick", { xmlns = namespaces.mix_core }) :tag("nick"):text(nick:get_text())); channel:save_state(); return true; @@ -539,32 +547,38 @@ end); function Channel:publish_info(srv) local timestamp = datetime.datetime(time.now()); + -- TODO: Check if this is correct 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" }); - for _, contact in pairs(self.contacts) do - info:add_child(st.stanza("value"):text(contact)); - end - srv:publish(mix_node_info, true, timestamp, info); + :add_child(mix_info_form:form({ + ["FORM_TYPE"] = "hidden", + ["Name"] = self.name, + ["Description"] = self.description, + ["Contacts"] = self.contacts + }, "result")); + srv:publish(namespaces.info, true, timestamp, info); end local function create_channel(node, creator, adhoc) - local channel = Channel:new(string.format("%s@%s", node, host), + -- TODO: Now all properties from the admin dataform are covered + local channel = Channel:new(string.format("%s@%s", node, host), -- Channel JID default_channel_name, default_channel_description, - {}, - {}, - {}, - { creator }, - adhoc); + {}, -- Participants + {}, -- Administrators + { creator }, -- Owners + {}, -- Subscriptions + {}, -- SPID mapping + { creator }, -- Contacts + adhoc, -- Is channel an AdHoc channel + {}, -- Allowed + {}, -- Banned + mixlib.default_channel_configuration, -- Channel config + {}); -- Present nodes + -- Create the PEP nodes local srv = pep.get_pep_service(node); -- MIX-CORE - for _, psnode in pairs({ mix_node_info, mix_node_participants, mix_node_messages }) do + for _, psnode in ipairs(default_mix_nodes) do srv:create(psnode, true, { ["access_model"] = "whitelist", ["persist_items"] = true, @@ -572,28 +586,44 @@ local function create_channel(node, creator, adhoc) end channel:publish_info(srv); - --[[ -- MIX-ADMIN - local admin_nodes = { mix_node_banned, mix_node_config }; + local admin_nodes = array { namespaces.banned }; if adhoc then - table.insert(admin_nodes, mix_node_allowed); + admin_nodes:push(namespaces.allowed); end for _, psnode in pairs(admin_nodes) do - srv:create(mix_node_allowed, true, { ["access_model"] = "whitelist" }); - srv:set_affiliation(mix_node_allowed, true, creator, "owner"); + srv:create(psnode, true, { + ["access_model"] = "whitelist", + ["persist_items"] = true, + }); + srv:set_affiliation(namespaces.allowed, true, creator, "publish"); end if adhoc then -- Allow the creator to join - srv:publish(mix_node_allowed, - true, - nil, - st.stanza("item", { id = creator })); + channel:allow_jid(creator); end - ]]-- + + -- Some bookkeeping + local nodes = default_mix_nodes + admin_nodes; + local node_names = array(nodes):filter(function (element) + return element ~= namespaces.config; + end):map(mix_admin_node_to_value):push("'avatar'"); + -- NOTE: This is to prevent cycles during serialization (apparently) + channel.nodes = {table.unpack(nodes)}; + + local config = mixlib.default_channel_configuration; + config["Owner"] = { creator }; + config["Nodes Present"] = node_names; + channel.config = config; + srv:publish(namespaces.config, + true, + "", + st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub" }) + :add_child(mix_config_form:form(config, "result"))); table.insert(channels, channel); end -module:hook("iq-set/host/"..mix_core_xmlns..":create", function(event) +module:hook("iq-set/host/"..namespaces.mix_core..":create", function(event) module:log("debug", "MIX create received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); @@ -604,7 +634,7 @@ module:hook("iq-set/host/"..mix_core_xmlns..":create", function(event) return true; end - local create = stanza:get_child("create", mix_core_xmlns); + local create = stanza:get_child("create", namespaces.mix_core); local node; if create.attr.channel ~= nil then -- Create non-adhoc channel @@ -637,17 +667,17 @@ module:hook("iq-set/host/"..mix_core_xmlns..":create", function(event) -- TODO: Add an event origin.send(st.reply(stanza) - :tag("create", { xmlns = mix_core_xmlns, channel = node })); + :tag("create", { xmlns = namespaces.mix_core, channel = node })); save_channels(); return true; end); -module:hook("iq-set/host/"..mix_core_xmlns..":destroy", function(event) +module:hook("iq-set/host/"..namespaces.mix_core..":destroy", function(event) module:log("debug", "MIX destroy received"); local origin, stanza = event.origin, event.stanza; local from = jid.bare(stanza.attr.from); - local destroy = stanza:get_child("destroy", mix_core_xmlns); + local destroy = stanza:get_child("destroy", namespaces.mix_core); local node = destroy.attr.channel; local node_jid = string.format("%s@%s", node, host); local i, channel = get_channel(node_jid); @@ -656,15 +686,14 @@ module:hook("iq-set/host/"..mix_core_xmlns..":destroy", function(event) return true; end - -- TODO: Permission checks - -- TODO: Maybe make this configurable - if helpers.find_str(channel.contacts, from) == -1 then + if not channel:is_owner(from) 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({ mix_node_participants, mix_node_info, mix_node_messages }) do + for _, pep_node in pairs(channel.nodes) do srv:delete(pep_node, true); end table.remove(channels, i); @@ -698,31 +727,280 @@ module:hook("message/bare", function(event) return true; end - 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()); + -- Handles sending the message accordingly, firing an event and + -- even doing nothing if an event handler for "mix-broadcast-message" + -- returns true. + channel:broadcast_message(stanza, participant, message_archive); + return true; +end); - -- 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()); - msg.attr.from = channel.jid.."/"..channel:get_spid(from); +local function handle_new_node(srv, channel, node) + if node == namespaces.allowed then + -- If the allowed node is created, make sure to add the owners + -- as allowed - if module:fire_event("mix-broadcast-message", { message = msg, channel = channel }) then + channel.allowed = {}; + for _, owner in pairs(channel.owners) do + array.push(channel.allowed, owner) + srv:publish(namespaces.allowed, true, owner, nil); + end + + -- Remove all non-allowed participants + for _, participant in channel.participants do + if not channel:is_allowed(participant.jid) then + channel:remove_participant(participant.jid); + end + end + end +end + +-- MIX Admin events +local function handle_pubsub_publish(event, publish, channel) + local node = publish.attr.node; + local from = jid.bare(event.stanza.attr.from); + local item = publish:get_child("item"); + + -- TODO: Deal with avatars + -- TODO: Should we trust the clients to behave? + -- local srv = pep.get_pep_service(jid.node(channel.jid)); + if node == namespaces.config then + if not channel:may(from, "update", namespaces.config) then + -- TODO: This does not work + event.origin:send(st.error_reply(publish, "auth", "forbidden")); + return true; + end + + local config, err = mix_config_form:data(item); + -- TODO: Error handling? + + local srv = pep.get_pep_service(jid.node(channel.jid)); + -- TODO: Check this over + local removed_admins = set.new(channel.administrators):difference(set.new(config["Administrator"])); + local removed_owners = set.new(channel.owners):difference(set.new(config["Owner"])); + local new_node_names = set.new(config["Nodes Present"]):difference(set.new(channel.nodes)); + local removed_node_names = set.new(channel.nodes):difference(set.new(config["Nodes Present"])); + local new_nodes = array.map(new_node_names, mix_admin_value_to_node); -- TODO + local removed_nodes = array.map(removed_node_names, mix_admin_value_to_node); -- TODO + + -- TODO: Verify that this is actually safe + for _, jid_ in pairs(array.append(removed_admins, removed_owners)) do + for _, psnode in pairs(channel.nodes) do + srv:set_affiliation(psnode, true, jid_, "member"); + end + end + + -- TODO: Apply this to the PubSub publish + config["Last Change Made By"] = jid.bare(event.stanza.attr.from); + channel.owners = config["Owner"]; + channel.administrators = config["Administrator"]; + channel.config = config; + channel.nodes = config["Nodes Present"] + + -- Remove all nodes that are not existant anymore... + for _, removed_node in pairs(removed_nodes) do + for participant, subscriptions in channel.participants do + local j, _ = helpers.find(subscriptions, removed_node); + if j ~= -1 then + srv:remove_subscription(removed_node, true, from); + table.remove(participant.subscriptions, j); + end + end + + if removed_node == namespaces.allowed then + channel.allowed = nil; + elseif removed_node == namespaces.banned then + channel.banned = nil; + end + end + -- ... and create all new ones. + for _, new_node in pairs(new_nodes) do + srv:create(new_node, true, { + ["access_model"] = "whitelist", + ["persist_items"] = true, + }); + for participant, _ in channel.participants do + local affiliation = ""; + if channel:is_admin(participant.jid) or channel:is_owner(participant.jid) then + affiliation = "publisher"; + else + affiliation = "member"; + end + + srv:set_affiliation(new_node, true, affiliation); + end + + handle_new_node(srv, channel, new_node); + end + + for _, jid_ in pairs(array.append(channel.owners, channel.administrators)) do + for _, psnode in pairs(channel.nodes) do + srv:set_affiliation(psnode, true, jid_, "publisher"); + end + end + elseif node == namespaces.info then + if not channel:may(from, "update", namespaces.info) then + -- TODO: This does not work + event.origin:send(st.error_reply(publish, "auth", "forbidden")); + return true; + end + + local info, err = mix_info_form:data(item); + -- TODO: Error handling + if info["Name"] then + channel.name = info["Name"]; + end + if info["Description"] then + channel.description = info["Description"]; + end + if info["Contact"] then + channel.contacts = info["Contact"]; + end + + module:log("debug", "Channel info updated"); + elseif node == namespaces.banned then + channel:ban_jid(publish:get_child("item").attr.id); + elseif node == namespaces.allowed then + channel:allow_jid(publish:get_child("item").attr.id); + elseif node == namespaces.participants or node == namespaces.messages then + -- We don't want admins or owners to publish to these nodes as they + -- are managed by the module + local reply = st.error_reply(event.stanza, "cancel", "feature-not-implemented"); + reply:tag("unsupported", { + xmlns = "http://jabber.org/protocol/pubsub#errors", + feature = "publish", + }); + event.origin:send(reply); return true; end - for _, p in pairs(channel.participants) do - -- 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); + -- Let the actual PubSub implementation handle the rest + channel:save_state(); +end + +local function handle_pubsub_retract(event, retract, channel) + local node = retract.attr.node; + local item = retract:get_child("item"); + if node == namespaces.banned then + channel.banned = array.filter(channel.banned, function (element) + return element ~= item.attr.id; + end); + elseif node == namespaces.allowed then + channel.allowed = array.filter(channel.allowed, function (element) + return element ~= item.attr.id; + end); + elseif node == namespaces.participants then + local reply = st.error_reply(event.stanza, "cancel", "feature-not-implemented"); + reply:tag("unsupported", { + xmlns = "http://jabber.org/protocol/pubsub#errors", + feature = "delete-items" + }); + event.origin:send(reply); + return true; + end + + channel:save_state(); +end + +local function handle_pubsub_subscribe(event, subscribe, channel) + local from = jid.bare(event.stanza.attr.from); + local origin = event.origin; + local node = subscribe.attr.node; + + -- Check for node existance + local i, _ = helpers.find(channel.nodes, node); + if i == -1 then + -- Let the actual PubSub implementation handle it + return; + end + + if not channel:may(from, "subscribe", node) then + origin:send(st.error_reply(event.stanza, "auth", "forbidden")); + return true; + end + + table.insert(channel.subscriptions[from], node); + channel:save_state(); + -- The rest is handled by the actual PubSub implementation +end + +local function handle_pubsub_unsubscribe(event, subscribe, channel) + local from = jid.bare(event.stanza.attr.from); + local origin = event.origin; + local node = subscribe.attr.node; + + -- Check for node existance + local i, _ = helpers.find(channel.nodes, node); + if i == -1 then + -- Let the actual PubSub implementation handle it + return; + end + + if not channel:may(from, "subscribe", node) then + origin:send(st.error_reply(event.stanza, "auth", "forbidden")); + return true; + end + + local j, _ = helpers.find(channel.subscriptions[from], node); + if j == -1 then + local errstanza = st.error_reply(event.stanza, "cancel", "unexpected-request"); + errstanza:tag("not-subscribed", { xmlns = "http://jabber.org/protocol/pubsub#errors" }); + origin:send(errstanza); + return true; + end + table.remove(channel.subscriptions[from], j); + channel:save_state(); + -- The rest is handled by the actual PubSub implementation +end + +local function handle_pubsub_items(event, items, channel) + local node = items.attr.node; + local from = jid.bare(event.stanza.attr.from); + if node == namespaces.config then + if not channel:may(from, "access", node) then + return true; end end - return true; -end); +end + +module:hook("iq-get/bare/http://jabber.org/protocol/pubsub:pubsub", function(event) + local stanza = event.stanza; + local _, channel = get_channel(stanza.attr.to); + if not channel then + return; + end + + local pubsub = stanza:get_child("pubsub", "http://jabber.org/protocol/pubsub"); + local items = pubsub:get_child("items"); + if items then + return handle_pubsub_items(event, items, channel); + end +end, 1000); + +module:hook("iq-set/bare/http://jabber.org/protocol/pubsub:pubsub", function(event) + local stanza = event.stanza; + local _, channel = get_channel(stanza.attr.to); + if not channel then + return; + end + + local pubsub = stanza:get_child("pubsub", "http://jabber.org/protocol/pubsub"); + local publish = pubsub:get_child("publish"); + if publish then + return handle_pubsub_publish(event, publish, channel); + end + + local subscribe = pubsub:get_child("subscribe"); + if subscribe then + return handle_pubsub_subscribe(event, subscribe, channel); + end + + local unsubscribe = pubsub:get_child("unsubscribe"); + if unsubscribe then + return handle_pubsub_unsubscribe(event, subscribe, channel); + end + + local retract = pubsub:get_child("retract"); + if retract then + return handle_pubsub_retract(event, retract, channel); + end +end, 1000); diff --git a/mod_mix/namespaces.lib.lua b/mod_mix/namespaces.lib.lua new file mode 100644 index 0000000..cc64f87 --- /dev/null +++ b/mod_mix/namespaces.lib.lua @@ -0,0 +1,19 @@ +return { + -- XMLNS + -- MIX + mix_core = "urn:xmpp:mix:core:1"; + mix_anon = "urn:xmpp:mix:anon:0"; + mix_admin = "urn:xmpp:mix:admin:0"; + -- MAM + mam = "urn:xmpp:mam:2"; + -- User Avatar + avatar = "urn:xmpp:avatar:data"; + avatar_metadata = "urn:xmpp:avatar:metadata"; + -- MIX PubSub nodes + messages = "urn:xmpp:mix:nodes:messages"; + participants = "urn:xmpp:mix:nodes:participants"; + info = "urn:xmpp:mix:nodes:info"; + allowed = "urn:xmpp:mix:nodes:allowed"; + banned = "urn:xmpp:mix:nodes:banned"; + config = "urn:xmpp:mix:nodes:config"; +};