prosody-modules/mod_mix/mod_mix.lua

610 lines
23 KiB
Lua
Raw Normal View History

2020-10-27 17:05:41 +00:00
local host = module:get_host();
if module:get_host_type() ~= "component" then
error("MIX should be loaded as a component", 0);
2020-10-27 17:05:41 +00:00
end
local st = require("util.stanza");
local jid = require("util.jid");
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");
2020-10-27 17:05:41 +00:00
local pep = module:depends("pep");
local helpers = module:require("mix/helpers");
local mixlib = module:require("mix/mix");
Channel = mixlib.Channel;
Participant = mixlib.Participant;
2020-10-27 17:05:41 +00:00
-- 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
2020-10-27 17:05:41 +00:00
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");
2020-10-27 17:05:41 +00:00
-- Configuration
2020-10-27 17:05:41 +00:00
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");
2020-10-27 17:05:41 +00:00
-- 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" },
});
2020-10-27 17:05:41 +00:00
module:depends("disco");
-- module:depends("mam"); TODO: Once message sending works
2020-10-30 16:45:22 +00:00
module:add_identity("conference", "mix", module:get_option("name", "Prosody MIX service"));
2020-10-27 17:05:41 +00:00
module:add_feature("http://jabber.org/protocol/disco#info");
module:add_feature(mix_core_xmlns);
2020-10-27 17:05:41 +00:00
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
return helpers.find(channels, function(c) return c.jid == jid; end);
2020-10-27 17:05:41 +00:00
end
function save_channels()
module:log("debug", "Saving channel list...");
local channel_list = {};
for _, channel in pairs(channels) do
table.insert(channel_list, channel.jid);
persistent_channel_data:set(channel.jid, channel);
end
persistent_channels:set("channels", channel_list);
module:log("debug", "Saving channel list done.");
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);
persistent_channel_data:set(self.jid, self);
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
2020-10-27 17:05:41 +00:00
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
local channel = Channel:from(persistent_channel_data:get(channel));
table.insert(channels, channel);
module:log("debug", "MIX channel %s loaded", channel.jid);
2020-10-27 17:05:41 +00:00
end
else
module:log("debug", "No MIX channels found.");
end
module:log("info", "Loading MIX channels done.");
2020-10-27 17:05:41 +00:00
end
2020-10-29 16:08:04 +00:00
function channel_not_found(stanza)
-- Wrapper for returning a "Channel-not-found" error stanza
return st.error_reply(stanza,
"cancel",
"item-not-found",
"The MIX channel was not found");
end
2020-10-27 17:05:41 +00:00
module:hook("host-disco-items", function(event)
module:log("debug", "host-disco-items called");
local reply = event.reply;
for _, channel in pairs(channels) do
-- Adhoc channels are supposed to be invisible
if not channel.adhoc then
reply:tag("item", { jid = channel.jid }):up();
end
end
end);
module:hook("iq/bare/http://jabber.org/protocol/disco#items:query", function(event)
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.
2020-10-30 16:45:22 +00:00
local _, channel = get_channel(stanza.attr.to);
2020-10-27 17:05:41 +00:00
if not channel then
2020-10-29 16:08:04 +00:00
origin.send(channel_not_found(stanza));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
if not channel:is_participant(jid.bare(stanza.attr.from)) then
origin.send(st.error_reply(stanza, "cancel", "forbidden"));
return true;
end
2020-10-29 16:08:04 +00:00
2020-10-27 17:05:41 +00:00
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
2020-10-27 17:05:41 +00:00
reply:tag("item", { jid = channel.jid, node = node }):up();
end
origin.send(reply);
return true;
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
module:log("debug", "Comparing %s (Sender) to %s (Host)", jid.host(user), host_suffix);
if jid.host(user) == host_suffix then
return true;
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)
return true;
end
module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function(event)
module:log("debug", "IQ-GET host disco#info");
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("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)
2020-10-27 17:05:41 +00:00
module:log("debug", "IQ-GET disco#info");
local origin, stanza = event.origin, event.stanza;
local _, channel = get_channel(stanza.attr.to);
if not channel then
2020-10-29 16:08:04 +00:00
origin.send(channel_not_found(stanza));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#info" });
2020-10-29 16:08:04 +00:00
reply:tag("feature", { var = "http://jabber.org/protocol/disco#info" }):up();
2020-10-27 17:05:41 +00:00
reply:tag("identity", { category = "conference", name = channel.name, type = "mix" }):up();
2020-10-29 16:08:04 +00:00
reply:tag("feature", { var = mix_core_xmlns }):up();
reply:tag("feature", { var = "urn:xmpp:mam:2" }):up();
2020-10-27 17:05:41 +00:00
origin.send(reply);
return true;
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
-- 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;
2020-10-27 17:05:41 +00:00
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"])
};
2020-10-27 17:05:41 +00:00
end
local data, err = message_archive:find(channel_jid, filter);
if not data then
module:log("debug", "MAM error: %s", err);
2020-10-27 17:05:41 +00:00
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);
2020-10-27 17:05:41 +00:00
module:hook("iq-set/bare/"..mix_core_xmlns..":leave", function(event)
2020-10-27 17:05:41 +00:00
module:log("debug", "MIX leave received");
local origin, stanza = event.origin, event.stanza;
local from = jid.bare(stanza.attr.from);
local i, channel = get_channel(stanza.attr.to);
if not channel then
2020-10-29 16:08:04 +00:00
origin.send(channel_not_found(stanza));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
local j, _ = channel:find_participant(from);
if j == -1 then
2020-10-29 16:08:04 +00:00
origin.send(st.error_reply(stanza,
"cancel",
"forbidden",
"Not a participant"));
2020-10-27 17:05:41 +00:00
channel:debug_print();
module:log("debug", "%s is not a participant in %s", from, channel.jid);
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
-- Remove the user as a participant by...
-- Unsubscribing
local srv = pep.get_pep_service(channel.jid);
for _, node in pairs(channel.subscriptions[from]) do
srv:remove_subscription(node, true, from);
module:log("debug", "Unsubscribed %s from %s on %s", from, node, channel.jid);
end
channels[i].subscriptions[from] = nil;
-- Retracting the participation
srv:retract("urn:xmpp:mix:nodes:participants", true, channel:get_spid(from), true);
2020-10-27 17:05:41 +00:00
-- Removing the user
table.remove(channels[i].participants, j);
2020-10-27 17:05:41 +00:00
channel:save_state();
origin.send(st.reply(stanza):tag("leave", { xmlns = mix_core_xmlns }));
2020-10-27 17:05:41 +00:00
return true;
end);
module:hook("iq-set/bare/"..mix_core_xmlns..":join", function(event)
2020-10-27 17:05:41 +00:00
module:log("debug", "MIX join received");
local origin, stanza = event.origin, event.stanza;
local from = jid.bare(stanza.attr.from);
2020-10-27 17:05:41 +00:00
local i, channel = get_channel(stanza.attr.to);
if not channel then
2020-10-29 16:08:04 +00:00
origin.send(channel_not_found(stanza));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
-- Prevent the user from joining multiple times
local j, _ = channel:find_participant(from);
if j ~= -1 then
2020-10-30 16:45:22 +00:00
module:send(st.error_reply(stanza, "cancel", "bad-request", "User already joined"));
return true;
end
local spid = channel:get_spid(from) or uuid.generate(); -- Stable Participant ID
2020-10-27 17:05:41 +00:00
local reply = st.reply(stanza)
:tag("join", { xmlns = mix_core_xmlns, id = spid });
2020-10-27 17:05:41 +00:00
local srv = pep.get_pep_service(jid.node(stanza.attr.to));
local join = stanza:get_child("join", mix_core_xmlns);
2020-10-27 17:05:41 +00:00
local nick = join:get_child("nick");
module:log("debug", "User joining as nick %s", nick:get_text());
local nodes = {};
local has_subscribed_once = false;
local first_error = nil;
2020-10-27 17:05:41 +00:00
for subscribe in join:childtags("subscribe") do
module:log("debug", "Subscribing user to node %s", subscribe.attr.node);
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
2020-10-27 17:05:41 +00:00
else
table.insert(nodes, subscribe.attr.node);
reply:tag("subscribe", { node = subscribe.attr.node }):up();
has_subscribed_once = true;
2020-10-27 17:05:41 +00:00
end
end
if not has_subscribed_once then
origin.send(st.error_reply(stanza, "cancel", first_error));
2020-10-30 16:45:22 +00:00
return true;
end
2020-10-27 17:05:41 +00:00
local participant = Participant:new(jid.bare(from), nick:get_text())
channels[i].subscriptions[from] = nodes;
2020-10-27 17:05:41 +00:00
table.insert(channels[i].participants, participant)
2020-10-30 16:45:22 +00:00
channels[i]:set_spid(jid.bare(stanza.attr.from), spid);
2020-10-27 17:05:41 +00:00
publish_participant(srv, spid, participant);
channels[i]:save_state();
reply:add_child(nick);
origin.send(reply);
return true
end);
module:hook("iq-set/bare/"..mix_core_xmlns..":setnick", function(event)
2020-10-27 17:05:41 +00:00
module:log("debug", "MIX setnick received");
local origin, stanza = event.origin, event.stanza;
local from = jid.bare(stanza.attr.from);
local i, channel = get_channel(stanza.attr.to);
if not channel then
2020-10-29 16:08:04 +00:00
origin.send(channel_not_found(stanza));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
local j, participant = channel:find_participant(from);
if j == -1 then
2020-10-27 17:05:41 +00:00
channel:debug_print();
module:log("debug", "%s is not a participant in %s", from, channel.jid);
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
-- NOTE: Prosody should guarantee us that the setnick stanza exists
local setnick = stanza:get_child("setnick", mix_core_xmlns);
local nick = setnick:get_child("nick");
if nick_stanza == nil then
origin.send(st.error_reply(stanza, "cancel", "bad-request", "Missing <nick>"));
2020-10-30 16:45:22 +00:00
return true;
end
2020-10-27 17:05:41 +00:00
-- Change the nick
channels[i].participants[j].nick = nick:get_text();
2020-10-27 17:05:41 +00:00
-- Inform all other members
local srv = pep.get_pep_service(channel.jid);
--local participant = channel.participants[participant_index];
2020-10-27 17:05:41 +00:00
publish_participant(srv, channel:get_spid(participant.jid), participant);
origin.send(st.reply(stanza)
:tag("setnick", { xmlns = mix_core_xmlns })
:tag("nick"):text(nick:get_text()));
2020-10-27 17:05:41 +00:00
channel:save_state();
return true;
end);
function Channel:publish_info(srv)
local timestamp = datetime.datetime(time.now());
local info = st.stanza("item", { id = timestamp, xmlns = "http://jabber.org/protocol/pubsub" })
2020-10-27 17:05:41 +00:00
:tag("x", { xmlns = "jabber:x:data", type = "result" })
:tag("field", { var = "FORM_TYPE", type = "hidden" })
:tag("value"):text(mix_core_xmlns):up():up()
2020-10-27 17:05:41 +00:00
: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);
2020-10-27 17:05:41 +00:00
end
function create_channel(node, creator, adhoc)
local channel = Channel:new(string.format("%s@%s", node, host),
default_channel_name,
default_channel_description,
{},
{},
{},
{ creator },
adhoc);
-- Create the PEP nodes
local srv = pep.get_pep_service(node);
local timestamp = datetime.datetime(time.now());
2020-10-30 16:45:22 +00:00
local access_model = adhoc and "whitelist" or "open";
for _, psnode in pairs({ mix_node_info, mix_node_participants, mix_node_messages }) do
2020-10-30 16:45:22 +00:00
srv:create(psnode, true, { ["access_model"] = access_model });
-- If the channel is an adhoc channel, then we need to make sure that
-- node affiliations are correct
if adhoc then
srv:set_affiliation(psnode, true, creator, "owner");
end
end
2020-10-27 17:05:41 +00:00
channel:publish_info(srv);
table.insert(channels, channel);
end
module:hook("iq-set/host/"..mix_core_xmlns..":create", function(event)
2020-10-27 17:05:41 +00:00
module:log("debug", "MIX create received");
local origin, stanza = event.origin, event.stanza;
local from = jid.bare(stanza.attr.from);
-- Check permissions
if not can_create_channels(from) then
origin.send(st.error_reply(stanza, "cancel", "forbidden", "Not authorized to create channels"));
2020-10-30 16:45:22 +00:00
return true;
end
local create = stanza:get_child("create", mix_core_xmlns);
2020-10-27 17:05:41 +00:00
local node;
if create.attr.channel ~= nil then
-- Create non-adhoc channel
module:log("debug", "Attempting to create channel %s", create.attr.channel);
2020-10-27 17:05:41 +00:00
node = create.attr.channel;
local i, _ = get_channel(create.attr.channel.."@"..stanza.attr.to);
module:log("debug", "Channel index %s", i);
if i ~= -1 then
2020-10-29 16:08:04 +00:00
origin.send(st.error_reply(stanza,
"cancel",
"conflict",
"Channel already exists"));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
create_channel(create.attr.channel, from, false);
else
-- Create adhoc channel
while (true) do
node = id.short();
local i, _ = get_channel(string.format("%s@%s", node, host));
if i == -1 then
break;
end
end
2020-10-27 17:05:41 +00:00
create_channel(node, from, true);
end
module:log("debug", "Channel %s created with %s as owner", node, from);
origin.send(st.reply(stanza)
:tag("create", { xmlns = mix_core_xmlns, channel = node }));
2020-10-27 17:05:41 +00:00
save_channels();
return true;
end);
module:hook("iq-set/host/"..mix_core_xmlns..":destroy", function(event)
2020-10-27 17:05:41 +00:00
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("create", mix_core_xmlns);
2020-10-27 17:05:41 +00:00
local node = destory.attr.channel;
local node_jid = string.format("%s@%s", node, host);
local i, channel = get_channel(node_jid);
if not channel then
2020-10-29 16:08:04 +00:00
origin.send(channel_not_found(stanza));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
-- 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
2020-10-27 17:05:41 +00:00
-- 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
2020-10-27 17:05:41 +00:00
srv:delete(pep_node, true);
end
table.remove(channels, i);
module:log("debug", "Channel %s destroyed", node);
origin.send(st.reply(stanza));
save_channels();
return true;
end);
module:hook("message/bare", function(event)
module:log("debug", "MIX message received");
2020-10-29 16:08:04 +00:00
local stanza, origin = event.stanza, event.origin;
2020-10-27 17:05:41 +00:00
if stanza.attr.type ~= "groupchat" then
origin.send(st.error_reply(stanza, "modify", "bad-request", "Non-groupchat message"));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
local from = jid.bare(stanza.attr.from);
local i, channel = get_channel(stanza.attr.to);
if not channel then
2020-10-29 16:08:04 +00:00
origin.send(channel_not_found(stanza));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
local j, participant = channel:find_participant(from);
if j == -1 then
2020-10-29 16:08:04 +00:00
origin.send(st.error_reply(stanza, "cancel", "forbidden", "Not a participant"));
2020-10-30 16:45:22 +00:00
return true;
2020-10-27 17:05:41 +00:00
end
local msg = st.clone(stanza);
msg:add_child(st.stanza("mix", { xmlns = mix_core_xmlns })
2020-10-27 17:05:41 +00:00
: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);
2020-10-27 17:05:41 +00:00
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);
end
2020-10-27 17:05:41 +00:00
end
return true;
end);