diff options
Diffstat (limited to 'plugins')
75 files changed, 7247 insertions, 2397 deletions
diff --git a/plugins/adhoc/adhoc.lib.lua b/plugins/adhoc/adhoc.lib.lua index 87415636..0b910299 100644 --- a/plugins/adhoc/adhoc.lib.lua +++ b/plugins/adhoc/adhoc.lib.lua @@ -36,30 +36,30 @@ function _M.handle_cmd(command, origin, stanza) local data, state = command:handler(dataIn, states[sessionid]); states[sessionid] = state; - local cmdtag; + local cmdreply; if data.status == "completed" then states[sessionid] = nil; - cmdtag = command:cmdtag("completed", sessionid); + cmdreply = command:cmdtag("completed", sessionid); elseif data.status == "canceled" then states[sessionid] = nil; - cmdtag = command:cmdtag("canceled", sessionid); + cmdreply = command:cmdtag("canceled", sessionid); elseif data.status == "error" then states[sessionid] = nil; local reply = st.error_reply(stanza, data.error.type, data.error.condition, data.error.message); origin.send(reply); return true; else - cmdtag = command:cmdtag("executing", sessionid); + cmdreply = command:cmdtag("executing", sessionid); data.actions = data.actions or { "complete" }; end for name, content in pairs(data) do if name == "info" then - cmdtag:tag("note", {type="info"}):text(content):up(); + cmdreply:tag("note", {type="info"}):text(content):up(); elseif name == "warn" then - cmdtag:tag("note", {type="warn"}):text(content):up(); + cmdreply:tag("note", {type="warn"}):text(content):up(); elseif name == "error" then - cmdtag:tag("note", {type="error"}):text(content.message):up(); + cmdreply:tag("note", {type="error"}):text(content.message):up(); elseif name == "actions" then local actions = st.stanza("actions", { execute = content.default }); for _, action in ipairs(content) do @@ -70,17 +70,17 @@ function _M.handle_cmd(command, origin, stanza) command.name, command.node, action); end end - cmdtag:add_child(actions); + cmdreply:add_child(actions); elseif name == "form" then - cmdtag:add_child((content.layout or content):form(content.values)); + cmdreply:add_child((content.layout or content):form(content.values)); elseif name == "result" then - cmdtag:add_child((content.layout or content):form(content.values, "result")); + cmdreply:add_child((content.layout or content):form(content.values, "result")); elseif name == "other" then - cmdtag:add_child(content); + cmdreply:add_child(content); end end local reply = st.reply(stanza); - reply:add_child(cmdtag); + reply:add_child(cmdreply); origin.send(reply); return true; diff --git a/plugins/adhoc/mod_adhoc.lua b/plugins/adhoc/mod_adhoc.lua index 1c956021..1ccea419 100644 --- a/plugins/adhoc/mod_adhoc.lua +++ b/plugins/adhoc/mod_adhoc.lua @@ -5,9 +5,8 @@ -- COPYING file in the source package for more information. -- +local it = require "util.iterators"; local st = require "util.stanza"; -local keys = require "util.iterators".keys; -local array_collect = require "util.array".collect; local is_admin = require "core.usermanager".is_admin; local jid_split = require "util.jid".split; local adhoc_handle_cmd = module:require "adhoc".handle_cmd; @@ -45,8 +44,8 @@ module:hook("host-disco-info-node", function (event) end); module:hook("host-disco-items-node", function (event) - local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node; - if node ~= xmlns_cmd then + local stanza, reply, disco_node = event.stanza, event.reply, event.node; + if disco_node ~= xmlns_cmd then return; end @@ -54,9 +53,7 @@ module:hook("host-disco-items-node", function (event) local admin = is_admin(from, stanza.attr.to); local global_admin = is_admin(from); local username, hostname = jid_split(from); - local nodes = array_collect(keys(commands)):sort(); - for _, node in ipairs(nodes) do - local command = commands[node]; + for node, command in it.sorted_pairs(commands) do if (command.permission == "admin" and admin) or (command.permission == "global_admin" and global_admin) or (command.permission == "local_user" and hostname == module.host) @@ -69,28 +66,26 @@ module:hook("host-disco-items-node", function (event) event.exists = true; end); -module:hook("iq/host/"..xmlns_cmd..":command", function (event) +module:hook("iq-set/host/"..xmlns_cmd..":command", function (event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type == "set" then - local node = stanza.tags[1].attr.node - local command = commands[node]; - if command then - local from = stanza.attr.from; - local admin = is_admin(from, stanza.attr.to); - local global_admin = is_admin(from); - local username, hostname = jid_split(from); - if (command.permission == "admin" and not admin) - or (command.permission == "global_admin" and not global_admin) - or (command.permission == "local_user" and hostname ~= module.host) then - origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up() - :add_child(commands[node]:cmdtag("canceled") - :tag("note", {type="error"}):text("You don't have permission to execute this command"))); - return true - end - -- User has permission now execute the command - adhoc_handle_cmd(commands[node], origin, stanza); - return true; + local node = stanza.tags[1].attr.node + local command = commands[node]; + if command then + local from = stanza.attr.from; + local admin = is_admin(from, stanza.attr.to); + local global_admin = is_admin(from); + local username, hostname = jid_split(from); + if (command.permission == "admin" and not admin) + or (command.permission == "global_admin" and not global_admin) + or (command.permission == "local_user" and hostname ~= module.host) then + origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up() + :add_child(commands[node]:cmdtag("canceled") + :tag("note", {type="error"}):text("You don't have permission to execute this command"))); + return true end + -- User has permission now execute the command + adhoc_handle_cmd(commands[node], origin, stanza); + return true; end end, 500); diff --git a/plugins/mod_admin_adhoc.lua b/plugins/mod_admin_adhoc.lua index f3de6793..37e77ab0 100644 --- a/plugins/mod_admin_adhoc.lua +++ b/plugins/mod_admin_adhoc.lua @@ -3,6 +3,7 @@ -- This file is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. -- +-- luacheck: ignore 212/self 212/data 212/state 412/err 422/err local _G = _G; @@ -95,7 +96,12 @@ local change_user_password_command_handler = adhoc_simple(change_user_password_l end local username, host, resource = jid.split(fields.accountjid); if module_host ~= host then - return { status = "completed", error = { message = "Trying to change the password of a user on " .. host .. " but command was sent to " .. module_host}}; + return { + status = "completed", + error = { + message = "Trying to change the password of a user on " .. host .. " but command was sent to " .. module_host + } + }; end if usermanager_user_exists(username, host) and usermanager_set_password(username, fields.password, host, nil) then return { status = "completed", info = "Password successfully changed" }; @@ -207,8 +213,8 @@ local get_user_password_handler = adhoc_simple(get_user_password_layout, functio return generate_error_message(err); end local user, host, resource = jid.split(fields.accountjid); - local accountjid = ""; - local password = ""; + local accountjid; + local password; if host ~= module_host then return { status = "completed", error = { message = "Tried to get password for a user on " .. host .. " but command was sent to " .. module_host } }; elseif usermanager_user_exists(user, host) then @@ -246,15 +252,15 @@ local get_user_roster_handler = adhoc_simple(get_user_roster_layout, function(fi local roster = rm_load_roster(user, host); local query = st.stanza("query", { xmlns = "jabber:iq:roster" }); - for jid in pairs(roster) do - if jid then + for contact_jid in pairs(roster) do + if contact_jid then query:tag("item", { - jid = jid, - subscription = roster[jid].subscription, - ask = roster[jid].ask, - name = roster[jid].name, + jid = contact_jid, + subscription = roster[contact_jid].subscription, + ask = roster[contact_jid].ask, + name = roster[contact_jid].name, }); - for group in pairs(roster[jid].groups) do + for group in pairs(roster[contact_jid].groups) do query:tag("group"):text(group):up(); end query:up(); @@ -289,7 +295,7 @@ local get_user_stats_handler = adhoc_simple(get_user_stats_layout, function(fiel return generate_error_message(err); end - local user, host, resource = jid.split(fields.accountjid); + local user, host = jid.split(fields.accountjid); if host ~= module_host then return { status = "completed", error = { message = "Tried to get stats for a user on " .. host .. " but command was sent to " .. module_host } }; elseif not usermanager_user_exists(user, host) then @@ -299,8 +305,8 @@ local get_user_stats_handler = adhoc_simple(get_user_stats_layout, function(fiel local rostersize = 0; local IPs = ""; local resources = ""; - for jid in pairs(roster) do - if jid then + for contact_jid in pairs(roster) do + if contact_jid then rostersize = rostersize + 1; end end @@ -319,7 +325,7 @@ local get_online_users_layout = dataforms_new{ { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; { name = "max_items", type = "list-single", label = "Maximum number of users", - value = { "25", "50", "75", "100", "150", "200", "all" } }; + options = { "25", "50", "75", "100", "150", "200", "all" } }; { name = "details", type = "boolean", label = "Show details" }; }; @@ -369,7 +375,7 @@ local list_s2s_this_result = dataforms_new { { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/s2s#list" }; { name = "sessions", type = "text-multi", label = "Connections:" }; - { name = "num_in", type = "text-single", label = "#incomming connections:" }; + { name = "num_in", type = "text-single", label = "#incoming connections:" }; { name = "num_out", type = "text-single", label = "#outgoing connections:" }; }; @@ -588,7 +594,7 @@ end, function(fields, err) end local ok_list, err_list = {}, {}; - for host_name, host in pairs(hosts) do + for host_name in pairs(hosts) do if modulemanager.is_loaded(host_name, fields.module) then local ok, err = modulemanager.reload(host_name, fields.module); if ok then @@ -641,13 +647,16 @@ local shut_down_service_layout = dataforms_new{ { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" }; { name = "delay", type = "list-single", label = "Time delay before shutting down", - value = { {label = "30 seconds", value = "30"}, - {label = "60 seconds", value = "60"}, - {label = "90 seconds", value = "90"}, - {label = "2 minutes", value = "120"}, - {label = "3 minutes", value = "180"}, - {label = "4 minutes", value = "240"}, - {label = "5 minutes", value = "300"}, + value = "5", + options = { + {label = "5 seconds", value = "5"}, + {label = "30 seconds", value = "30"}, + {label = "60 seconds", value = "60"}, + {label = "90 seconds", value = "90"}, + {label = "2 minutes", value = "120"}, + {label = "3 minutes", value = "180"}, + {label = "4 minutes", value = "240"}, + {label = "5 minutes", value = "300"}, }; }; { name = "announcement", type = "text-multi", label = "Announcement" }; @@ -664,7 +673,7 @@ local shut_down_service_handler = adhoc_simple(shut_down_service_layout, functio send_to_online(message); end - timer_add_task(tonumber(fields.delay or "5"), function(time) prosody.shutdown("Shutdown by adhoc command") end); + timer_add_task(tonumber(fields.delay or "5"), function() prosody.shutdown("Shutdown by adhoc command") end); return { status = "completed", info = "Server is about to shut down" }; end); @@ -730,7 +739,7 @@ end, function(fields, err) end local ok_list, err_list = {}, {}; - for host_name, host in pairs(hosts) do + for host_name in pairs(hosts) do if modulemanager.is_loaded(host_name, fields.module) then local ok, err = modulemanager.unload(host_name, fields.module); if ok then @@ -799,6 +808,7 @@ local deactivate_host_handler = adhoc_simple(deactivate_host_layout, function(fi end end); +-- luacheck: max_line_length 180 local add_user_desc = adhoc_new("Add User", "http://jabber.org/protocol/admin#add-user", add_user_command_handler, "admin"); local change_user_password_desc = adhoc_new("Change User Password", "http://jabber.org/protocol/admin#change-user-password", change_user_password_command_handler, "admin"); diff --git a/plugins/mod_admin_telnet.lua b/plugins/mod_admin_telnet.lua index 5e8d8534..1cbe27a4 100644 --- a/plugins/mod_admin_telnet.lua +++ b/plugins/mod_admin_telnet.lua @@ -5,6 +5,7 @@ -- This project is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. -- +-- luacheck: ignore 212/self module:set_global(); @@ -13,11 +14,11 @@ local modulemanager = require "core.modulemanager"; local s2smanager = require "core.s2smanager"; local portmanager = require "core.portmanager"; local helpers = require "util.helpers"; +local server = require "net.server"; local _G = _G; local prosody = _G.prosody; -local hosts = prosody.hosts; local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" }; @@ -34,8 +35,8 @@ local commands = module:shared("commands") local def_env = module:shared("env"); local default_env_mt = { __index = def_env }; -local function redirect_output(_G, session) - local env = setmetatable({ print = session.print }, { __index = function (t, k) return rawget(_G, k); end }); +local function redirect_output(target, session) + local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end }); env.dofile = function(name) local f, err = envloadfile(name, env); if not f then return f, err; end @@ -163,7 +164,7 @@ function console_listener.onreadtimeout(conn) end end -function console_listener.ondisconnect(conn, err) +function console_listener.ondisconnect(conn, err) -- luacheck: ignore 212/err local session = sessions[conn]; if session then session.disconnect(); @@ -361,7 +362,7 @@ function def_env.module:load(name, hosts, config) hosts = get_hosts_set(hosts); -- Load the module for each host - local ok, err, count, mod = true, nil, 0, nil; + local ok, err, count, mod = true, nil, 0; for host in hosts do if (not modulemanager.is_loaded(host, name)) then mod, err = modulemanager.load(host, name, config); @@ -404,12 +405,14 @@ function def_env.module:unload(name, hosts) return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err)); end +local function _sort_hosts(a, b) + if a == "*" then return true + elseif b == "*" then return false + else return a < b; end +end + function def_env.module:reload(name, hosts) - hosts = array.collect(get_hosts_set(hosts, name)):sort(function (a, b) - if a == "*" then return true - elseif b == "*" then return false - else return a < b; end - end); + hosts = array.collect(get_hosts_set(hosts, name)):sort(_sort_hosts) -- Reload the module for each host local ok, err, count = true, nil, 0; @@ -567,7 +570,7 @@ local function show_c2s(callback) end); end -function def_env.c2s:count(match_jid) +function def_env.c2s:count() return true, "Total: ".. iterators.count(values(module:shared"/*/c2s/sessions")) .." clients"; end @@ -651,6 +654,7 @@ function def_env.s2s:show(match_jid, annotate) if (not match_jid) or remotehost:match(match_jid) or localhost:match(match_jid) then table.insert(s2s_list, sess_lines); + -- luacheck: ignore 421/print local print = function (s) table.insert(sess_lines, " "..s); end if session.sendq then print("There are "..#session.sendq.." queued outgoing stanzas for this connection"); @@ -830,7 +834,7 @@ function def_env.s2s:close(from, to) local match_id; if from and not to then - match_id, from = from; + match_id, from = from, nil; elseif not to then return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'"; elseif from == to then @@ -875,9 +879,9 @@ function def_env.host:list() local print = self.session.print; local i = 0; local type; - for host in values(array.collect(keys(prosody.hosts)):sort()) do + for host, host_session in iterators.sorted_pairs(prosody.hosts) do i = i + 1; - type = hosts[host].type; + type = host_session.type; if type == "local" then print(host); else @@ -896,14 +900,11 @@ def_env.port = {}; function def_env.port:list() local print = self.session.print; local services = portmanager.get_active_services().data; - local ordered_services, n_ports = {}, 0; - for service, interfaces in pairs(services) do - table.insert(ordered_services, service); - end - table.sort(ordered_services); - for _, service in ipairs(ordered_services) do + local n_services, n_ports = 0, 0; + for service, interfaces in iterators.sorted_pairs(services) do + n_services = n_services + 1; local ports_list = {}; - for interface, ports in pairs(services[service]) do + for interface, ports in pairs(interfaces) do for port in pairs(ports) do table.insert(ports_list, "["..interface.."]:"..port); end @@ -911,14 +912,14 @@ function def_env.port:list() n_ports = n_ports + #ports_list; print(service..": "..table.concat(ports_list, ", ")); end - return true, #ordered_services.." services listening on "..n_ports.." ports"; + return true, n_services.." services listening on "..n_ports.." ports"; end function def_env.port:close(close_port, close_interface) close_port = assert(tonumber(close_port), "Invalid port number"); local n_closed = 0; local services = portmanager.get_active_services().data; - for service, interfaces in pairs(services) do + for service, interfaces in pairs(services) do -- luacheck: ignore 213 for interface, ports in pairs(interfaces) do if not close_interface or close_interface == interface then if ports[close_port] then @@ -947,22 +948,23 @@ local console_room_mt = { local function check_muc(jid) local room_name, host = jid_split(jid); - if not hosts[host] then + if not prosody.hosts[host] then return nil, "No such host: "..host; - elseif not hosts[host].modules.muc then + elseif not prosody.hosts[host].modules.muc then return nil, "Host '"..host.."' is not a MUC service"; end return room_name, host; end -function def_env.muc:create(room_jid) +function def_env.muc:create(room_jid, config) local room_name, host = check_muc(room_jid); if not room_name then return room_name, host; end if not room_name then return nil, host end - if hosts[host].modules.muc.rooms[room_jid] then return nil, "Room exists already" end - return hosts[host].modules.muc.create_room(room_jid); + if config ~= nil and type(config) ~= "table" then return nil, "Config must be a table"; end + if prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end + return prosody.hosts[host].modules.muc.create_room(room_jid, config); end function def_env.muc:room(room_jid) @@ -970,7 +972,7 @@ function def_env.muc:room(room_jid) if not room_name then return room_name, host; end - local room_obj = hosts[host].modules.muc.rooms[room_jid]; + local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid); if not room_obj then return nil, "No such room: "..room_jid; end @@ -978,14 +980,14 @@ function def_env.muc:room(room_jid) end function def_env.muc:list(host) - local host_session = hosts[host]; + local host_session = prosody.hosts[host]; if not host_session or not host_session.modules.muc then return nil, "Please supply the address of a local MUC component"; end local print = self.session.print; local c = 0; - for name in keys(host_session.modules.muc.rooms) do - print(name); + for room in host_session.modules.muc.each_room() do + print(room.jid); c = c + 1; end return true, c.." rooms"; @@ -996,7 +998,7 @@ local um = require"core.usermanager"; def_env.user = {}; function def_env.user:create(jid, password) local username, host = jid_split(jid); - if not hosts[host] then + if not prosody.hosts[host] then return nil, "No such host: "..host; elseif um.user_exists(username, host) then return nil, "User exists"; @@ -1011,7 +1013,7 @@ end function def_env.user:delete(jid) local username, host = jid_split(jid); - if not hosts[host] then + if not prosody.hosts[host] then return nil, "No such host: "..host; elseif not um.user_exists(username, host) then return nil, "No such user"; @@ -1026,7 +1028,7 @@ end function def_env.user:password(jid, password) local username, host = jid_split(jid); - if not hosts[host] then + if not prosody.hosts[host] then return nil, "No such host: "..host; elseif not um.user_exists(username, host) then return nil, "No such user"; @@ -1042,7 +1044,7 @@ end function def_env.user:list(host, pat) if not host then return nil, "No host given"; - elseif not hosts[host] then + elseif not prosody.hosts[host] then return nil, "No such host"; end local print = self.session.print; @@ -1061,9 +1063,9 @@ def_env.xmpp = {}; local st = require "util.stanza"; function def_env.xmpp:ping(localhost, remotehost) - if hosts[localhost] then + if prosody.hosts[localhost] then module:send(st.iq{ from=localhost, to=remotehost, type="get", id="ping" } - :tag("ping", {xmlns="urn:xmpp:ping"}), hosts[localhost]); + :tag("ping", {xmlns="urn:xmpp:ping"}), prosody.hosts[localhost]); return true, "Sent ping"; else return nil, "No such host"; @@ -1141,22 +1143,374 @@ end function def_env.debug:events(host, event) local events_obj; if host and host ~= "*" then - if not hosts[host] then + if host == "http" then + events_obj = require "net.http.server"._events; + elseif not prosody.hosts[host] then return false, "Unknown host: "..host; + else + events_obj = prosody.hosts[host].events; end - events_obj = hosts[host].events; else events_obj = prosody.events; end return true, helpers.show_events(events_obj, event); end +function def_env.debug:timers() + local socket = require "socket"; + local print = self.session.print; + local add_task = require"util.timer".add_task; + local h, params = add_task.h, add_task.params; + if h then + print("-- util.timer"); + for i, id in ipairs(h.ids) do + if not params[id] then + print(os.date("%F %T", h.priorities[i]), h.items[id]); + elseif not params[id].callback then + print(os.date("%F %T", h.priorities[i]), h.items[id], unpack(params[id])); + else + print(os.date("%F %T", h.priorities[i]), params[id].callback, unpack(params[id])); + end + end + end + if server.event_base then + local count = 0; + for _, v in pairs(debug.getregistry()) do + if type(v) == "function" and v.callback and v.callback == add_task._on_timer then + count = count + 1; + end + end + print(count .. " libevent callbacks"); + end + if h then + local next_time = h:peek(); + if next_time then + return true, os.date("Next event at %F %T (in %%.6fs)", next_time):format(next_time - socket.gettime()); + end + end + return true; +end + +-- COMPAT: debug:timers() was timer:info() for some time in trunk +def_env.timer = { info = def_env.debug.timers }; + module:hook("server-stopping", function(event) - for conn, session in pairs(sessions) do + for _, session in pairs(sessions) do session.print("Shutting down: "..(event.reason or "unknown reason")); end end); +def_env.stats = {}; + +local function format_stat(type, value, ref_value) + ref_value = ref_value or value; + --do return tostring(value) end + if type == "duration" then + if ref_value < 0.001 then + return ("%d µs"):format(value*1000000); + elseif ref_value < 0.9 then + return ("%0.2f ms"):format(value*1000); + end + return ("%0.2f"):format(value); + elseif type == "size" then + if ref_value > 1048576 then + return ("%d MB"):format(value/1048576); + elseif ref_value > 1024 then + return ("%d KB"):format(value/1024); + end + return ("%d bytes"):format(value); + elseif type == "rate" then + if ref_value < 0.9 then + return ("%0.2f/min"):format(value*60); + end + return ("%0.2f/sec"):format(value); + end + return tostring(value); +end + +local stats_methods = {}; +function stats_methods:bounds(_lower, _upper) + for _, stat_info in ipairs(self) do + local data = stat_info[4]; + if data then + local lower = _lower or data.min; + local upper = _upper or data.max; + local new_data = { + min = lower; + max = upper; + samples = {}; + sample_count = 0; + count = data.count; + units = data.units; + }; + local sum = 0; + for _, v in ipairs(data.samples) do + if v > upper then + break; + elseif v>=lower then + table.insert(new_data.samples, v); + sum = sum + v; + end + end + new_data.sample_count = #new_data.samples; + stat_info[4] = new_data; + stat_info[3] = sum/new_data.sample_count; + end + end + return self; +end + +function stats_methods:trim(lower, upper) + upper = upper or (100-lower); + local statistics = require "util.statistics"; + for _, stat_info in ipairs(self) do + -- Strip outliers + local data = stat_info[4]; + if data then + local new_data = { + min = statistics.get_percentile(data, lower); + max = statistics.get_percentile(data, upper); + samples = {}; + sample_count = 0; + count = data.count; + units = data.units; + }; + local sum = 0; + for _, v in ipairs(data.samples) do + if v > new_data.max then + break; + elseif v>=new_data.min then + table.insert(new_data.samples, v); + sum = sum + v; + end + end + new_data.sample_count = #new_data.samples; + stat_info[4] = new_data; + stat_info[3] = sum/new_data.sample_count; + end + end + return self; +end + +function stats_methods:max(upper) + return self:bounds(nil, upper); +end + +function stats_methods:min(lower) + return self:bounds(lower, nil); +end + +function stats_methods:summary() + local statistics = require "util.statistics"; + for _, stat_info in ipairs(self) do + local type, value, data = stat_info[2], stat_info[3], stat_info[4]; + if data and data.samples then + table.insert(stat_info.output, string.format("Count: %d (%d captured)", + data.count, + data.sample_count + )); + table.insert(stat_info.output, string.format("Min: %s Mean: %s Max: %s", + format_stat(type, data.min), + format_stat(type, value), + format_stat(type, data.max) + )); + table.insert(stat_info.output, string.format("Q1: %s Median: %s Q3: %s", + format_stat(type, statistics.get_percentile(data, 25)), + format_stat(type, statistics.get_percentile(data, 50)), + format_stat(type, statistics.get_percentile(data, 75)) + )); + end + end + return self; +end + +function stats_methods:cfgraph() + for _, stat_info in ipairs(self) do + local name, type, value, data = unpack(stat_info, 1, 4); + local function print(s) + table.insert(stat_info.output, s); + end + + if data and data.sample_count and data.sample_count > 0 then + local raw_histogram = require "util.statistics".get_histogram(data); + + local graph_width, graph_height = 50, 10; + local eighth_chars = " ▁▂▃▄▅▆▇█"; + + local range = data.max - data.min; + + if range > 0 then + local x_scaling = #raw_histogram/graph_width; + local histogram = {}; + for i = 1, graph_width do + histogram[i] = math.max(raw_histogram[i*x_scaling-1] or 0, raw_histogram[i*x_scaling] or 0); + end + + print(""); + print(("_"):rep(52)..format_stat(type, data.max)); + for row = graph_height, 1, -1 do + local row_chars = {}; + local min_eighths, max_eighths = 8, 0; + for i = 1, #histogram do + local char_eighths = math.ceil(math.max(math.min((graph_height/(data.max/histogram[i]))-(row-1), 1), 0)*8); + if char_eighths < min_eighths then + min_eighths = char_eighths; + end + if char_eighths > max_eighths then + max_eighths = char_eighths; + end + if char_eighths == 0 then + row_chars[i] = "-"; + else + local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3); + row_chars[i] = char; + end + end + print(table.concat(row_chars).."|-"..format_stat(type, data.max/(graph_height/(row-0.5)))); + end + print(("\\ "):rep(11)); + local x_labels = {}; + for i = 1, 11 do + local s = ("%-4s"):format((i-1)*10); + if #s > 4 then + s = s:sub(1, 3).."…"; + end + x_labels[i] = s; + end + print(" "..table.concat(x_labels, " ")); + local units = "%"; + local margin = math.floor((graph_width-#units)/2); + print((" "):rep(margin)..units); + else + print("[range too small to graph]"); + end + print(""); + end + end + return self; +end + +function stats_methods:histogram() + for _, stat_info in ipairs(self) do + local name, type, value, data = unpack(stat_info, 1, 4); + local function print(s) + table.insert(stat_info.output, s); + end + + if not data then + print("[no data]"); + return self; + elseif not data.sample_count then + print("[not a sampled metric type]"); + return self; + end + + local graph_width, graph_height = 50, 10; + local eighth_chars = " ▁▂▃▄▅▆▇█"; + + local range = data.max - data.min; + + if range > 0 then + local n_buckets = graph_width; + + local histogram = {}; + for i = 1, n_buckets do + histogram[i] = 0; + end + local max_bin_samples = 0; + for _, d in ipairs(data.samples) do + local bucket = math.floor(1+(n_buckets-1)/(range/(d-data.min))); + histogram[bucket] = histogram[bucket] + 1; + if histogram[bucket] > max_bin_samples then + max_bin_samples = histogram[bucket]; + end + end + + print(""); + print(("_"):rep(52)..max_bin_samples); + for row = graph_height, 1, -1 do + local row_chars = {}; + local min_eighths, max_eighths = 8, 0; + for i = 1, #histogram do + local char_eighths = math.ceil(math.max(math.min((graph_height/(max_bin_samples/histogram[i]))-(row-1), 1), 0)*8); + if char_eighths < min_eighths then + min_eighths = char_eighths; + end + if char_eighths > max_eighths then + max_eighths = char_eighths; + end + if char_eighths == 0 then + row_chars[i] = "-"; + else + local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3); + row_chars[i] = char; + end + end + print(table.concat(row_chars).."|-"..math.ceil((max_bin_samples/graph_height)*(row-0.5))); + end + print(("\\ "):rep(11)); + local x_labels = {}; + for i = 1, 11 do + local s = ("%-4s"):format(format_stat(type, data.min+range*i/11, data.min):match("^%S+")); + if #s > 4 then + s = s:sub(1, 3).."…"; + end + x_labels[i] = s; + end + print(" "..table.concat(x_labels, " ")); + local units = format_stat(type, data.min):match("%s+(.+)$") or data.units or ""; + local margin = math.floor((graph_width-#units)/2); + print((" "):rep(margin)..units); + else + print("[range too small to graph]"); + end + print(""); + end + return self; +end + +local function stats_tostring(stats) + local print = stats.session.print; + for _, stat_info in ipairs(stats) do + if #stat_info.output > 0 then + print("\n#"..stat_info[1]); + print(""); + for _, v in ipairs(stat_info.output) do + print(v); + end + print(""); + else + print(("%-50s %s"):format(stat_info[1], format_stat(stat_info[2], stat_info[3]))); + end + end + return #stats.." statistics displayed"; +end + +local stats_mt = {__index = stats_methods, __tostring = stats_tostring } +local function new_stats_context(self) + return setmetatable({ session = self.session, stats = true }, stats_mt); +end + +function def_env.stats:show(filter) + local stats, changed, extra = require "core.statsmanager".get_stats(); + local available, displayed = 0, 0; + local displayed_stats = new_stats_context(self); + for name, value in pairs(stats) do + available = available + 1; + if not filter or name:match(filter) then + displayed = displayed + 1; + local type = name:match(":(%a+)$"); + table.insert(displayed_stats, { + name, type, value, extra[name]; + output = {}; + }); + end + end + return displayed_stats; +end + + + ------------- function printbanner(session) @@ -1175,7 +1529,7 @@ function printbanner(session) if option == "short" or option == "full" then session.print("Welcome to the Prosody administration console. For a list of commands, type: help"); session.print("You may find more help on using this console in our online documentation at "); - session.print("http://prosody.im/doc/console\n"); + session.print("https://prosody.im/doc/console\n"); end if option ~= "short" and option ~= "full" and option ~= "graphic" then session.print(option); diff --git a/plugins/mod_announce.lua b/plugins/mod_announce.lua index 9327556c..14bb9f3d 100644 --- a/plugins/mod_announce.lua +++ b/plugins/mod_announce.lua @@ -37,7 +37,7 @@ end -- Old <message>-based jabberd-style announcement sending function handle_announcement(event) - local origin, stanza = event.origin, event.stanza; + local stanza = event.stanza; local node, host, resource = jid.split(stanza.attr.to); if resource ~= "announce/online" then @@ -72,7 +72,7 @@ local announce_layout = dataforms_new{ { name = "announcement", type = "text-multi", required = true, label = "Announcement" }; }; -function announce_handler(self, data, state) +function announce_handler(_, data, state) if state then if data.action == "cancel" then return { status = "canceled" }; @@ -91,10 +91,9 @@ function announce_handler(self, data, state) else return { status = "executing", actions = {"next", "complete", default = "complete"}, form = announce_layout }, "executing"; end - - return true; end +module:depends "adhoc"; local adhoc_new = module:require "adhoc".new; local announce_desc = adhoc_new("Send Announcement to Online Users", "http://jabber.org/protocol/admin#announce", announce_handler, "admin"); module:provides("adhoc", announce_desc); diff --git a/plugins/mod_auth_insecure.lua b/plugins/mod_auth_insecure.lua new file mode 100644 index 00000000..9e23c29f --- /dev/null +++ b/plugins/mod_auth_insecure.lua @@ -0,0 +1,53 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- luacheck: ignore 212 + +local datamanager = require "util.datamanager"; +local new_sasl = require "util.sasl".new; + +local host = module.host; +local provider = { name = "insecure" }; + +assert(module:get_option_string("insecure_open_authentication") == "Yes please, I know what I'm doing!"); + +function provider.test_password(username, password) + return true; +end + +function provider.set_password(username, password) + local account = datamanager.load(username, host, "accounts"); + if account then + account.password = password; + return datamanager.store(username, host, "accounts", account); + end + return nil, "Account not available."; +end + +function provider.user_exists(username) + return true; +end + +function provider.create_user(username, password) + return datamanager.store(username, host, "accounts", {password = password}); +end + +function provider.delete_user(username) + return datamanager.store(username, host, "accounts", nil); +end + +function provider.get_sasl_handler() + local getpass_authentication_profile = { + plain_test = function(sasl, username, password, realm) + return true, true; + end + }; + return new_sasl(module.host, getpass_authentication_profile); +end + +module:add_item("auth-provider", provider); + diff --git a/plugins/mod_blocklist.lua b/plugins/mod_blocklist.lua index 3c6708f0..8aca7332 100644 --- a/plugins/mod_blocklist.lua +++ b/plugins/mod_blocklist.lua @@ -114,12 +114,14 @@ end, -1); -- Add or remove some jid(s) from the blocklist -- We want this to be atomic and not do a partial update local function edit_blocklist(event) + local now = os.time(); local origin, stanza = event.origin, event.stanza; local username = origin.username; local action = stanza.tags[1]; -- "block" or "unblock" - local is_blocking = action.name == "block" or nil; -- nil if unblocking + local is_blocking = action.name == "block" and now or nil; -- nil if unblocking local new = {}; -- JIDs to block depending or unblock on action + -- XEP-0191 sayeth: -- > When the user blocks communications with the contact, the user's -- > server MUST send unavailable presence information to the contact (but @@ -158,15 +160,15 @@ local function edit_blocklist(event) local new_blocklist = { -- We set the [false] key to someting as a signal not to migrate privacy lists - [false] = blocklist[false] or { created = os.time(); }; + [false] = blocklist[false] or { created = now; }; }; if type(blocklist[false]) == "table" then - new_blocklist[false].modified = os.time(); + new_blocklist[false].modified = now; end if is_blocking or next(new) then - for jid in pairs(blocklist) do - if jid then new_blocklist[jid] = true; end + for jid, t in pairs(blocklist) do + if jid then new_blocklist[jid] = t; end end for jid in pairs(new) do new_blocklist[jid] = is_blocking; diff --git a/plugins/mod_bosh.lua b/plugins/mod_bosh.lua index 8cda4a23..251ef740 100644 --- a/plugins/mod_bosh.lua +++ b/plugins/mod_bosh.lua @@ -6,9 +6,8 @@ -- COPYING file in the source package for more information. -- -module:set_global(); -- Global module +module:set_global(); -local hosts = _G.hosts; local new_xmpp_stream = require "util.xmppstream".new; local sm = require "core.sessionmanager"; local sm_destroy_session = sm.destroy_session; @@ -16,12 +15,14 @@ local new_uuid = require "util.uuid".generate; local core_process_stanza = prosody.core_process_stanza; local st = require "util.stanza"; local logger = require "util.logger"; -local log = logger.init("mod_bosh"); +local log = module._log; local initialize_filters = require "util.filters".initialize; local math_min = math.min; -local xpcall, tostring, type = xpcall, tostring, type; +local tostring, type = tostring, type; local traceback = debug.traceback; +local runner = require"util.async".runner; local nameprep = require "util.encodings".stringprep.nameprep; +local cache = require "util.cache"; local xmlns_streams = "http://etherx.jabber.org/streams"; local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; @@ -48,33 +49,14 @@ local cross_domain = module:get_option("cross_domain_bosh", false); if cross_domain == true then cross_domain = "*"; end if type(cross_domain) == "table" then cross_domain = table.concat(cross_domain, ", "); end -local trusted_proxies = module:get_option_set("trusted_proxies", { "127.0.0.1", "::1" })._items; - -local function get_ip_from_request(request) - local ip = request.conn:ip(); - local forwarded_for = request.headers.x_forwarded_for; - if forwarded_for then - forwarded_for = forwarded_for..", "..ip; - for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do - if not trusted_proxies[forwarded_ip] then - ip = forwarded_ip; - end - end - end - return ip; -end - local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat; -local os_time = os.time; -- All sessions, and sessions that have no requests open -local sessions, inactive_sessions = module:shared("sessions", "inactive_sessions"); +local sessions = module:shared("sessions"); -- Used to respond to idle sessions (those with waiting requests) -local waiting_requests = module:shared("waiting_requests"); function on_destroy_request(request) log("debug", "Request destroyed: %s", tostring(request)); - waiting_requests[request] = nil; local session = sessions[request.context.sid]; if session then local requests = session.requests; @@ -88,9 +70,24 @@ function on_destroy_request(request) -- If this session now has no requests open, mark it as inactive local max_inactive = session.bosh_max_inactive; if max_inactive and #requests == 0 then - inactive_sessions[session] = os_time() + max_inactive; + if session.inactive_timer then + session.inactive_timer:stop(); + end + session.inactive_timer = module:add_timer(max_inactive, check_inactive, session, request.context, + "BOSH client silent for over "..max_inactive.." seconds"); (session.log or log)("debug", "BOSH session marked as inactive (for %ds)", max_inactive); end + if session.bosh_wait_timer then + session.bosh_wait_timer:stop(); + session.bosh_wait_timer = nil; + end + end +end + +function check_inactive(now, session, context, reason) -- luacheck: ignore 212/now + if not session.destroyed then + sessions[context.sid] = nil; + sm_destroy_session(session, reason); end end @@ -124,7 +121,7 @@ function handle_POST(event) local headers = response.headers; headers.content_type = "text/xml; charset=utf-8"; - if cross_domain and event.request.headers.origin then + if cross_domain and request.headers.origin then set_cross_domain_headers(response); end @@ -148,8 +145,14 @@ function handle_POST(event) if session then -- Session was marked as inactive, since we have -- a request open now, unmark it - if inactive_sessions[session] and #session.requests > 0 then - inactive_sessions[session] = nil; + if session.inactive_timer and #session.requests > 0 then + session.inactive_timer:stop(); + session.inactive_timer = nil; + end + + if session.bosh_wait_timer then + session.bosh_wait_timer:stop(); + session.bosh_wait_timer = nil; end local r = session.requests; @@ -177,9 +180,6 @@ function handle_POST(event) if not response.finished then -- We're keeping this request open, to respond later log("debug", "Have nothing to say, so leaving request unanswered for now"); - if session.bosh_wait then - waiting_requests[response] = os_time() + session.bosh_wait; - end end if session.bosh_terminate then @@ -187,10 +187,22 @@ function handle_POST(event) session:close(); return nil; else + if session.bosh_wait and #session.requests > 0 then + session.bosh_wait_timer = module:add_timer(session.bosh_wait, after_bosh_wait, session.requests[1], session) + end + return true; -- Inform http server we shall reply later end - elseif response.finished then - return; -- A response has been sent already + elseif response.finished or context.ignore_request then + if response.finished then + module:log("debug", "Response finished"); + end + if context.ignore_request then + module:log("debug", "Ignoring this request"); + end + -- A response has been sent already, or we're ignoring this request + -- (e.g. so a different instance of the module can handle it) + return; end module:log("warn", "Unable to associate request with a session (incomplete request?)"); local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", @@ -198,13 +210,17 @@ function handle_POST(event) return tostring(close_reply) .. "\n"; end +function after_bosh_wait(now, request, session) -- luacheck: ignore 212 + if request.conn then + session.send(""); + end +end local function bosh_reset_stream(session) session.notopen = true; end local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" }; - local function bosh_close_stream(session, reason) - (session.log or log)("info", "BOSH client disconnected"); + (session.log or log)("info", "BOSH client disconnected: %s", tostring((reason and reason.condition or reason) or "session close")); local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", ["xmlns:stream"] = xmlns_streams }); @@ -237,21 +253,22 @@ local function bosh_close_stream(session, reason) held_request:send(response_body); end sessions[session.sid] = nil; - inactive_sessions[session] = nil; sm_destroy_session(session); end +local runner_callbacks = { }; + -- Handle the <body> tag in the request payload. function stream_callbacks.streamopened(context, attr) local request, response = context.request, context.response; - local sid = attr.sid; + local sid, rid = attr.sid, tonumber(attr.rid); log("debug", "BOSH body open (sid: %s)", sid or "<none>"); + context.rid = rid; if not sid then -- New session request context.notopen = nil; -- Signals that we accept this opening tag local to_host = nameprep(attr.to); - local rid = tonumber(attr.rid); local wait = tonumber(attr.wait); if not to_host then log("debug", "BOSH client tried to connect to invalid host: %s", tostring(attr.to)); @@ -259,13 +276,6 @@ function stream_callbacks.streamopened(context, attr) ["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" }); response:send(tostring(close_reply)); return; - elseif not hosts[to_host] then - -- Unknown host - log("debug", "BOSH client tried to connect to unknown host: %s", tostring(attr.to)); - local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", - ["xmlns:stream"] = xmlns_streams, condition = "host-unknown" }); - response:send(tostring(close_reply)); - return; end if not rid or (not wait and attr.wait or wait < 0 or wait % 1 ~= 0) then log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", tostring(attr.rid), tostring(attr.wait)); @@ -275,28 +285,32 @@ function stream_callbacks.streamopened(context, attr) return; end - rid = rid - 1; wait = math_min(wait, bosh_max_wait); -- New session sid = new_uuid(); local session = { - type = "c2s_unauthed", conn = request.conn, sid = sid, rid = rid, host = to_host, + type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to, + rid = rid - 1, -- Hack for initial session setup, "previous" rid was $current_request - 1 bosh_version = attr.ver, bosh_wait = wait, streamid = sid, - bosh_max_inactive = bosh_max_inactivity, + bosh_max_inactive = bosh_max_inactivity, bosh_responses = cache.new(BOSH_HOLD+1):table(); requests = { }, send_buffer = {}, reset_stream = bosh_reset_stream, close = bosh_close_stream, dispatch_stanza = core_process_stanza, notopen = true, log = logger.init("bosh"..sid), secure = consider_bosh_secure or request.secure, - ip = get_ip_from_request(request); + ip = request.ip; }; sessions[sid] = session; + session.thread = runner(function (stanza) + session:dispatch_stanza(stanza); + end, runner_callbacks, session); + local filter = initialize_filters(session); session.log("debug", "BOSH session created for request from %s", session.ip); log("info", "New BOSH session, assigned it sid '%s'", sid); - hosts[session.host].events.fire_event("bosh-session", { session = session, request = request }); + module:fire_event("bosh-session", { session = session, request = request }); -- Send creation response local creating_session = true; @@ -335,8 +349,9 @@ function stream_callbacks.streamopened(context, attr) body_attr["xmlns:xmpp"] = "urn:xmpp:xbosh"; body_attr["xmpp:version"] = "1.0"; end - session.bosh_last_response = st.stanza("body", body_attr):top_tag()..t_concat(session.send_buffer).."</body>"; - oldest_request:send(session.bosh_last_response); + local response_xml = st.stanza("body", body_attr):top_tag()..t_concat(session.send_buffer).."</body>"; + session.bosh_responses[oldest_request.context.rid] = response_xml; + oldest_request:send(response_xml); session.send_buffer = {}; end return true; @@ -356,24 +371,31 @@ function stream_callbacks.streamopened(context, attr) session.conn = request.conn; if session.rid then - local rid = tonumber(attr.rid); local diff = rid - session.rid; -- Diff should be 1 for a healthy request + session.log("debug", "rid: %d, sess: %s, diff: %d", rid, session.rid, diff) if diff ~= 1 then context.sid = sid; context.notopen = nil; - if diff == 2 then + if diff == 2 then -- Missed a request -- Hold request, but don't process it (ouch!) session.log("debug", "rid skipped: %d, deferring this request", rid-1) context.defer = true; session.bosh_deferred = { context = context, sid = sid, rid = rid, terminate = attr.type == "terminate" }; return; end + -- Set a marker to indicate that stanzas in this request should NOT be processed + -- (these stanzas will already be in the XML parser's buffer) context.ignore = true; - if diff == 0 then - -- Re-send previous response, ignore stanzas in this request - session.log("debug", "rid repeated, ignoring: %s (diff %d)", session.rid, diff); - response:send(session.bosh_last_response); + if session.bosh_responses[rid] then + -- Re-send past response, ignore stanzas in this request + session.log("debug", "rid repeated within window, replaying old response"); + response:send(session.bosh_responses[rid]); + return; + elseif diff == 0 then + session.log("debug", "current rid repeated, ignoring stanzas"); + t_insert(session.requests, response); + context.sid = sid; return; end -- Session broken, destroy it @@ -397,13 +419,18 @@ function stream_callbacks.streamopened(context, attr) if session.notopen then local features = st.stanza("stream:features"); - hosts[session.host].events.fire_event("stream-features", { origin = session, features = features }); + module:context(session.host):fire_event("stream-features", { origin = session, features = features }); session.send(features); session.notopen = nil; end end local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(tostring(err), 2)); end + +function runner_callbacks:error(err) -- luacheck: ignore 212/self + return handleerr(err); +end + function stream_callbacks.handlestanza(context, stanza) if context.ignore then return; end log("debug", "BOSH stanza received: %s\n", stanza:top_tag()); @@ -417,9 +444,7 @@ function stream_callbacks.handlestanza(context, stanza) t_insert(session.bosh_deferred, stanza); else stanza = session.filter("stanzas/in", stanza); - if stanza then - return xpcall(function () return core_process_stanza(session, stanza) end, handleerr); - end + session.thread:run(stanza); end else log("debug", "No session for this stanza! (sid: %s)", context.sid or "none!"); @@ -432,13 +457,13 @@ function stream_callbacks.streamclosed(context) if not context.defer and session.bosh_deferred then -- Handle deferred stanzas now local deferred_stanzas = session.bosh_deferred; - local context = deferred_stanzas.context; + local deferred_context = deferred_stanzas.context; session.bosh_deferred = nil; log("debug", "Handling deferred stanzas from rid %d", deferred_stanzas.rid); session.rid = deferred_stanzas.rid; - t_insert(session.requests, context.response); + t_insert(session.requests, deferred_context.response); for _, stanza in ipairs(deferred_stanzas) do - stream_callbacks.handlestanza(context, stanza); + stream_callbacks.handlestanza(deferred_context, stanza); end if deferred_stanzas.terminate then session.bosh_terminate = true; @@ -452,8 +477,8 @@ function stream_callbacks.streamclosed(context) end function stream_callbacks.error(context, error) - log("debug", "Error parsing BOSH request payload; %s", error); if not context.sid then + log("debug", "Error parsing BOSH request payload; %s", error); local response = context.response; local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", ["xmlns:stream"] = xmlns_streams, condition = "bad-request" }); @@ -462,6 +487,7 @@ function stream_callbacks.error(context, error) end local session = sessions[context.sid]; + (session and session.log or log)("warn", "Error parsing BOSH request payload; %s", error); if error == "stream-error" then -- Remote stream error, we close normally session:close(); else @@ -469,65 +495,25 @@ function stream_callbacks.error(context, error) end end -local dead_sessions = module:shared("dead_sessions"); -function on_timer() - -- log("debug", "Checking for requests soon to timeout..."); - -- Identify requests timing out within the next few seconds - local now = os_time() + 3; - for request, reply_before in pairs(waiting_requests) do - if reply_before <= now then - log("debug", "%s was soon to timeout (at %d, now %d), sending empty response", tostring(request), reply_before, now); - -- Send empty response to let the - -- client know we're still here - if request.conn then - sessions[request.context.sid].send(""); - end - end - end - - now = now - 3; - local n_dead_sessions = 0; - for session, close_after in pairs(inactive_sessions) do - if close_after < now then - (session.log or log)("debug", "BOSH client inactive too long, destroying session at %d", now); - sessions[session.sid] = nil; - inactive_sessions[session] = nil; - n_dead_sessions = n_dead_sessions + 1; - dead_sessions[n_dead_sessions] = session; - end - end - - for i=1,n_dead_sessions do - local session = dead_sessions[i]; - dead_sessions[i] = nil; - sm_destroy_session(session, "BOSH client silent for over "..session.bosh_max_inactive.." seconds"); - end - return 1; -end -module:add_timer(1, on_timer); - - local GET_response = { headers = { content_type = "text/html"; }; body = [[<html><body> <p>It works! Now point your BOSH client to this URL to connect to Prosody.</p> - <p>For more information see <a href="http://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p> + <p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p> </body></html>]]; }; -function module.add_host(module) - module:depends("http"); - module:provides("http", { - default_path = "/http-bind"; - route = { - ["GET"] = GET_response; - ["GET /"] = GET_response; - ["OPTIONS"] = handle_OPTIONS; - ["OPTIONS /"] = handle_OPTIONS; - ["POST"] = handle_POST; - ["POST /"] = handle_POST; - }; - }); -end +module:depends("http"); +module:provides("http", { + default_path = "/http-bind"; + route = { + ["GET"] = GET_response; + ["GET /"] = GET_response; + ["OPTIONS"] = handle_OPTIONS; + ["OPTIONS /"] = handle_OPTIONS; + ["POST"] = handle_POST; + ["POST /"] = handle_POST; + }; +}); diff --git a/plugins/mod_c2s.lua b/plugins/mod_c2s.lua index 7f0d1b01..8e31a968 100644 --- a/plugins/mod_c2s.lua +++ b/plugins/mod_c2s.lua @@ -15,9 +15,9 @@ local sessionmanager = require "core.sessionmanager"; local st = require "util.stanza"; local sm_new_session, sm_destroy_session = sessionmanager.new_session, sessionmanager.destroy_session; local uuid_generate = require "util.uuid".generate; +local runner = require "util.async".runner; -local xpcall, tostring, type = xpcall, tostring, type; -local traceback = debug.traceback; +local tostring, type = tostring, type; local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; @@ -28,6 +28,7 @@ local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5); local opt_keepalives = module:get_option_boolean("c2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true)); local measure_connections = module:measure("connections", "amount"); +local measure_ipv6 = module:measure("ipv6", "amount"); local sessions = module:shared("sessions"); local core_process_stanza = prosody.core_process_stanza; @@ -35,13 +36,19 @@ local hosts = prosody.hosts; local stream_callbacks = { default_ns = "jabber:client" }; local listener = {}; +local runner_callbacks = {}; module:hook("stats-update", function () local count = 0; - for _ in pairs(sessions) do + local ipv6 = 0; + for _, session in pairs(sessions) do count = count + 1; + if session.ip and session.ip:match(":") then + ipv6 = ipv6 + 1; + end end measure_connections(count); + measure_ipv6(ipv6); end); --- Stream events handlers @@ -64,7 +71,7 @@ function stream_callbacks.streamopened(session, attr) end session.version = tonumber(attr.version) or 0; session.streamid = uuid_generate(); - (session.log or session)("debug", "Client sent opening <stream:stream> to %s", session.host); + (session.log or log)("debug", "Client sent opening <stream:stream> to %s", session.host); if not hosts[session.host] or not hosts[session.host].modules.c2s then -- We don't serve this host... @@ -134,12 +141,9 @@ function stream_callbacks.error(session, error, data) end end -local function handleerr(err) log("error", "Traceback[c2s]: %s", traceback(tostring(err), 2)); end function stream_callbacks.handlestanza(session, stanza) stanza = session.filter("stanzas/in", stanza); - if stanza then - return xpcall(function () return core_process_stanza(session, stanza) end, handleerr); - end + session.thread:run(stanza); end --- Session methods @@ -220,6 +224,18 @@ module:hook_global("user-password-changed", function(event) end end, 200); +function runner_callbacks:ready() + self.data.conn:resume(); +end + +function runner_callbacks:waiting() + self.data.conn:pause(); +end + +function runner_callbacks:error(err) + (self.data.log or log)("error", "Traceback[c2s]: %s", err); +end + --- Port listener function listener.onconnect(conn) local session = sm_new_session(conn); @@ -256,6 +272,10 @@ function listener.onconnect(conn) session.stream:reset(); end + session.thread = runner(function (stanza) + core_process_stanza(session, stanza); + end, runner_callbacks, session); + local filter = session.filter; function session.data(data) -- Parse the data, which will store stanzas in session.pending_stanzas diff --git a/plugins/mod_component.lua b/plugins/mod_component.lua index 4a210495..743a16a3 100644 --- a/plugins/mod_component.lua +++ b/plugins/mod_component.lua @@ -38,7 +38,7 @@ end function module.add_host(module) if module:get_host_type() ~= "component" then - error("Don't load mod_component manually, it should be for a component, please see http://prosody.im/doc/components", 0); + error("Don't load mod_component manually, it should be for a component, please see https://prosody.im/doc/components", 0); end local env = module.environment; diff --git a/plugins/mod_csi.lua b/plugins/mod_csi.lua new file mode 100644 index 00000000..03f052b7 --- /dev/null +++ b/plugins/mod_csi.lua @@ -0,0 +1,22 @@ +local st = require "util.stanza"; +local xmlns_csi = "urn:xmpp:csi:0"; +local csi_feature = st.stanza("csi", { xmlns = xmlns_csi }); + +module:hook("stream-features", function (event) + if event.origin.username then + event.features:add_child(csi_feature); + end +end); + +function refire_event(name) + return function (event) + if event.origin.username then + module:fire_event(name, event); + return true; + end + end; +end + +module:hook("stanza/"..xmlns_csi..":active", refire_event("csi-client-active")); +module:hook("stanza/"..xmlns_csi..":inactive", refire_event("csi-client-inactive")); + diff --git a/plugins/mod_disco.lua b/plugins/mod_disco.lua index 6d3e7753..a5479f4c 100644 --- a/plugins/mod_disco.lua +++ b/plugins/mod_disco.lua @@ -95,9 +95,8 @@ module:hook("item-removed/feature", clear_disco_cache); module:hook("item-removed/extension", clear_disco_cache); -- Handle disco requests to the server -module:hook("iq/host/http://jabber.org/protocol/disco#info:query", function(event) +module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type ~= "get" then return; end local node = stanza.tags[1].attr.node; if node and node ~= "" and node ~= "http://prosody.im#"..get_server_caps_hash() then local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info', node=node}); @@ -117,9 +116,8 @@ module:hook("iq/host/http://jabber.org/protocol/disco#info:query", function(even origin.send(reply); return true; end); -module:hook("iq/host/http://jabber.org/protocol/disco#items:query", function(event) +module:hook("iq-get/host/http://jabber.org/protocol/disco#items:query", function(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type ~= "get" then return; end local node = stanza.tags[1].attr.node; if node and node ~= "" then local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#items', node=node}); @@ -155,9 +153,8 @@ end); -- Handle disco requests to user accounts if module:get_host_type() ~= "local" then return end -- skip for components -module:hook("iq/bare/http://jabber.org/protocol/disco#info:query", function(event) +module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type ~= "get" then return; end local node = stanza.tags[1].attr.node; local username = jid_split(stanza.attr.to) or origin.username; if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then @@ -182,9 +179,8 @@ module:hook("iq/bare/http://jabber.org/protocol/disco#info:query", function(even return true; end end); -module:hook("iq/bare/http://jabber.org/protocol/disco#items:query", function(event) +module:hook("iq-get/bare/http://jabber.org/protocol/disco#items:query", function(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type ~= "get" then return; end local node = stanza.tags[1].attr.node; local username = jid_split(stanza.attr.to) or origin.username; if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then diff --git a/plugins/mod_groups.lua b/plugins/mod_groups.lua index 71a5271a..646b7408 100644 --- a/plugins/mod_groups.lua +++ b/plugins/mod_groups.lua @@ -10,8 +10,8 @@ local groups; local members; -local jid, datamanager = require "util.jid", require "util.datamanager"; -local jid_prep = jid.prep; +local datamanager = require "util.datamanager"; +local jid_prep = require "util.jid".prep; local module_host = module:get_host(); diff --git a/plugins/mod_http.lua b/plugins/mod_http.lua index e678e663..19f1e2fa 100644 --- a/plugins/mod_http.lua +++ b/plugins/mod_http.lua @@ -42,7 +42,11 @@ local function get_http_event(host, app_path, key) if app_path == "/" and path:sub(1,1) == "/" then app_path = ""; end - return method:upper().." "..host..app_path..path; + if host == "*" then + return method:upper().." "..app_path..path; + else + return method:upper().." "..host..app_path..path; + end end local function get_base_path(host_module, app_name, default_app_path) @@ -54,6 +58,9 @@ end local function redir_handler(event) event.response.headers.location = event.request.path.."/"; + if event.request.url.query then + event.response.headers.location = event.response.headers.location .. "?" .. event.request.url.query + end return 301; end @@ -68,10 +75,10 @@ function moduleapi.http_url(module, app_name, default_path) end local services = portmanager.get_active_services(); local http_services = services:get("https") or services:get("http") or {}; - for interface, ports in pairs(http_services) do - for port, services in pairs(ports) do + for interface, ports in pairs(http_services) do -- luacheck: ignore 213/interface + for port, service in pairs(ports) do -- luacheck: ignore 512 local url = { - scheme = (external_url.scheme or services[1].service.name); + scheme = (external_url.scheme or service[1].service.name); host = (external_url.host or module:get_option_string("http_host", module.host)); port = tonumber(external_url.port) or port or 80; path = normalize_path(external_url.path or "/", true).. @@ -86,7 +93,10 @@ function moduleapi.http_url(module, app_name, default_path) end function module.add_host(module) - local host = module:get_option_string("http_host", module.host); + local host = module.host; + if host ~= "*" then + host = module:get_option_string("http_host", host); + end local apps = {}; module.environment.apps = apps; local function http_app_added(event) @@ -109,9 +119,9 @@ function module.add_host(module) elseif event_name:sub(-2, -1) == "/*" then local base_path_len = #event_name:match("/.+$"); local _handler = handler; - handler = function (event) - local path = event.request.path:sub(base_path_len); - return _handler(event, path); + handler = function (_event) + local path = _event.request.path:sub(base_path_len); + return _handler(_event, path); end; module:hook_object_event(server, event_name:sub(1, -3), redir_handler, -1); elseif event_name:sub(-1, -1) == "/" then @@ -124,7 +134,7 @@ function module.add_host(module) module:log("warn", "App %s added handler twice for '%s', ignoring", app_name, event_name); end else - module:log("error", "Invalid route in %s, %q. See http://prosody.im/doc/developers/http#routes", app_name, key); + module:log("error", "Invalid route in %s, %q. See https://prosody.im/doc/developers/http#routes", app_name, key); end end local services = portmanager.get_active_services(); @@ -138,19 +148,48 @@ function module.add_host(module) local function http_app_removed(event) local app_handlers = apps[event.item.name]; apps[event.item.name] = nil; - for event, handler in pairs(app_handlers) do - module:unhook_object_event(server, event, handler); + for event_name, handler in pairs(app_handlers) do + module:unhook_object_event(server, event_name, handler); end end module:handle_items("http-provider", http_app_added, http_app_removed); - server.add_host(host); - function module.unload() - server.remove_host(host); + if host ~= "*" then + server.add_host(host); + function module.unload() + server.remove_host(host); + end + end +end + +module.add_host(module); -- set up handling on global context too + +local trusted_proxies = module:get_option_set("trusted_proxies", { "127.0.0.1", "::1" })._items; + +local function get_ip_from_request(request) + local ip = request.conn:ip(); + local forwarded_for = request.headers.x_forwarded_for; + if forwarded_for then + forwarded_for = forwarded_for..", "..ip; + for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do + if not trusted_proxies[forwarded_ip] then + ip = forwarded_ip; + end + end end + return ip; end +module:wrap_object_event(server._events, false, function (handlers, event_name, event_data) + local request = event_data.request; + if request then + -- Not included in eg http-error events + request.ip = get_ip_from_request(request); + end + return handlers(event_name, event_data); +end); + module:provides("net", { name = "http"; listener = server.listener; diff --git a/plugins/mod_http_files.lua b/plugins/mod_http_files.lua index c5986e37..1dae0d6d 100644 --- a/plugins/mod_http_files.lua +++ b/plugins/mod_http_files.lua @@ -89,6 +89,7 @@ function serve(opts) if type(opts) ~= "table" then -- assume path string opts = { path = opts }; end + -- luacheck: ignore 431 local base_path = opts.path; local dir_indices = opts.index_files or dir_indices; local directory_index = opts.directory_index; @@ -127,9 +128,9 @@ function serve(opts) data = data.data; elseif attr.mode == "directory" and path then if full_path:sub(-1) ~= "/" then - local path = { is_absolute = true, is_directory = true }; - for dir in orig_path:gmatch("[^/]+") do path[#path+1]=dir; end - response_headers.location = build_path(path); + local dir_path = { is_absolute = true, is_directory = true }; + for dir in orig_path:gmatch("[^/]+") do dir_path[#dir_path+1]=dir; end + response_headers.location = build_path(dir_path); return 301; end for i=1,#dir_indices do diff --git a/plugins/mod_iq.lua b/plugins/mod_iq.lua index c6d62e85..87c3a467 100644 --- a/plugins/mod_iq.lua +++ b/plugins/mod_iq.lua @@ -13,7 +13,7 @@ local full_sessions = prosody.full_sessions; if module:get_host_type() == "local" then module:hook("iq/full", function(data) - -- IQ to full JID recieved + -- IQ to full JID received local origin, stanza = data.origin, data.stanza; local session = full_sessions[stanza.attr.to]; @@ -27,7 +27,7 @@ if module:get_host_type() == "local" then end module:hook("iq/bare", function(data) - -- IQ to bare JID recieved + -- IQ to bare JID received local stanza = data.stanza; local type = stanza.attr.type; @@ -44,7 +44,7 @@ module:hook("iq/bare", function(data) end); module:hook("iq/self", function(data) - -- IQ to self JID recieved + -- IQ to self JID received local stanza = data.stanza; local type = stanza.attr.type; @@ -60,7 +60,7 @@ module:hook("iq/self", function(data) end); module:hook("iq/host", function(data) - -- IQ to a local host recieved + -- IQ to a local host received local stanza = data.stanza; local type = stanza.attr.type; diff --git a/plugins/mod_lastactivity.lua b/plugins/mod_lastactivity.lua index 2dd61699..575e66be 100644 --- a/plugins/mod_lastactivity.lua +++ b/plugins/mod_lastactivity.lua @@ -24,22 +24,20 @@ module:hook("pre-presence/bare", function(event) end end, 10); -module:hook("iq/bare/jabber:iq:last:query", function(event) +module:hook("iq-get/bare/jabber:iq:last:query", function(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type == "get" then - local username = jid_split(stanza.attr.to) or origin.username; - if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then - local seconds, text = "0", ""; - if map[username] then - seconds = tostring(os.difftime(os.time(), map[username].t)); - text = map[username].s; - end - origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=seconds}):text(text)); - else - origin.send(st.error_reply(stanza, 'auth', 'forbidden')); + local username = jid_split(stanza.attr.to) or origin.username; + if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then + local seconds, text = "0", ""; + if map[username] then + seconds = tostring(os.difftime(os.time(), map[username].t)); + text = map[username].s; end - return true; + origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=seconds}):text(text)); + else + origin.send(st.error_reply(stanza, 'auth', 'forbidden')); end + return true; end); module.save = function() diff --git a/plugins/mod_legacyauth.lua b/plugins/mod_legacyauth.lua index 5edc26bb..0f41d3e7 100644 --- a/plugins/mod_legacyauth.lua +++ b/plugins/mod_legacyauth.lua @@ -35,7 +35,8 @@ module:hook("stanza/iq/jabber:iq:auth:query", function(event) local session, stanza = event.origin, event.stanza; if session.type ~= "c2s_unauthed" then - (session.sends2s or session.send)(st.error_reply(stanza, "cancel", "service-unavailable", "Legacy authentication is only allowed for unauthenticated client connections.")); + (session.sends2s or session.send)(st.error_reply(stanza, "cancel", "service-unavailable", + "Legacy authentication is only allowed for unauthenticated client connections.")); return true; end diff --git a/plugins/mod_limits.lua b/plugins/mod_limits.lua index 3fc3fcaa..914d5c44 100644 --- a/plugins/mod_limits.lua +++ b/plugins/mod_limits.lua @@ -51,18 +51,18 @@ end local default_filter_set = {}; function default_filter_set.bytes_in(bytes, session) - local throttle = session.throttle; - if throttle then - local ok, balance, outstanding = throttle:poll(#bytes, true); + local sess_throttle = session.throttle; + if sess_throttle then + local ok, balance, outstanding = sess_throttle:poll(#bytes, true); if not ok then - session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", throttle.max, #bytes, outstanding); + session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding); outstanding = ceil(outstanding); session.conn:pause(); -- Read no more data from the connection until there is no outstanding data local outstanding_data = bytes:sub(-outstanding); bytes = bytes:sub(1, #bytes-outstanding); timer.add_task(limits_resolution, function () if not session.conn then return; end - if throttle:peek(#outstanding_data) then + if sess_throttle:peek(#outstanding_data) then session.log("debug", "Resuming paused session"); session.conn:resume(); end diff --git a/plugins/mod_message.lua b/plugins/mod_message.lua index 0d370ec1..4b8154e0 100644 --- a/plugins/mod_message.lua +++ b/plugins/mod_message.lua @@ -63,7 +63,7 @@ local function process_to_bare(bare, origin, stanza) end module:hook("message/full", function(data) - -- message to full JID recieved + -- message to full JID received local origin, stanza = data.origin, data.stanza; local session = full_sessions[stanza.attr.to]; @@ -75,7 +75,7 @@ module:hook("message/full", function(data) end, -1); module:hook("message/bare", function(data) - -- message to bare JID recieved + -- message to bare JID received local origin, stanza = data.origin, data.stanza; return process_to_bare(stanza.attr.to or (origin.username..'@'..origin.host), origin, stanza); diff --git a/plugins/mod_muc_mam.lua b/plugins/mod_muc_mam.lua new file mode 100644 index 00000000..3013bb49 --- /dev/null +++ b/plugins/mod_muc_mam.lua @@ -0,0 +1,385 @@ +-- XEP-0313: Message Archive Management for Prosody MUC +-- Copyright (C) 2011-2017 Kim Alvefur +-- +-- This file is MIT/X11 licensed. + +if module:get_host_type() ~= "component" then + module:log("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name); + return; +end + +local xmlns_mam = "urn:xmpp:mam:2"; +local xmlns_delay = "urn:xmpp:delay"; +local xmlns_forward = "urn:xmpp:forward:0"; +local xmlns_st_id = "urn:xmpp:sid:0"; +local xmlns_muc_user = "http://jabber.org/protocol/muc#user"; +local muc_form_enable = "muc#roomconfig_enablearchiving" + +local st = require "util.stanza"; +local rsm = require "util.rsm"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local jid_prep = require "util.jid".prep; +local dataform = require "util.dataforms".new; + +local mod_muc = module:depends"muc"; +local get_room_from_jid = mod_muc.get_room_from_jid; + +local is_stanza = st.is_stanza; +local tostring = tostring; +local time_now = os.time; +local m_min = math.min; +local timestamp, timestamp_parse = require "util.datetime".datetime, require "util.datetime".parse; +local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50); + +local default_history_length = 20; +local max_history_length = module:get_option_number("max_history_messages", math.huge); + +local function get_historylength(room) + return math.min(room._data.history_length or default_history_length, max_history_length); +end + +local log_all_rooms = module:get_option_boolean("muc_log_all_rooms", false); +local log_by_default = module:get_option_boolean("muc_log_by_default", true); + +local archive_store = "muc_log"; +local archive = module:open_store(archive_store, "archive"); + +if archive.name == "null" or not archive.find then + if not archive.find then + module:log("error", "Attempt to open archive storage returned a driver without archive API support"); + module:log("error", "mod_%s does not support archiving", + archive._provided_by or archive.name and "storage_"..archive.name.."(?)" or "<unknown>"); + else + module:log("error", "Attempt to open archive storage returned null driver"); + end + module:log("info", "See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information"); + return false; +end + +local function archiving_enabled(room) + if log_all_rooms then + return true; + end + local enabled = room._data.archiving; + if enabled == nil then + return log_by_default; + end + return enabled; +end + +if not log_all_rooms then + module:hook("muc-config-form", function(event) + local room, form = event.room, event.form; + table.insert(form, + { + name = muc_form_enable, + type = "boolean", + label = "Enable archiving?", + value = archiving_enabled(room), + } + ); + end); + + module:hook("muc-config-submitted/"..muc_form_enable, function(event) + event.room._data.archiving = event.value; + event.status_codes[event.value and "170" or "171"] = true; + end); +end + +-- Note: We ignore the 'with' field as this is internally used for stanza types +local query_form = dataform { + { name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam; }; + { name = "with"; type = "jid-single"; }; + { name = "start"; type = "text-single" }; + { name = "end"; type = "text-single"; }; +}; + +-- Serve form +module:hook("iq-get/bare/"..xmlns_mam..":query", function(event) + local origin, stanza = event.origin, event.stanza; + origin.send(st.reply(stanza):add_child(query_form:form())); + return true; +end); + +-- Handle archive queries +module:hook("iq-set/bare/"..xmlns_mam..":query", function(event) + local origin, stanza = event.origin, event.stanza; + local room_jid = stanza.attr.to; + local room_node = jid_split(room_jid); + local orig_from = stanza.attr.from; + local query = stanza.tags[1]; + + local room = get_room_from_jid(room_jid); + if not room then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")) + return true; + end + local from = jid_bare(orig_from); + + -- Banned or not a member of a members-only room? + local from_affiliation = room:get_affiliation(from); + if from_affiliation == "outcast" -- banned + or room:get_members_only() and not from_affiliation then -- members-only, not a member + origin.send(st.error_reply(stanza, "auth", "forbidden")) + return true; + end + + local qid = query.attr.queryid; + + -- Search query parameters + local qstart, qend; + local form = query:get_child("x", "jabber:x:data"); + if form then + local err; + form, err = query_form:data(form); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err)))); + return true; + end + qstart, qend = form["start"], form["end"]; + end + + if qstart or qend then -- Validate timestamps + local vstart, vend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend)) + if (qstart and not vstart) or (qend and not vend) then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp")) + return true; + end + qstart, qend = vstart, vend; + end + + module:log("debug", "Archive query id %s from %s until %s)", + tostring(qid), + qstart and timestamp(qstart) or "the dawn of time", + qend and timestamp(qend) or "now"); + + -- RSM stuff + local qset = rsm.get(query); + local qmax = m_min(qset and qset.max or default_max_items, max_max_items); + local reverse = qset and qset.before or false; + + local before, after = qset and qset.before, qset and qset.after; + if type(before) ~= "string" then before = nil; end + + -- Load all the data! + local data, err = archive:find(room_node, { + start = qstart; ["end"] = qend; -- Time range + limit = qmax + 1; + before = before; after = after; + reverse = reverse; + with = "message<groupchat"; + }); + + if not data then + origin.send(st.error_reply(stanza, "cancel", "internal-server-error")); + return true; + end + local total = tonumber(err); + + local msg_reply_attr = { to = stanza.attr.from, from = stanza.attr.to }; + + local results = {}; + + -- Wrap it in stuff and deliver + local first, last; + local count = 0; + local complete = "true"; + for id, item, when in data do + count = count + 1; + if count > qmax then + complete = nil; + break; + end + local fwd_st = st.message(msg_reply_attr) + :tag("result", { xmlns = xmlns_mam, queryid = qid, id = id }) + :tag("forwarded", { xmlns = xmlns_forward }) + :tag("delay", { xmlns = xmlns_delay, stamp = timestamp(when) }):up(); + + -- Strip <x> tag, containing the original senders JID, unless the room makes this public + if room:get_whois() ~= "anyone" then + item:maptags(function (tag) + if tag.name == "x" and tag.attr.xmlns == xmlns_muc_user then + return nil; + end + return tag; + end); + end + if not is_stanza(item) then + item = st.deserialize(item); + end + item.attr.xmlns = "jabber:client"; + fwd_st:add_child(item); + + if not first then first = id; end + last = id; + + if reverse then + results[count] = fwd_st; + else + origin.send(fwd_st); + end + end + + if reverse then + for i = #results, 1, -1 do + origin.send(results[i]); + end + first, last = last, first; + end + + -- That's all folks! + module:log("debug", "Archive query %s completed", tostring(qid)); + + origin.send(st.reply(stanza) + :tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete }) + :add_child(rsm.generate { + first = first, last = last, count = total })); + return true; +end); + +module:hook("muc-get-history", function (event) + local room = event.room; + if not archiving_enabled(room) then return end + local room_jid = room.jid; + local maxstanzas = event.maxstanzas; + local maxchars = event.maxchars; + local since = event.since; + local to = event.to; + + if maxstanzas == 0 or maxchars == 0 then + return -- No history requested + end + + if not maxstanzas or maxstanzas > get_historylength(room) then + maxstanzas = get_historylength(room); + end + + if room._history and #room._history >= maxstanzas then + return -- It can deal with this itself + end + + -- Load all the data! + local query = { + limit = maxstanzas; + start = since; + reverse = true; + with = "message<groupchat"; + } + local data, err = archive:find(jid_split(room_jid), query); + + if not data then + module:log("error", "Could not fetch history: %s", tostring(err)); + return + end + + local history, i = {}, 1; + + for id, item, when in data do + item.attr.to = to; + item:tag("delay", { xmlns = "urn:xmpp:delay", from = room_jid, stamp = timestamp(when) }):up(); -- XEP-0203 + item:tag("stanza-id", { xmlns = xmlns_st_id, by = room_jid, id = id }):up(); + if room:get_whois() ~= "anyone" then + item:maptags(function (tag) + if tag.name == "x" and tag.attr.xmlns == xmlns_muc_user then + return nil; + end + return tag; + end); + end + if maxchars then + local chars = #tostring(item); + if maxchars - chars < 0 then + break + end + maxchars = maxchars - chars; + end + history[i], i = item, i+1; + -- module:log("debug", tostring(item)); + end + function event.next_stanza() + i = i - 1; + return history[i]; + end + return true; +end, 1); + +module:hook("muc-broadcast-messages", function (event) + local room, stanza = event.room, event.stanza; + + -- Filter out <stanza-id> that claim to be from us + stanza:maptags(function (tag) + if tag.name == "stanza-id" and tag.attr.xmlns == xmlns_st_id + and jid_prep(tag.attr.by) == room.jid then + return nil; + end + if tag.name == "x" and tag.attr.xmlns == xmlns_muc_user then + return nil; + end + return tag; + end); + +end, 0); + +-- Handle messages +local function save_to_history(self, stanza) + local room_node, room_host = jid_split(self.jid); + + local stored_stanza = stanza; + + if stanza.name == "message" and self:get_whois() == "anyone" then + stored_stanza = st.clone(stanza); + local actor = jid_bare(self._occupants[stanza.attr.from].jid); + local affiliation = self:get_affiliation(actor) or "none"; + local role = self:get_role(actor) or self:get_default_role(affiliation); + stored_stanza:add_direct_child(st.stanza("x", { xmlns = xmlns_muc_user }) + :tag("item", { affiliation = affiliation; role = role; jid = actor })); + end + + -- Policy check + if not archiving_enabled(self) then return end -- Don't log + + -- And stash it + local with = stanza.name + if stanza.attr.type then + with = with .. "<" .. stanza.attr.type + end + + local id = archive:append(room_node, nil, stored_stanza, time_now(), with); + + if id then + stanza:add_direct_child(st.stanza("stanza-id", { xmlns = xmlns_st_id, by = self.jid, id = id })); + end +end + +module:hook("muc-add-history", function (event) + local room, stanza = event.room, event.stanza; + save_to_history(room, stanza); +end); + +if module:get_option_boolean("muc_log_presences", false) then + module:hook("muc-occupant-joined", function (event) + save_to_history(event.room, st.stanza("presence", { from = event.nick }):tag("x", { xmlns = "http://jabber.org/protocol/muc" })); + end); + module:hook("muc-occupant-left", function (event) + save_to_history(event.room, st.stanza("presence", { type = "unavailable", from = event.nick })); + end); +end + +if not archive.delete then + module:log("warn", "Storage driver %s does not support deletion", archive._provided_by); + module:log("warn", "Archived message will persist after a room has been destroyed"); +else + module:hook("muc-room-destroyed", function(event) + local room_node = jid_split(event.room.jid); + archive:delete(room_node); + end); +end + +-- And role/affiliation changes? + +module:add_feature(xmlns_mam); + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var=xmlns_mam}):up(); +end); diff --git a/plugins/mod_muc_unique.lua b/plugins/mod_muc_unique.lua new file mode 100644 index 00000000..13284745 --- /dev/null +++ b/plugins/mod_muc_unique.lua @@ -0,0 +1,12 @@ +-- XEP-0307: Unique Room Names for Multi-User Chat +local st = require "util.stanza"; +local unique_name = require "util.id".medium; +module:add_feature "http://jabber.org/protocol/muc#unique" +module:hook("iq-get/host/http://jabber.org/protocol/muc#unique:unique", function(event) + local origin, stanza = event.origin, event.stanza; + origin.send(st.reply(stanza) + :tag("unique", {xmlns = "http://jabber.org/protocol/muc#unique"}) + :text(unique_name():lower()) + ); + return true; +end,-1); diff --git a/plugins/mod_net_multiplex.lua b/plugins/mod_net_multiplex.lua index 6db49391..8ef77883 100644 --- a/plugins/mod_net_multiplex.lua +++ b/plugins/mod_net_multiplex.lua @@ -18,7 +18,7 @@ end module:hook("service-added", function (event) add_service(event.service); end); module:hook("service-removed", function (event) available_services[event.service] = nil; end); -for service_name, services in pairs(portmanager.get_registered_services()) do +for _, services in pairs(portmanager.get_registered_services()) do for _, service in ipairs(services) do add_service(service); end @@ -38,11 +38,11 @@ function listener.onincoming(conn, data) for service, multiplex_pattern in pairs(available_services) do if buf:match(multiplex_pattern) then module:log("debug", "Routing incoming connection to %s", service.name); - local listener = service.listener; - conn:setlistener(listener); - local onconnect = listener.onconnect; + local next_listener = service.listener; + conn:setlistener(next_listener); + local onconnect = next_listener.onconnect; if onconnect then onconnect(conn) end - return listener.onincoming(conn, buf); + return next_listener.onincoming(conn, buf); end end if #buf > max_buffer_len then -- Give up @@ -52,7 +52,7 @@ function listener.onincoming(conn, data) end end -function listener.ondisconnect(conn, err) +function listener.ondisconnect(conn) buffers[conn] = nil; -- warn if no buffer? end diff --git a/plugins/mod_pep.lua b/plugins/mod_pep.lua index 1025be37..0d52a2fd 100644 --- a/plugins/mod_pep.lua +++ b/plugins/mod_pep.lua @@ -1,318 +1,453 @@ --- Prosody IM --- Copyright (C) 2008-2010 Matthew Wild --- Copyright (C) 2008-2010 Waqas Hussain --- --- This project is MIT/X11 licensed. Please see the --- COPYING file in the source package for more information. --- - - +local pubsub = require "util.pubsub"; local jid_bare = require "util.jid".bare; local jid_split = require "util.jid".split; +local jid_join = require "util.jid".join; +local set_new = require "util.set".new; local st = require "util.stanza"; -local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; -local pairs = pairs; -local next = next; -local type = type; local calculate_hash = require "util.caps".calculate_hash; -local core_post_stanza = prosody.core_post_stanza; -local bare_sessions = prosody.bare_sessions; - --- Used as canonical 'empty table' -local NULL = {}; --- data[user_bare_jid][node] = item_stanza -local data = {}; ---- recipients[user_bare_jid][contact_full_jid][subscribed_node] = true +local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; +local cache = require "util.cache"; +local set = require "util.set"; + +local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; +local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event"; +local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner"; + +local lib_pubsub = module:require "pubsub"; + +local empty_set = set_new(); + +-- username -> util.pubsub service object +local services = {}; + +-- username -> recipient -> set of nodes local recipients = {}; --- hash_map[hash][subscribed_nodes] = true + +-- caps hash -> set of nodes local hash_map = {}; -module.save = function() - return { data = data, recipients = recipients, hash_map = hash_map }; +local host = module.host; + +local node_config = module:open_store("pep", "map"); +local known_nodes = module:open_store("pep"); + +local max_max_items = module:get_option_number("pep_max_items", 256); + +function module.save() + return { + services = services; + recipients = recipients; + }; +end + +function module.restore(data) + services = data.services; + recipients = data.recipients; end -module.restore = function(state) - data = state.data or {}; - recipients = state.recipients or {}; - hash_map = state.hash_map or {}; + +function is_item_stanza(item) + return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item"; end -module:add_identity("pubsub", "pep", module:get_option_string("name", "Prosody")); -module:add_feature("http://jabber.org/protocol/pubsub#publish"); +function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node 212/actor + if (new_config["max_items"] or 1) > max_max_items then + return false; + end + if new_config["access_model"] ~= "presence" + and new_config["access_model"] ~= "whitelist" + and new_config["access_model"] ~= "open" then + return false; + end + return true; +end -local function subscription_presence(user_bare, recipient) +local function subscription_presence(username, recipient) + local user_bare = jid_join(username, host); local recipient_bare = jid_bare(recipient); - if (recipient_bare == user_bare) then return true end - local username, host = jid_split(user_bare); + if (recipient_bare == user_bare) then return true; end return is_contact_subscribed(username, host, recipient_bare); end -module:hook("pep-publish-item", function (event) - local session, bare, node, id, item = event.session, event.user, event.node, event.id, event.item; - item.attr.xmlns = nil; - local disable = #item.tags ~= 1 or #item.tags[1] == 0; - if #item.tags == 0 then item.name = "retract"; end - local stanza = st.message({from=bare, type='headline'}) - :tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'}) - :tag('items', {node=node}) - :add_child(item) - :up() - :up(); - - -- store for the future - local user_data = data[bare]; - if disable then - if user_data then - user_data[node] = nil; - if not next(user_data) then data[bare] = nil; end +local function nodestore(username) + -- luacheck: ignore 212/self + local store = {}; + function store:get(node) + local data, err = node_config:get(username, node) + if data == true then + -- COMPAT Previously stored only a boolean representing 'persist_items' + data = { + name = node; + config = {}; + subscribers = {}; + affiliations = {}; + }; + end + return data, err; + end + function store:set(node, data) + if data then + -- Save the data without subscriptions + local subscribers = {}; + for jid, sub in pairs(data.subscribers) do + if type(sub) ~= "table" or not sub.presence then + subscribers[jid] = sub; + end + end + data = { + name = data.name; + config = data.config; + affiliations = data.affiliations; + subscribers = subscribers; + }; + end + return node_config:set(username, node, data); + end + function store:users() + return pairs(known_nodes:get(username) or {}); + end + return store; +end + +local function simple_itemstore(username) + return function (config, node) + if config["persist_items"] then + module:log("debug", "Creating new persistent item store for user %s, node %q", username, node); + local archive = module:open_store("pep_"..node, "archive"); + return lib_pubsub.archive_itemstore(archive, config, username, node, false); + else + module:log("debug", "Creating new ephemeral item store for user %s, node %q", username, node); + return cache.new(tonumber(config["max_items"])); end - else - if not user_data then user_data = {}; data[bare] = user_data; end - user_data[node] = {id, item}; end +end - -- broadcast - for recipient, notify in pairs(recipients[bare] or NULL) do - if notify[node] then - stanza.attr.to = recipient; - core_post_stanza(session, stanza); +local function get_broadcaster(username) + local user_bare = jid_join(username, host); + local function simple_broadcast(kind, node, jids, item, _, node_obj) + if node_obj then + if node_obj.config["notify_"..kind] == false then + return; + end + end + if kind == "retract" then + kind = "items"; -- XEP-0060 signals retraction in an <items> container + end + local message = st.message({ from = user_bare, type = "headline" }) + :tag("event", { xmlns = xmlns_pubsub_event }) + :tag(kind, { node = node }); + if item then + item = st.clone(item); + item.attr.xmlns = nil; -- Clear the pubsub namespace + if kind == "items" then + if node_obj and node_obj.config.include_payload == false then + item:maptags(function () return nil; end); + end + end + message:add_child(item); + end + for jid in pairs(jids) do + module:log("debug", "Sending notification to %s from %s: %s", jid, user_bare, tostring(item)); + message.attr.to = jid; + module:send(message); end end -end); + return simple_broadcast; +end + +function get_pep_service(username) + module:log("debug", "get_pep_service(%q)", username); + local user_bare = jid_join(username, host); + local service = services[username]; + if service then + return service; + end + service = pubsub.new({ + node_defaults = { + ["max_items"] = 1; + ["persist_items"] = true; + ["access_model"] = "presence"; + }; + + autocreate_on_publish = true; + autocreate_on_subscribe = true; -local function publish_all(user, recipient, session) - local d = data[user]; - local notify = recipients[user] and recipients[user][recipient]; - if d and notify then - for node in pairs(notify) do - if d[node] then - local id, item = unpack(d[node]); - session.send(st.message({from=user, to=recipient, type='headline'}) - :tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'}) - :tag('items', {node=node}) - :add_child(item) - :up() - :up()); + nodestore = nodestore(username); + itemstore = simple_itemstore(username); + broadcaster = get_broadcaster(username); + itemcheck = is_item_stanza; + get_affiliation = function (jid) + if jid_bare(jid) == user_bare then + return "owner"; end + end; + + access_models = { + presence = function (jid) + if subscription_presence(username, jid) then + return "member"; + end + return "outcast"; + end; + }; + + normalize_jid = jid_bare; + + check_node_config = check_node_config; + }); + local nodes, err = known_nodes:get(username); + if nodes then + module:log("debug", "Restoring nodes for user %s", username); + for node in pairs(nodes) do + module:log("debug", "Restoring node %q", node); + service:create(node, true); end + elseif err then + module:log("error", "Could not restore nodes for %s: %s", username, err); + else + module:log("debug", "No known nodes"); end + services[username] = service; + module:add_item("pep-service", { service = service, jid = user_bare }); + return service; end +function handle_pubsub_iq(event) + local origin, stanza = event.origin, event.stanza; + local service_name = origin.username; + if stanza.attr.to ~= nil then + service_name = jid_split(stanza.attr.to); + end + local service = get_pep_service(service_name); + + return lib_pubsub.handle_pubsub_iq(event, service) +end + +module:hook("iq/bare/"..xmlns_pubsub..":pubsub", handle_pubsub_iq); +module:hook("iq/bare/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq); + +module:add_identity("pubsub", "pep", module:get_option_string("name", "Prosody")); +module:add_feature("http://jabber.org/protocol/pubsub#publish"); + local function get_caps_hash_from_presence(stanza, current) local t = stanza.attr.type; if not t then - for _, child in pairs(stanza.tags) do - if child.name == "c" and child.attr.xmlns == "http://jabber.org/protocol/caps" then - local attr = child.attr; - if attr.hash then -- new caps - if attr.hash == 'sha-1' and attr.node and attr.ver then return attr.ver, attr.node.."#"..attr.ver; end - else -- legacy caps - if attr.node and attr.ver then return attr.node.."#"..attr.ver.."#"..(attr.ext or ""), attr.node.."#"..attr.ver; end + local child = stanza:get_child("c", "http://jabber.org/protocol/caps"); + if child then + local attr = child.attr; + if attr.hash then -- new caps + if attr.hash == 'sha-1' and attr.node and attr.ver then + return attr.ver, attr.node.."#"..attr.ver; + end + else -- legacy caps + if attr.node and attr.ver then + return attr.node.."#"..attr.ver.."#"..(attr.ext or ""), attr.node.."#"..attr.ver; end - return; -- bad caps format end end + return; -- no or bad caps elseif t == "unavailable" or t == "error" then return; end return current; -- no caps, could mean caps optimization, so return current end +local function resend_last_item(jid, node, service) + local ok, id, item = service:get_last_item(node, jid); + if not (ok and id) then return; end + service.config.broadcaster("items", node, { [jid] = true }, item); +end + +local function update_subscriptions(recipient, service_name, nodes) + nodes = nodes or empty_set; + + local service_recipients = recipients[service_name]; + if not service_recipients then + service_recipients = {}; + recipients[service_name] = service_recipients; + end + + local current = service_recipients[recipient]; + if not current or type(current) ~= "table" then + current = empty_set; + end + + if (current == empty_set or current:empty()) and (nodes == empty_set or nodes:empty()) then + return; + end + + local service = get_pep_service(service_name); + for node in current - nodes do + service:remove_subscription(node, recipient, recipient); + end + + for node in nodes - current do + if service:add_subscription(node, recipient, recipient, { presence = true }) then + resend_last_item(recipient, node, service); + end + end + + if nodes == empty_set or nodes:empty() then + nodes = nil; + end + + service_recipients[recipient] = nodes; +end + module:hook("presence/bare", function(event) - -- inbound presence to bare JID recieved + -- inbound presence to bare JID received local origin, stanza = event.origin, event.stanza; - local user = stanza.attr.to or (origin.username..'@'..origin.host); local t = stanza.attr.type; - local self = not stanza.attr.to; - - -- Only cache subscriptions if user is online - if not bare_sessions[user] then return; end + local is_self = not stanza.attr.to; + local username = jid_split(stanza.attr.to); + local user_bare = jid_bare(stanza.attr.to); + if is_self then + username = origin.username; + user_bare = jid_join(username, host); + end if not t then -- available presence - if self or subscription_presence(user, stanza.attr.from) then + if is_self or subscription_presence(username, stanza.attr.from) then local recipient = stanza.attr.from; - local current = recipients[user] and recipients[user][recipient]; - local hash = get_caps_hash_from_presence(stanza, current); + local current = recipients[username] and recipients[username][recipient]; + local hash, query_node = get_caps_hash_from_presence(stanza, current); if current == hash or (current and current == hash_map[hash]) then return; end if not hash then - if recipients[user] then recipients[user][recipient] = nil; end + update_subscriptions(recipient, username); else - recipients[user] = recipients[user] or {}; + recipients[username] = recipients[username] or {}; if hash_map[hash] then - recipients[user][recipient] = hash_map[hash]; - publish_all(user, recipient, origin); + update_subscriptions(recipient, username, hash_map[hash]); else - recipients[user][recipient] = hash; + recipients[username][recipient] = hash; local from_bare = origin.type == "c2s" and origin.username.."@"..origin.host; - if self or origin.type ~= "c2s" or (recipients[from_bare] and recipients[from_bare][origin.full_jid]) ~= hash then - -- COMPAT from ~= stanza.attr.to because OneTeam and Asterisk 1.8 can't deal with missing from attribute + if is_self or origin.type ~= "c2s" or (recipients[from_bare] and recipients[from_bare][origin.full_jid]) ~= hash then + -- COMPAT from ~= stanza.attr.to because OneTeam can't deal with missing from attribute origin.send( - st.stanza("iq", {from=user, to=stanza.attr.from, id="disco", type="get"}) - :query("http://jabber.org/protocol/disco#info") + st.stanza("iq", {from=user_bare, to=stanza.attr.from, id="disco", type="get"}) + :tag("query", {xmlns = "http://jabber.org/protocol/disco#info", node = query_node}) ); end end end end elseif t == "unavailable" then - if recipients[user] then recipients[user][stanza.attr.from] = nil; end - elseif not self and t == "unsubscribe" then + update_subscriptions(stanza.attr.from, username); + elseif not is_self and t == "unsubscribe" then local from = jid_bare(stanza.attr.from); - local subscriptions = recipients[user]; + local subscriptions = recipients[username]; if subscriptions then for subscriber in pairs(subscriptions) do if jid_bare(subscriber) == from then - recipients[user][subscriber] = nil; + update_subscriptions(subscriber, username); end end end end end, 10); -module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function(event) - local session, stanza = event.origin, event.stanza; - local payload = stanza.tags[1]; - - if stanza.attr.type == 'set' and (not stanza.attr.to or jid_bare(stanza.attr.from) == stanza.attr.to) then - payload = payload.tags[1]; - if payload and (payload.name == 'publish' or payload.name == 'retract') and payload.attr.node then -- <publish node='http://jabber.org/protocol/tune'> - local node = payload.attr.node; - payload = payload.tags[1]; - if payload and payload.name == "item" then -- <item> - local id = payload.attr.id or "1"; - payload.attr.id = id; - session.send(st.reply(stanza)); - module:fire_event("pep-publish-item", { - node = node, user = jid_bare(session.full_jid), actor = session.jid, - id = id, session = session, item = st.clone(payload); - }); - return true; - else - module:log("debug", "Payload is missing the <item>", node); - end - else - module:log("debug", "Unhandled payload: %s", payload and payload:top_tag() or "(no payload)"); +module:hook("iq-result/bare/disco", function(event) + local origin, stanza = event.origin, event.stanza; + local disco = stanza:get_child("query", "http://jabber.org/protocol/disco#info"); + if not disco then + return; + end + + -- Process disco response + local is_self = stanza.attr.to == nil; + local user_bare = jid_bare(stanza.attr.to); + local username = jid_split(stanza.attr.to); + if is_self then + username = origin.username; + user_bare = jid_join(username, host); + end + local contact = stanza.attr.from; + local current = recipients[username] and recipients[username][contact]; + if type(current) ~= "string" then return; end -- check if waiting for recipient's response + local ver = current; + if not string.find(current, "#") then + ver = calculate_hash(disco.tags); -- calculate hash + end + local notify = set_new(); + for _, feature in pairs(disco.tags) do + if feature.name == "feature" and feature.attr.var then + local nfeature = feature.attr.var:match("^(.*)%+notify$"); + if nfeature then notify:add(nfeature); end end - elseif stanza.attr.type == 'get' then - local user = stanza.attr.to and jid_bare(stanza.attr.to) or session.username..'@'..session.host; - if subscription_presence(user, stanza.attr.from) then - local user_data = data[user]; - local node, requested_id; - payload = payload.tags[1]; - if payload and payload.name == 'items' then - node = payload.attr.node; - local item = payload.tags[1]; - if item and item.name == "item" then - requested_id = item.attr.id; - end - end - if node and user_data and user_data[node] then -- Send the last item - local id, item = unpack(user_data[node]); - if not requested_id or id == requested_id then - local stanza = st.reply(stanza) - :tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'}) - :tag('items', {node=node}) - :add_child(item) - :up() - :up(); - session.send(stanza); - return true; - else -- requested item doesn't exist - local stanza = st.reply(stanza) - :tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'}) - :tag('items', {node=node}) - :up(); - session.send(stanza); - return true; + end + hash_map[ver] = notify; -- update hash map + if is_self then + -- Optimization: Fiddle with other local users + for jid, item in pairs(origin.roster) do -- for all interested contacts + if jid then + local contact_node, contact_host = jid_split(jid); + if contact_host == host and (item.subscription == "both" or item.subscription == "from") then + update_subscriptions(user_bare, contact_node, notify); end - elseif node then -- node doesn't exist - session.send(st.error_reply(stanza, 'cancel', 'item-not-found')); - module:log("debug", "Item '%s' not found", node) - return true; - else --invalid request - session.send(st.error_reply(stanza, 'modify', 'bad-request')); - module:log("debug", "Invalid request: %s", tostring(payload)); - return true; end - else --no presence subscription - session.send(st.error_reply(stanza, 'auth', 'not-authorized') - :tag('presence-subscription-required', {xmlns='http://jabber.org/protocol/pubsub#errors'})); - module:log("debug", "Unauthorized request: %s", tostring(payload)); - return true; end end + update_subscriptions(contact, username, notify); end); -module:hook("iq-result/bare/disco", function(event) - local session, stanza = event.origin, event.stanza; - if stanza.attr.type == "result" then - local disco = stanza.tags[1]; - if disco and disco.name == "query" and disco.attr.xmlns == "http://jabber.org/protocol/disco#info" then - -- Process disco response - local self = not stanza.attr.to; - local user = stanza.attr.to or (session.username..'@'..session.host); - local contact = stanza.attr.from; - local current = recipients[user] and recipients[user][contact]; - if type(current) ~= "string" then return; end -- check if waiting for recipient's response - local ver = current; - if not string.find(current, "#") then - ver = calculate_hash(disco.tags); -- calculate hash - end - local notify = {}; - for _, feature in pairs(disco.tags) do - if feature.name == "feature" and feature.attr.var then - local nfeature = feature.attr.var:match("^(.*)%+notify$"); - if nfeature then notify[nfeature] = true; end - end - end - hash_map[ver] = notify; -- update hash map - if self then - for jid, item in pairs(session.roster) do -- for all interested contacts - if item.subscription == "both" or item.subscription == "from" then - if not recipients[jid] then recipients[jid] = {}; end - recipients[jid][contact] = notify; - publish_all(jid, contact, session); - end - end - end - recipients[user][contact] = notify; -- set recipient's data to calculated data - -- send messages to recipient - publish_all(user, contact, session); - end +module:hook("account-disco-info-node", function(event) + local stanza, origin = event.stanza, event.origin; + local service_name = origin.username; + if stanza.attr.to ~= nil then + service_name = jid_split(stanza.attr.to); end + local service = get_pep_service(service_name); + return lib_pubsub.handle_disco_info_node(event, service); end); module:hook("account-disco-info", function(event) - local reply = event.reply; + local origin, reply = event.origin, event.reply; + reply:tag('identity', {category='pubsub', type='pep'}):up(); - reply:tag('feature', {var='http://jabber.org/protocol/pubsub#publish'}):up(); -end); -module:hook("account-disco-items", function(event) - local reply = event.reply; - local bare = reply.attr.to; - local user_data = data[bare]; + local username = jid_split(reply.attr.from) or origin.username; + local service = get_pep_service(username); - if user_data then - for node, _ in pairs(user_data) do - reply:tag('item', {jid=bare, node=node}):up(); - end + local supported_features = lib_pubsub.get_feature_set(service) + set.new{ + -- Features not covered by the above + "auto-subscribe", + "filtered-notifications", + "last-published", + "presence-notifications", + "presence-subscribe", + }; + + for feature in supported_features do + reply:tag('feature', {var=xmlns_pubsub.."#"..feature}):up(); end end); -module:hook("account-disco-info-node", function (event) - local session, stanza, node = event.origin, event.stanza, event.node; - local user = stanza.attr.to; - local user_data = data[user]; - if user_data and user_data[node] then - event.exists = true; - event.reply:tag('identity', {category='pubsub', type='leaf'}):up(); +module:hook("account-disco-items-node", function(event) + local stanza, origin = event.stanza, event.origin; + local is_self = stanza.attr.to == nil; + local username = jid_split(stanza.attr.to); + if is_self then + username = origin.username; end + local service = get_pep_service(username); + return lib_pubsub.handle_disco_items_node(event, service); end); -module:hook("resource-unbind", function (event) - local user_bare_jid = event.session.username.."@"..event.session.host; - if not bare_sessions[user_bare_jid] then -- User went offline - -- We don't need this info cached anymore, clear it. - recipients[user_bare_jid] = nil; +module:hook("account-disco-items", function(event) + local reply, stanza, origin = event.reply, event.stanza, event.origin; + + local is_self = stanza.attr.to == nil; + local user_bare = jid_bare(stanza.attr.to); + local username = jid_split(stanza.attr.to); + if is_self then + username = origin.username; + user_bare = jid_join(username, host); + end + local service = get_pep_service(username); + + local ok, ret = service:get_nodes(jid_bare(stanza.attr.from)); + if not ok then return; end + + for node, node_obj in pairs(ret) do + reply:tag("item", { jid = user_bare, node = node, name = node_obj.config.name }):up(); end end); diff --git a/plugins/mod_pep_plus.lua b/plugins/mod_pep_plus.lua new file mode 100644 index 00000000..503315d2 --- /dev/null +++ b/plugins/mod_pep_plus.lua @@ -0,0 +1,2 @@ +module:log("error", "mod_pep_plus has been renamed to mod_pep, please update your config file. Auto-loading mod_pep..."); +module:depends("pep"); diff --git a/plugins/mod_pep_simple.lua b/plugins/mod_pep_simple.lua new file mode 100644 index 00000000..f0b5d7ef --- /dev/null +++ b/plugins/mod_pep_simple.lua @@ -0,0 +1,333 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + + +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local st = require "util.stanza"; +local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; +local pairs = pairs; +local next = next; +local type = type; +local calculate_hash = require "util.caps".calculate_hash; +local core_post_stanza = prosody.core_post_stanza; +local bare_sessions = prosody.bare_sessions; + +local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; + +-- Used as canonical 'empty table' +local NULL = {}; +-- data[user_bare_jid][node] = item_stanza +local data = {}; +--- recipients[user_bare_jid][contact_full_jid][subscribed_node] = true +local recipients = {}; +-- hash_map[hash][subscribed_nodes] = true +local hash_map = {}; + +module.save = function() + return { data = data, recipients = recipients, hash_map = hash_map }; +end +module.restore = function(state) + data = state.data or {}; + recipients = state.recipients or {}; + hash_map = state.hash_map or {}; +end + +local function subscription_presence(user_bare, recipient) + local recipient_bare = jid_bare(recipient); + if (recipient_bare == user_bare) then return true end + local username, host = jid_split(user_bare); + return is_contact_subscribed(username, host, recipient_bare); +end + +module:hook("pep-publish-item", function (event) + local session, bare, node, id, item = event.session, event.user, event.node, event.id, event.item; + item.attr.xmlns = nil; + local disable = #item.tags ~= 1 or #item.tags[1] == 0; + if #item.tags == 0 then item.name = "retract"; end + local stanza = st.message({from=bare, type='headline'}) + :tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'}) + :tag('items', {node=node}) + :add_child(item) + :up() + :up(); + + -- store for the future + local user_data = data[bare]; + if disable then + if user_data then + user_data[node] = nil; + if not next(user_data) then data[bare] = nil; end + end + else + if not user_data then user_data = {}; data[bare] = user_data; end + user_data[node] = {id, item}; + end + + -- broadcast + for recipient, notify in pairs(recipients[bare] or NULL) do + if notify[node] then + stanza.attr.to = recipient; + core_post_stanza(session, stanza); + end + end +end); + +local function publish_all(user, recipient, session) + local d = data[user]; + local notify = recipients[user] and recipients[user][recipient]; + if d and notify then + for node in pairs(notify) do + if d[node] then + local id, item = unpack(d[node]); + session.send(st.message({from=user, to=recipient, type='headline'}) + :tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'}) + :tag('items', {node=node}) + :add_child(item) + :up() + :up()); + end + end + end +end + +local function get_caps_hash_from_presence(stanza, current) + local t = stanza.attr.type; + if not t then + for _, child in pairs(stanza.tags) do + if child.name == "c" and child.attr.xmlns == "http://jabber.org/protocol/caps" then + local attr = child.attr; + if attr.hash then -- new caps + if attr.hash == 'sha-1' and attr.node and attr.ver then return attr.ver, attr.node.."#"..attr.ver; end + else -- legacy caps + if attr.node and attr.ver then return attr.node.."#"..attr.ver.."#"..(attr.ext or ""), attr.node.."#"..attr.ver; end + end + return; -- bad caps format + end + end + elseif t == "unavailable" or t == "error" then + return; + end + return current; -- no caps, could mean caps optimization, so return current +end + +module:hook("presence/bare", function(event) + -- inbound presence to bare JID received + local origin, stanza = event.origin, event.stanza; + local user = stanza.attr.to or (origin.username..'@'..origin.host); + local t = stanza.attr.type; + local self = not stanza.attr.to; + + -- Only cache subscriptions if user is online + if not bare_sessions[user] then return; end + + if not t then -- available presence + if self or subscription_presence(user, stanza.attr.from) then + local recipient = stanza.attr.from; + local current = recipients[user] and recipients[user][recipient]; + local hash = get_caps_hash_from_presence(stanza, current); + if current == hash or (current and current == hash_map[hash]) then return; end + if not hash then + if recipients[user] then recipients[user][recipient] = nil; end + else + recipients[user] = recipients[user] or {}; + if hash_map[hash] then + recipients[user][recipient] = hash_map[hash]; + publish_all(user, recipient, origin); + else + recipients[user][recipient] = hash; + local from_bare = origin.type == "c2s" and origin.username.."@"..origin.host; + if self or origin.type ~= "c2s" or (recipients[from_bare] and recipients[from_bare][origin.full_jid]) ~= hash then + -- COMPAT from ~= stanza.attr.to because OneTeam and Asterisk 1.8 can't deal with missing from attribute + origin.send( + st.stanza("iq", {from=user, to=stanza.attr.from, id="disco", type="get"}) + :query("http://jabber.org/protocol/disco#info") + ); + end + end + end + end + elseif t == "unavailable" then + if recipients[user] then recipients[user][stanza.attr.from] = nil; end + elseif not self and t == "unsubscribe" then + local from = jid_bare(stanza.attr.from); + local subscriptions = recipients[user]; + if subscriptions then + for subscriber in pairs(subscriptions) do + if jid_bare(subscriber) == from then + recipients[user][subscriber] = nil; + end + end + end + end +end, 10); + +module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function(event) + local session, stanza = event.origin, event.stanza; + local payload = stanza.tags[1]; + + if stanza.attr.type == 'set' and (not stanza.attr.to or jid_bare(stanza.attr.from) == stanza.attr.to) then + payload = payload.tags[1]; -- <publish node='http://jabber.org/protocol/tune'> + if payload and (payload.name == 'publish' or payload.name == 'retract') and payload.attr.node then + local node = payload.attr.node; + payload = payload.tags[1]; + if payload and payload.name == "item" then -- <item> + local id = payload.attr.id or "1"; + payload.attr.id = id; + session.send(st.reply(stanza)); + module:fire_event("pep-publish-item", { + node = node, user = jid_bare(session.full_jid), actor = session.jid, + id = id, session = session, item = st.clone(payload); + }); + return true; + else + module:log("debug", "Payload is missing the <item>", node); + end + else + module:log("debug", "Unhandled payload: %s", payload and payload:top_tag() or "(no payload)"); + end + elseif stanza.attr.type == 'get' then + local user = stanza.attr.to and jid_bare(stanza.attr.to) or session.username..'@'..session.host; + if subscription_presence(user, stanza.attr.from) then + local user_data = data[user]; + local node, requested_id; + payload = payload.tags[1]; + if payload and payload.name == 'items' then + node = payload.attr.node; + local item = payload.tags[1]; + if item and item.name == "item" then + requested_id = item.attr.id; + end + end + if node and user_data and user_data[node] then -- Send the last item + local id, item = unpack(user_data[node]); + if not requested_id or id == requested_id then + local reply_stanza = st.reply(stanza) + :tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'}) + :tag('items', {node=node}) + :add_child(item) + :up() + :up(); + session.send(reply_stanza); + return true; + else -- requested item doesn't exist + local reply_stanza = st.reply(stanza) + :tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'}) + :tag('items', {node=node}) + :up(); + session.send(reply_stanza); + return true; + end + elseif node then -- node doesn't exist + session.send(st.error_reply(stanza, 'cancel', 'item-not-found')); + module:log("debug", "Item '%s' not found", node) + return true; + else --invalid request + session.send(st.error_reply(stanza, 'modify', 'bad-request')); + module:log("debug", "Invalid request: %s", tostring(payload)); + return true; + end + else --no presence subscription + session.send(st.error_reply(stanza, 'auth', 'not-authorized') + :tag('presence-subscription-required', {xmlns='http://jabber.org/protocol/pubsub#errors'})); + module:log("debug", "Unauthorized request: %s", tostring(payload)); + return true; + end + end +end); + +module:hook("iq-result/bare/disco", function(event) + local session, stanza = event.origin, event.stanza; + if stanza.attr.type == "result" then + local disco = stanza.tags[1]; + if disco and disco.name == "query" and disco.attr.xmlns == "http://jabber.org/protocol/disco#info" then + -- Process disco response + local self = not stanza.attr.to; + local user = stanza.attr.to or (session.username..'@'..session.host); + local contact = stanza.attr.from; + local current = recipients[user] and recipients[user][contact]; + if type(current) ~= "string" then return; end -- check if waiting for recipient's response + local ver = current; + if not string.find(current, "#") then + ver = calculate_hash(disco.tags); -- calculate hash + end + local notify = {}; + for _, feature in pairs(disco.tags) do + if feature.name == "feature" and feature.attr.var then + local nfeature = feature.attr.var:match("^(.*)%+notify$"); + if nfeature then notify[nfeature] = true; end + end + end + hash_map[ver] = notify; -- update hash map + if self then + for jid, item in pairs(session.roster) do -- for all interested contacts + if item.subscription == "both" or item.subscription == "from" then + if not recipients[jid] then recipients[jid] = {}; end + recipients[jid][contact] = notify; + publish_all(jid, contact, session); + end + end + end + recipients[user][contact] = notify; -- set recipient's data to calculated data + -- send messages to recipient + publish_all(user, contact, session); + end + end +end); + +module:hook("account-disco-info", function(event) + local reply = event.reply; + reply:tag('identity', {category='pubsub', type='pep'}):up(); + reply:tag('feature', {var=xmlns_pubsub}):up(); + local features = { + "access-presence", + "auto-create", + "auto-subscribe", + "filtered-notifications", + "item-ids", + "last-published", + "presence-notifications", + "presence-subscribe", + "publish", + "retract-items", + "retrieve-items", + }; + for _, feature in ipairs(features) do + reply:tag('feature', {var=xmlns_pubsub.."#"..feature}):up(); + end +end); + +module:hook("account-disco-items", function(event) + local reply = event.reply; + local bare = reply.attr.to; + local user_data = data[bare]; + + if user_data then + for node, _ in pairs(user_data) do + reply:tag('item', {jid=bare, node=node}):up(); + end + end +end); + +module:hook("account-disco-info-node", function (event) + local stanza, node = event.stanza, event.node; + local user = stanza.attr.to; + local user_data = data[user]; + if user_data and user_data[node] then + event.exists = true; + event.reply:tag('identity', {category='pubsub', type='leaf'}):up(); + end +end); + +module:hook("resource-unbind", function (event) + local user_bare_jid = event.session.username.."@"..event.session.host; + if not bare_sessions[user_bare_jid] then -- User went offline + -- We don't need this info cached anymore, clear it. + recipients[user_bare_jid] = nil; + end +end); diff --git a/plugins/mod_ping.lua b/plugins/mod_ping.lua index 1a503409..33f05197 100644 --- a/plugins/mod_ping.lua +++ b/plugins/mod_ping.lua @@ -21,11 +21,12 @@ module:hook("iq-get/host/urn:xmpp:ping:ping", ping_handler); local datetime = require "util.datetime".datetime; -function ping_command_handler (self, data, state) +function ping_command_handler (self, data, state) -- luacheck: ignore 212 local now = datetime(); return { info = "Pong\n"..now, status = "completed" }; end +module:depends "adhoc"; local adhoc_new = module:require "adhoc".new; local descriptor = adhoc_new("Ping", "ping", ping_command_handler); module:add_item ("adhoc", descriptor); diff --git a/plugins/mod_posix.lua b/plugins/mod_posix.lua index fccc7a2b..825d3be0 100644 --- a/plugins/mod_posix.lua +++ b/plugins/mod_posix.lua @@ -61,7 +61,7 @@ if not prosody.start_time then -- server-starting if not suid or suid == 0 or suid == "root" then if pposix.getuid() == 0 and not module:get_option_boolean("run_as_root") then module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!"); - module:log("error", "For more information on running Prosody as root, see http://prosody.im/doc/root"); + module:log("error", "For more information on running Prosody as root, see https://prosody.im/doc/root"); prosody.shutdown("Refusing to run as root"); end end @@ -161,23 +161,25 @@ module:hook("server-stopped", remove_pidfile); -- Set signal handlers if have_signal then - signal.signal("SIGTERM", function () - module:log("warn", "Received SIGTERM"); - prosody.unlock_globals(); - prosody.shutdown("Received SIGTERM"); - prosody.lock_globals(); - end); - - signal.signal("SIGHUP", function () - module:log("info", "Received SIGHUP"); - prosody.reload_config(); - prosody.reopen_logfiles(); - end); - - signal.signal("SIGINT", function () - module:log("info", "Received SIGINT"); - prosody.unlock_globals(); - prosody.shutdown("Received SIGINT"); - prosody.lock_globals(); + module:add_timer(0, function () + signal.signal("SIGTERM", function () + module:log("warn", "Received SIGTERM"); + prosody.unlock_globals(); + prosody.shutdown("Received SIGTERM"); + prosody.lock_globals(); + end); + + signal.signal("SIGHUP", function () + module:log("info", "Received SIGHUP"); + prosody.reload_config(); + prosody.reopen_logfiles(); + end); + + signal.signal("SIGINT", function () + module:log("info", "Received SIGINT"); + prosody.unlock_globals(); + prosody.shutdown("Received SIGINT"); + prosody.lock_globals(); + end); end); end diff --git a/plugins/mod_presence.lua b/plugins/mod_presence.lua index 0c243bc6..5056a3a3 100644 --- a/plugins/mod_presence.lua +++ b/plugins/mod_presence.lua @@ -10,7 +10,6 @@ local log = module._log; local require = require; local pairs = pairs; -local t_concat = table.concat; local s_find = string.find; local tonumber = tonumber; @@ -121,6 +120,8 @@ function handle_normal_presence(origin, stanza) stanza.attr.to = nil; -- reset it end +-- luacheck: ignore 212/recipient_session +-- TODO This argument is used in 3rd party modules function send_presence_of_available_resources(user, host, jid, recipient_session, stanza) local h = hosts[host]; local count = 0; @@ -252,7 +253,7 @@ function handle_inbound_presence_subscriptions_and_probes(origin, stanza, from_b end local outbound_presence_handler = function(data) - -- outbound presence recieved + -- outbound presence received local origin, stanza = data.origin, data.stanza; local to = stanza.attr.to; @@ -280,7 +281,7 @@ module:hook("pre-presence/bare", outbound_presence_handler); module:hook("pre-presence/host", outbound_presence_handler); module:hook("presence/bare", function(data) - -- inbound presence to bare JID recieved + -- inbound presence to bare JID received local origin, stanza = data.origin, data.stanza; local to = stanza.attr.to; @@ -306,7 +307,7 @@ module:hook("presence/bare", function(data) return true; end); module:hook("presence/full", function(data) - -- inbound presence to full JID recieved + -- inbound presence to full JID received local origin, stanza = data.origin, data.stanza; local t = stanza.attr.type; diff --git a/plugins/mod_privacy.lua b/plugins/mod_privacy.lua index b749b7c7..bb9c0253 100644 --- a/plugins/mod_privacy.lua +++ b/plugins/mod_privacy.lua @@ -7,7 +7,7 @@ -- COPYING file in the source package for more information. -- - +-- luacheck: ignore 631 -- COMPAT w/ pre 0.10 module:log("error", "The mod_privacy plugin has been replaced by mod_blocklist. Please update your config. For more information see https://prosody.im/doc/modules/mod_privacy"); module:depends("blocklist"); diff --git a/plugins/mod_private.lua b/plugins/mod_private.lua index c01053d5..6046d490 100644 --- a/plugins/mod_private.lua +++ b/plugins/mod_private.lua @@ -9,7 +9,7 @@ local st = require "util.stanza" -local private_storage = module:open_store(); +local private_storage = module:open_store("private", "map"); module:add_feature("jabber:iq:private"); @@ -22,28 +22,23 @@ module:hook("iq/self/jabber:iq:private:query", function(event) end local tag = query.tags[1]; local key = tag.name..":"..tag.attr.xmlns; - local data, err = private_storage:get(origin.username); - if err then - origin.send(st.error_reply(stanza, "wait", "internal-server-error", err)); - return true; - end if stanza.attr.type == "get" then - if data and data[key] then - origin.send(st.reply(stanza):query("jabber:iq:private"):add_child(st.deserialize(data[key]))); - return true; + local data, err = private_storage:get(origin.username, key); + if data then + origin.send(st.reply(stanza):query("jabber:iq:private"):add_child(st.deserialize(data))); + elseif err then + origin.send(st.error_reply(stanza, "wait", "internal-server-error", err)); else origin.send(st.reply(stanza):add_child(query)); - return true; end - else -- type == set - if not data then data = {}; end; - if #tag == 0 then - data[key] = nil; - else - data[key] = st.preserialize(tag); + return true; + else -- stanza.attr.type == "set" + local data; + if #tag ~= 0 then + data = st.preserialize(tag); end -- TODO delete datastore if empty - local ok, err = private_storage:set(origin.username, data); + local ok, err = private_storage:set(origin.username, key, data); if not ok then origin.send(st.error_reply(stanza, "wait", "internal-server-error", err)); return true; diff --git a/plugins/mod_proxy65.lua b/plugins/mod_proxy65.lua index cbbfad12..71014e79 100644 --- a/plugins/mod_proxy65.lua +++ b/plugins/mod_proxy65.lua @@ -16,7 +16,8 @@ local b64 = require "util.encodings".base64.encode; local server = require "net.server"; local portmanager = require "core.portmanager"; -local sessions, transfers = module:shared("sessions", "transfers"); +local sessions = module:shared("sessions"); +local transfers = module:shared("transfers"); local max_buffer_size = 4096; local listener = {}; @@ -44,7 +45,7 @@ function listener.onincoming(conn, data) end -- else error, unexpected input conn:write("\5\255"); -- send (SOCKS version 5, no acceptable method) conn:close(); - module:log("debug", "Invalid SOCKS5 greeting recieved: '%s'", b64(data)); + module:log("debug", "Invalid SOCKS5 greeting received: '%s'", b64(data)); else -- connection request --local head = string.char( 0x05, 0x01, 0x00, 0x03, 40 ); -- ( VER=5=SOCKS5, CMD=1=CONNECT, RSV=0=RESERVED, ATYP=3=DOMAIMNAME, SHA-1 size ) if #data == 47 and data:sub(1,5) == "\5\1\0\3\40" and data:sub(-2) == "\0\0" then @@ -66,12 +67,12 @@ function listener.onincoming(conn, data) else -- error, unexpected input conn:write("\5\1\0\3\0\0\0"); -- VER, REP, RSV, ATYP, BND.ADDR (sha), BND.PORT (2 Byte) conn:close(); - module:log("debug", "Invalid SOCKS5 negotiation recieved: '%s'", b64(data)); + module:log("debug", "Invalid SOCKS5 negotiation received: '%s'", b64(data)); end end end -function listener.ondisconnect(conn, err) +function listener.ondisconnect(conn) local session = sessions[conn]; if session then if transfers[session.sha] then @@ -79,7 +80,7 @@ function listener.ondisconnect(conn, err) if initiator == conn and target ~= nil then target:close(); elseif target == conn and initiator ~= nil then - initiator:close(); + initiator:close(); end transfers[session.sha] = nil; end @@ -109,7 +110,8 @@ function module.add_host(module) local origin, stanza = event.origin, event.stanza; -- check ACL - while proxy_acl and #proxy_acl > 0 do -- using 'while' instead of 'if' so we can break out of it + -- using 'while' instead of 'if' so we can break out of it + while proxy_acl and #proxy_acl > 0 do --luacheck: ignore 512 local jid = stanza.attr.from; local allow; for _, acl in ipairs(proxy_acl) do @@ -123,7 +125,7 @@ function module.add_host(module) local sid = stanza.tags[1].attr.sid; origin.send(st.reply(stanza):tag("query", {xmlns="http://jabber.org/protocol/bytestreams", sid=sid}) - :tag("streamhost", {jid=host, host=proxy_address, port=proxy_port})); + :tag("streamhost", {jid=host, host=proxy_address, port=("%d"):format(proxy_port)})); return true; end); diff --git a/plugins/mod_pubsub/mod_pubsub.lua b/plugins/mod_pubsub/mod_pubsub.lua index 8e7bfc53..05d2d663 100644 --- a/plugins/mod_pubsub/mod_pubsub.lua +++ b/plugins/mod_pubsub/mod_pubsub.lua @@ -2,6 +2,7 @@ local pubsub = require "util.pubsub"; local st = require "util.stanza"; local jid_bare = require "util.jid".bare; local usermanager = require "core.usermanager"; +local new_id = require "util.id".medium; local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event"; @@ -15,107 +16,138 @@ local expose_publisher = module:get_option_boolean("expose_publisher", false) local service; local lib_pubsub = module:require "pubsub"; -local handlers = lib_pubsub.handlers; -local pubsub_error_reply = lib_pubsub.pubsub_error_reply; module:depends("disco"); module:add_identity("pubsub", "service", pubsub_disco_name); module:add_feature("http://jabber.org/protocol/pubsub"); function handle_pubsub_iq(event) - local origin, stanza = event.origin, event.stanza; - local pubsub = stanza.tags[1]; - local action = pubsub.tags[1]; - if not action then - origin.send(st.error_reply(stanza, "cancel", "bad-request")); - return true; + return lib_pubsub.handle_pubsub_iq(event, service); +end + +-- An itemstore supports the following methods: +-- items(): iterator over (id, item) +-- get(id): return item with id +-- set(id, item): set id to item +-- clear(): clear all items +-- resize(n): set new limit and trim oldest items +-- tail(): return the latest item + +-- A nodestore supports the following methods: +-- set(node_name, node_data) +-- get(node_name) +-- users(): iterator over (node_name) + + +local node_store = module:open_store(module.name.."_nodes"); + +local function create_simple_itemstore(node_config, node_name) + local archive = module:open_store("pubsub_"..node_name, "archive"); + return lib_pubsub.archive_itemstore(archive, node_config, nil, node_name); +end + +function simple_broadcast(kind, node, jids, item, actor, node_obj) + if node_obj then + if node_obj.config["notify_"..kind] == false then + return; + end end - local handler = handlers[stanza.attr.type.."_"..action.name]; - if handler then - handler(origin, stanza, action, service); - return true; + if kind == "retract" then + kind = "items"; -- XEP-0060 signals retraction in an <items> container end -end -function simple_broadcast(kind, node, jids, item, actor) if item then item = st.clone(item); item.attr.xmlns = nil; -- Clear the pubsub namespace - if expose_publisher and actor then - item.attr.publisher = actor + if kind == "items" then + if node_obj and node_obj.config.include_payload == false then + item:maptags(function () return nil; end); + end + if expose_publisher and actor then + item.attr.publisher = actor + end end end - local message = st.message({ from = module.host, type = "headline" }) + + local id = new_id(); + local msg_type = node_obj and node_obj.config.message_type or "headline"; + local message = st.message({ from = module.host, type = msg_type, id = id }) :tag("event", { xmlns = xmlns_pubsub_event }) :tag(kind, { node = node }) - :add_child(item); - for jid in pairs(jids) do - module:log("debug", "Sending notification to %s", jid); - message.attr.to = jid; - module:send(message); + + if item then + message:add_child(item); end -end -module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq); -module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq); + local summary; + -- Compose a sensible textual representation of at least Atom payloads + if item and item.tags[1] then + local payload = item.tags[1]; + summary = module:fire_event("pubsub-summary/"..payload.attr.xmlns, { + kind = kind, node = node, jids = jids, actor = actor, item = item, payload = payload, + }); + end -local feature_map = { - create = { "create-nodes", "instant-nodes", "item-ids" }; - retract = { "delete-items", "retract-items" }; - purge = { "purge-nodes" }; - publish = { "publish", autocreate_on_publish and "auto-create" }; - delete = { "delete-nodes" }; - get_items = { "retrieve-items" }; - add_subscription = { "subscribe" }; - get_subscriptions = { "retrieve-subscriptions" }; - set_configure = { "config-node" }; - get_default = { "retrieve-default" }; -}; - -local function add_disco_features_from_service(service) - for method, features in pairs(feature_map) do - if service[method] then - for _, feature in ipairs(features) do - if feature then - module:add_feature(xmlns_pubsub.."#"..feature); - end - end + for jid, options in pairs(jids) do + local new_stanza = st.clone(message); + if summary and type(options) == "table" and options["pubsub#include_body"] then + new_stanza:body(summary); end + new_stanza.attr.to = jid; + module:send(new_stanza); end - for affiliation in pairs(service.config.capabilities) do - if affiliation ~= "none" and affiliation ~= "owner" then - module:add_feature(xmlns_pubsub.."#"..affiliation.."-affiliation"); - end +end + +local max_max_items = module:get_option_number("pubsub_max_items", 256); +function check_node_config(node, actor, new_config) -- luacheck: ignore 212/actor 212/node + if (new_config["max_items"] or 1) > max_max_items then + return false; end + if new_config["access_model"] ~= "whitelist" and new_config["access_model"] ~= "open" then + return false; + end + return true; end -module:hook("host-disco-info-node", function (event) - local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node; - local ok, ret = service:get_nodes(stanza.attr.from); - if not ok or not ret[node] then - return; +function is_item_stanza(item) + return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item"; +end + +module:hook("pubsub-summary/http://www.w3.org/2005/Atom", function (event) + local payload = event.payload; + local title = payload:get_child_text("title"); + local summary = payload:get_child_text("summary"); + if not summary and title then + local author = payload:find("author/name#"); + summary = title; + if author then + summary = author .. " posted " .. summary; + end end - event.exists = true; - reply:tag("identity", { category = "pubsub", type = "leaf" }); + return summary; end); -module:hook("host-disco-items-node", function (event) - local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node; - local ok, ret = service:get_items(node, stanza.attr.from); - if not ok then - return; - end +module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq); +module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq); - for _, id in ipairs(ret) do - reply:tag("item", { jid = module.host, name = id }):up(); +local function add_disco_features_from_service(service) --luacheck: ignore 431/service + for feature in lib_pubsub.get_feature_set(service) do + module:add_feature(xmlns_pubsub.."#"..feature); end - event.exists = true; +end + +module:hook("host-disco-info-node", function (event) + return lib_pubsub.handle_disco_info_node(event, service); +end); + +module:hook("host-disco-items-node", function (event) + return lib_pubsub.handle_disco_items_node(event, service); end); module:hook("host-disco-items", function (event) - local stanza, origin, reply = event.stanza, event.origin, event.reply; - local ok, ret = service:get_nodes(event.stanza.attr.from); + local stanza, reply = event.stanza, event.reply; + local ok, ret = service:get_nodes(stanza.attr.from); if not ok then return; end @@ -132,6 +164,10 @@ local function get_affiliation(jid) end end +function get_service() + return service; +end + function set_service(new_service) service = new_service; module.environment.service = service; @@ -150,82 +186,14 @@ function module.load() if module.reloading then return; end set_service(pubsub.new({ - capabilities = { - none = { - create = false; - publish = false; - retract = false; - get_nodes = true; - - subscribe = true; - unsubscribe = true; - get_subscription = true; - get_subscriptions = true; - get_items = true; - - subscribe_other = false; - unsubscribe_other = false; - get_subscription_other = false; - get_subscriptions_other = false; - - be_subscribed = true; - be_unsubscribed = true; - - set_affiliation = false; - }; - publisher = { - create = false; - publish = true; - retract = true; - get_nodes = true; - - subscribe = true; - unsubscribe = true; - get_subscription = true; - get_subscriptions = true; - get_items = true; - - subscribe_other = false; - unsubscribe_other = false; - get_subscription_other = false; - get_subscriptions_other = false; - - be_subscribed = true; - be_unsubscribed = true; - - set_affiliation = false; - }; - owner = { - create = true; - publish = true; - retract = true; - delete = true; - get_nodes = true; - configure = true; - - subscribe = true; - unsubscribe = true; - get_subscription = true; - get_subscriptions = true; - get_items = true; - - - subscribe_other = true; - unsubscribe_other = true; - get_subscription_other = true; - get_subscriptions_other = true; - - be_subscribed = true; - be_unsubscribed = true; - - set_affiliation = true; - }; - }; - autocreate_on_publish = autocreate_on_publish; autocreate_on_subscribe = autocreate_on_subscribe; + nodestore = node_store; + itemstore = create_simple_itemstore; broadcaster = simple_broadcast; + itemcheck = is_item_stanza; + check_node_config = check_node_config; get_affiliation = get_affiliation; normalize_jid = jid_bare; diff --git a/plugins/mod_pubsub/pubsub.lib.lua b/plugins/mod_pubsub/pubsub.lib.lua index 1497c21c..1bd5fa33 100644 --- a/plugins/mod_pubsub/pubsub.lib.lua +++ b/plugins/mod_pubsub/pubsub.lib.lua @@ -1,4 +1,10 @@ +local t_unpack = table.unpack or unpack; -- luacheck: ignore 113 +local time_now = os.time; + +local jid_prep = require "util.jid".prep; +local set = require "util.set"; local st = require "util.stanza"; +local it = require "util.iterators"; local uuid_generate = require "util.uuid".generate; local dataform = require"util.dataforms".new; @@ -18,12 +24,17 @@ local pubsub_errors = { ["nodeid-required"] = { "modify", "bad-request", nil, "nodeid-required" }; ["item-not-found"] = { "cancel", "item-not-found" }; ["not-subscribed"] = { "modify", "unexpected-request", nil, "not-subscribed" }; + ["invalid-options"] = { "modify", "bad-request", nil, "invalid-options" }; ["forbidden"] = { "auth", "forbidden" }; ["not-allowed"] = { "cancel", "not-allowed" }; + ["not-acceptable"] = { "modify", "not-acceptable" }; + ["internal-server-error"] = { "wait", "internal-server-error" }; + ["precondition-not-met"] = { "cancel", "conflict", nil, "precondition-not-met" }; + ["invalid-item"] = { "modify", "bad-request", "invalid item" }; }; local function pubsub_error_reply(stanza, error) local e = pubsub_errors[error]; - local reply = st.error_reply(stanza, unpack(e, 1, 3)); + local reply = st.error_reply(stanza, t_unpack(e, 1, 3)); if e[4] then reply:tag(e[4], { xmlns = xmlns_pubsub_errors }):up(); end @@ -31,29 +42,267 @@ local function pubsub_error_reply(stanza, error) end _M.pubsub_error_reply = pubsub_error_reply; -local node_config_form = require"util.dataforms".new { +local function dataform_error_message(err) -- ({ string : string }) -> string? + local out = {}; + for field, errmsg in pairs(err) do + table.insert(out, ("%s: %s"):format(field, errmsg)) + end + return table.concat(out, "; "); +end + +-- Note: If any config options are added that are of complex types, +-- (not simply strings/numbers) then the publish-options code will +-- need to be revisited +local node_config_form = dataform { { type = "hidden"; - name = "FORM_TYPE"; + var = "FORM_TYPE"; value = "http://jabber.org/protocol/pubsub#node_config"; }; { type = "text-single"; - name = "pubsub#max_items"; + name = "title"; + var = "pubsub#title"; + label = "Title"; + }; + { + type = "text-single"; + name = "description"; + var = "pubsub#description"; + label = "Description"; + }; + { + type = "text-single"; + name = "payload_type"; + var = "pubsub#type"; + label = "The type of node data, usually specified by the namespace of the payload (if any)"; + }; + { + type = "text-single"; + datatype = "xs:integer"; + name = "max_items"; + var = "pubsub#max_items"; label = "Max # of items to persist"; }; + { + type = "boolean"; + name = "persist_items"; + var = "pubsub#persist_items"; + label = "Persist items to storage"; + }; + { + type = "list-single"; + name = "access_model"; + var = "pubsub#access_model"; + label = "Specify the subscriber model"; + options = { + "authorize", + "open", + "presence", + "roster", + "whitelist", + }; + }; + { + type = "list-single"; + name = "publish_model"; + var = "pubsub#publish_model"; + label = "Specify the publisher model"; + options = { + "publishers"; + "subscribers"; + "open"; + }; + }; + { + type = "boolean"; + value = true; + label = "Whether to deliver event notifications"; + name = "notify_items"; + var = "pubsub#deliver_notifications"; + }; + { + type = "boolean"; + value = true; + label = "Whether to deliver payloads with event notifications"; + name = "include_payload"; + var = "pubsub#deliver_payloads"; + }; + { + type = "list-single"; + name = "notification_type"; + var = "pubsub#notification_type"; + label = "Specify the delivery style for notifications"; + options = { + { label = "Messages of type normal", value = "normal" }, + { label = "Messages of type headline", value = "headline", default = true }, + }; + }; + { + type = "boolean"; + label = "Whether to notify subscribers when the node is deleted"; + name = "notify_delete"; + var = "pubsub#notify_delete"; + value = true; + }; + { + type = "boolean"; + label = "Whether to notify subscribers when items are removed from the node"; + name = "notify_retract"; + var = "pubsub#notify_retract"; + value = true; + }; +}; + +local subscribe_options_form = dataform { + { + type = "hidden"; + var = "FORM_TYPE"; + value = "http://jabber.org/protocol/pubsub#subscribe_options"; + }; + { + type = "boolean"; + name = "pubsub#include_body"; + label = "Receive message body in addition to payload?"; + }; +}; + +local node_metadata_form = dataform { + { + type = "hidden"; + var = "FORM_TYPE"; + value = "http://jabber.org/protocol/pubsub#meta-data"; + }; + { + type = "text-single"; + name = "pubsub#title"; + }; + { + type = "text-single"; + name = "pubsub#description"; + }; + { + type = "text-single"; + name = "pubsub#type"; + }; +}; + +local service_method_feature_map = { + add_subscription = { "subscribe", "subscription-options" }; + create = { "create-nodes", "instant-nodes", "item-ids", "create-and-configure" }; + delete = { "delete-nodes" }; + get_items = { "retrieve-items" }; + get_subscriptions = { "retrieve-subscriptions" }; + node_defaults = { "retrieve-default" }; + publish = { "publish", "multi-items", "publish-options" }; + purge = { "purge-nodes" }; + retract = { "delete-items", "retract-items" }; + set_node_config = { "config-node", "meta-data" }; + set_affiliation = { "modify-affiliations" }; +}; +local service_config_feature_map = { + autocreate_on_publish = { "auto-create" }; }; +function _M.get_feature_set(service) + local supported_features = set.new(); + + for method, features in pairs(service_method_feature_map) do + if service[method] then + for _, feature in ipairs(features) do + if feature then + supported_features:add(feature); + end + end + end + end + + for option, features in pairs(service_config_feature_map) do + if service.config[option] then + for _, feature in ipairs(features) do + if feature then + supported_features:add(feature); + end + end + end + end + + for affiliation in pairs(service.config.capabilities) do + if affiliation ~= "none" and affiliation ~= "owner" then + supported_features:add(affiliation.."-affiliation"); + end + end + + if service.node_defaults.access_model then + supported_features:add("access-"..service.node_defaults.access_model); + end + + if rawget(service.config, "itemstore") and rawget(service.config, "nodestore") then + supported_features:add("persistent-items"); + end + + return supported_features; +end + +function _M.handle_disco_info_node(event, service) + local stanza, reply, node = event.stanza, event.reply, event.node; + local ok, ret = service:get_nodes(stanza.attr.from); + local node_obj = ret[node]; + if not ok or not node_obj then + return; + end + event.exists = true; + reply:tag("identity", { category = "pubsub", type = "leaf" }):up(); + if node_obj.config then + reply:add_child(node_metadata_form:form({ + ["pubsub#title"] = node_obj.config.title; + ["pubsub#description"] = node_obj.config.description; + ["pubsub#type"] = node_obj.config.payload_type; + }, "result")); + end +end + +function _M.handle_disco_items_node(event, service) + local stanza, reply, node = event.stanza, event.reply, event.node; + local ok, ret = service:get_items(node, stanza.attr.from); + if not ok then + return; + end + + for _, id in ipairs(ret) do + reply:tag("item", { jid = module.host, name = id }):up(); + end + event.exists = true; +end + +function _M.handle_pubsub_iq(event, service) + local origin, stanza = event.origin, event.stanza; + local pubsub_tag = stanza.tags[1]; + local action = pubsub_tag.tags[1]; + if not action then + return origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + local prefix = ""; + if pubsub_tag.attr.xmlns == xmlns_pubsub_owner then + prefix = "owner_"; + end + local handler = handlers[prefix..stanza.attr.type.."_"..action.name]; + if handler then + handler(origin, stanza, action, service); + return true; + end +end + function handlers.get_items(origin, stanza, items, service) local node = items.attr.node; local item = items:get_child("item"); - local id = item and item.attr.id; + local item_id = item and item.attr.id; if not node then origin.send(pubsub_error_reply(stanza, "nodeid-required")); return true; end - local ok, results = service:get_items(node, stanza.attr.from, id); + local ok, results = service:get_items(node, stanza.attr.from, item_id); if not ok then origin.send(pubsub_error_reply(stanza, results)); return true; @@ -92,11 +341,81 @@ function handlers.get_subscriptions(origin, stanza, subscriptions, service) return true; end +function handlers.owner_get_subscriptions(origin, stanza, subscriptions, service) + local node = subscriptions.attr.node; + local ok, ret = service:get_subscriptions(node, stanza.attr.from); + if not ok then + origin.send(pubsub_error_reply(stanza, ret)); + return true; + end + local reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub_owner }) + :tag("subscriptions"); + for _, sub in ipairs(ret) do + reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up(); + end + origin.send(reply); + return true; +end + +function handlers.owner_set_subscriptions(origin, stanza, subscriptions, service) + local node = subscriptions.attr.node; + if not node then + origin.send(pubsub_error_reply(stanza, "nodeid-required")); + return true; + end + if not service:may(node, stanza.attr.from, "subscribe_other") then + origin.send(pubsub_error_reply(stanza, "forbidden")); + return true; + end + + local node_obj = service.nodes[node]; + if not node_obj then + origin.send(pubsub_error_reply(stanza, "item-not-found")); + return true; + end + + for subscription_tag in subscriptions:childtags("subscription") do + if subscription_tag.attr.subscription == 'subscribed' then + local ok, err = service:add_subscription(node, stanza.attr.from, subscription_tag.attr.jid); + if not ok then + origin.send(pubsub_error_reply(stanza, err)); + return true; + end + elseif subscription_tag.attr.subscription == 'none' then + local ok, err = service:remove_subscription(node, stanza.attr.from, subscription_tag.attr.jid); + if not ok then + origin.send(pubsub_error_reply(stanza, err)); + return true; + end + end + end + + local reply = st.reply(stanza); + origin.send(reply); + return true; +end + function handlers.set_create(origin, stanza, create, service) local node = create.attr.node; local ok, ret, reply; + local config; + local configure = stanza.tags[1]:get_child("configure"); + if configure then + local config_form = configure:get_child("x", "jabber:x:data"); + if not config_form then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform")); + return true; + end + local form_data, err = node_config_form:data(config_form); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err))); + return true; + end + config = form_data; + end if node then - ok, ret = service:create(node, stanza.attr.from); + ok, ret = service:create(node, stanza.attr.from, config); if ok then reply = st.reply(stanza); else @@ -105,7 +424,7 @@ function handlers.set_create(origin, stanza, create, service) else repeat node = uuid_generate(); - ok, ret = service:create(node, stanza.attr.from); + ok, ret = service:create(node, stanza.attr.from, config); until ok or ret ~= "conflict"; if ok then reply = st.reply(stanza) @@ -119,10 +438,10 @@ function handlers.set_create(origin, stanza, create, service) return true; end -function handlers.set_delete(origin, stanza, delete, service) +function handlers.owner_set_delete(origin, stanza, delete, service) local node = delete.attr.node; - local reply, notifier; + local reply; if not node then origin.send(pubsub_error_reply(stanza, "nodeid-required")); return true; @@ -139,17 +458,21 @@ end function handlers.set_subscribe(origin, stanza, subscribe, service) local node, jid = subscribe.attr.node, subscribe.attr.jid; + jid = jid_prep(jid); if not (node and jid) then origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid")); return true; end - --[[ local options_tag, options = stanza.tags[1]:get_child("options"), nil; if options_tag then - options = options_form:data(options_tag.tags[1]); + -- FIXME form parsing errors ignored here, why? + local err + options, err = subscribe_options_form:data(options_tag.tags[1]); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err))); + return true + end end - --]] - local options_tag, options; -- FIXME local ok, ret = service:add_subscription(node, stanza.attr.from, jid, options); local reply; if ok then @@ -171,6 +494,7 @@ end function handlers.set_unsubscribe(origin, stanza, unsubscribe, service) local node, jid = unsubscribe.attr.node, unsubscribe.attr.jid; + jid = jid_prep(jid); if not (node and jid) then origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid")); return true; @@ -186,12 +510,74 @@ function handlers.set_unsubscribe(origin, stanza, unsubscribe, service) return true; end +function handlers.get_options(origin, stanza, options, service) + local node, jid = options.attr.node, options.attr.jid; + jid = jid_prep(jid); + if not (node and jid) then + origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid")); + return true; + end + local ok, ret = service:get_subscription(node, stanza.attr.from, jid); + if not ok then + origin.send(pubsub_error_reply(stanza, "not-subscribed")); + return true; + end + if ret == true then ret = {} end + origin.send(st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("options", { node = node, jid = jid }) + :add_child(subscribe_options_form:form(ret))); + return true; +end + +function handlers.set_options(origin, stanza, options, service) + local node, jid = options.attr.node, options.attr.jid; + jid = jid_prep(jid); + if not (node and jid) then + origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid")); + return true; + end + local ok, ret = service:get_subscription(node, stanza.attr.from, jid); + if not ok then + origin.send(pubsub_error_reply(stanza, ret)); + return true; + elseif not ret then + origin.send(pubsub_error_reply(stanza, "not-subscribed")); + return true; + end + local old_subopts = ret; + local new_subopts, err = subscribe_options_form:data(options.tags[1], old_subopts); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err))); + return true; + end + local ok, err = service:add_subscription(node, stanza.attr.from, jid, new_subopts); + if not ok then + origin.send(pubsub_error_reply(stanza, err)); + return true; + end + origin.send(st.reply(stanza)); + return true; +end + function handlers.set_publish(origin, stanza, publish, service) local node = publish.attr.node; if not node then origin.send(pubsub_error_reply(stanza, "nodeid-required")); return true; end + local required_config = nil; + local publish_options = stanza.tags[1]:get_child("publish-options"); + if publish_options then + -- Ensure that the node configuration matches the values in publish-options + local publish_options_form = publish_options:get_child("x", "jabber:x:data"); + local err; + required_config, err = node_config_form:data(publish_options_form); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err))); + return true + end + end local item = publish:get_child("item"); local id = (item and item.attr.id); if not id then @@ -200,9 +586,12 @@ function handlers.set_publish(origin, stanza, publish, service) item.attr.id = id; end end - local ok, ret = service:publish(node, stanza.attr.from, id, item); + local ok, ret = service:publish(node, stanza.attr.from, id, item, required_config); local reply; if ok then + if type(ok) == "string" then + id = ok; + end reply = st.reply(stanza) :tag("pubsub", { xmlns = xmlns_pubsub }) :tag("publish", { node = node }) @@ -237,7 +626,7 @@ function handlers.set_retract(origin, stanza, retract, service) return true; end -function handlers.set_purge(origin, stanza, purge, service) +function handlers.owner_set_purge(origin, stanza, purge, service) local node, notify = purge.attr.node, purge.attr.notify; notify = (notify == "1") or (notify == "true"); local reply; @@ -255,33 +644,28 @@ function handlers.set_purge(origin, stanza, purge, service) return true; end -function handlers.get_configure(origin, stanza, config, service) +function handlers.owner_get_configure(origin, stanza, config, service) local node = config.attr.node; if not node then origin.send(pubsub_error_reply(stanza, "nodeid-required")); return true; end - if not service:may(node, stanza.attr.from, "configure") then - origin.send(pubsub_error_reply(stanza, "forbidden")); - return true; - end - - local node_obj = service.nodes[node]; - if not node_obj then - origin.send(pubsub_error_reply(stanza, "item-not-found")); + local ok, node_config = service:get_node_config(node, stanza.attr.from); + if not ok then + origin.send(pubsub_error_reply(stanza, node_config)); return true; end local reply = st.reply(stanza) :tag("pubsub", { xmlns = xmlns_pubsub_owner }) :tag("configure", { node = node }) - :add_child(node_config_form:form(node_obj.config)); + :add_child(node_config_form:form(node_config)); origin.send(reply); return true; end -function handlers.set_configure(origin, stanza, config, service) +function handlers.owner_set_configure(origin, stanza, config, service) local node = config.attr.node; if not node then origin.send(pubsub_error_reply(stanza, "nodeid-required")); @@ -291,9 +675,19 @@ function handlers.set_configure(origin, stanza, config, service) origin.send(pubsub_error_reply(stanza, "forbidden")); return true; end - local new_config, err = node_config_form:data(config.tags[1]); - if not new_config then - origin.send(st.error_reply(stanza, "modify", "bad-request", err)); + local config_form = config:get_child("x", "jabber:x:data"); + if not config_form then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform")); + return true; + end + local ok, old_config = service:get_node_config(node, stanza.attr.from); + if not ok then + origin.send(pubsub_error_reply(stanza, old_config)); + return true; + end + local new_config, err = node_config_form:data(config_form, old_config); + if err then + origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err))); return true; end local ok, err = service:set_node_config(node, stanza.attr.from, new_config); @@ -305,7 +699,7 @@ function handlers.set_configure(origin, stanza, config, service) return true; end -function handlers.get_default(origin, stanza, default, service) +function handlers.owner_get_default(origin, stanza, default, service) -- luacheck: ignore 212/default local reply = st.reply(stanza) :tag("pubsub", { xmlns = xmlns_pubsub_owner }) :tag("default") @@ -314,4 +708,157 @@ function handlers.get_default(origin, stanza, default, service) return true; end +function handlers.owner_get_affiliations(origin, stanza, affiliations, service) + local node = affiliations.attr.node; + if not node then + origin.send(pubsub_error_reply(stanza, "nodeid-required")); + return true; + end + if not service:may(node, stanza.attr.from, "set_affiliation") then + origin.send(pubsub_error_reply(stanza, "forbidden")); + return true; + end + + local node_obj = service.nodes[node]; + if not node_obj then + origin.send(pubsub_error_reply(stanza, "item-not-found")); + return true; + end + + local reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub_owner }) + :tag("affiliations", { node = node }); + + for jid, affiliation in pairs(node_obj.affiliations) do + reply:tag("affiliation", { jid = jid, affiliation = affiliation }):up(); + end + + origin.send(reply); + return true; +end + +function handlers.owner_set_affiliations(origin, stanza, affiliations, service) + local node = affiliations.attr.node; + if not node then + origin.send(pubsub_error_reply(stanza, "nodeid-required")); + return true; + end + if not service:may(node, stanza.attr.from, "set_affiliation") then + origin.send(pubsub_error_reply(stanza, "forbidden")); + return true; + end + + local node_obj = service.nodes[node]; + if not node_obj then + origin.send(pubsub_error_reply(stanza, "item-not-found")); + return true; + end + + for affiliation_tag in affiliations:childtags("affiliation") do + local jid = affiliation_tag.attr.jid; + local affiliation = affiliation_tag.attr.affiliation; + + jid = jid_prep(jid); + if affiliation == "none" then affiliation = nil; end + + local ok, err = service:set_affiliation(node, stanza.attr.from, jid, affiliation); + if not ok then + -- FIXME Incomplete error handling, + -- see XEP 60 8.9.2.4 Multiple Simultaneous Modifications + origin.send(pubsub_error_reply(stanza, err)); + return true; + end + end + + local reply = st.reply(stanza); + origin.send(reply); + return true; +end + +local function create_encapsulating_item(id, payload) + local item = st.stanza("item", { id = id, xmlns = xmlns_pubsub }); + item:add_child(payload); + return item; +end + +local function archive_itemstore(archive, config, user, node) + module:log("debug", "Creation of itemstore for node %s with config %s", node, config); + local get_set = {}; + local max_items = config["max_items"]; + function get_set:items() -- luacheck: ignore 212/self + local data, err = archive:find(user, { + limit = tonumber(max_items); + reverse = true; + }); + if not data then + module:log("error", "Unable to get items: %s", err); + return true; + end + module:log("debug", "Listed items %s", data); + return it.reverse(function() + local id, payload, when, publisher = data(); + if id == nil then + return; + end + local item = create_encapsulating_item(id, payload, publisher); + return id, item; + end); + end + function get_set:get(key) -- luacheck: ignore 212/self + local data, err = archive:find(user, { + key = key; + -- Get the last item with that key, if the archive doesn't deduplicate + reverse = true, + limit = 1; + }); + if not data then + module:log("error", "Unable to get item: %s", err); + return nil, err; + end + local id, payload, when, publisher = data(); + module:log("debug", "Get item %s (published at %s by %s)", id, when, publisher); + if id == nil then + return nil; + end + return create_encapsulating_item(id, payload, publisher); + end + function get_set:set(key, value) -- luacheck: ignore 212/self + local data, err; + if value ~= nil then + local publisher = value.attr.publisher; + local payload = value.tags[1]; + data, err = archive:append(user, key, payload, time_now(), publisher); + else + data, err = archive:delete(user, { key = key; }); + end + -- TODO archive support for maintaining maximum items + archive:delete(user, { + truncate = max_items; + }); + if not data then + module:log("error", "Unable to set item: %s", err); + return nil, err; + end + return data; + end + function get_set:clear() -- luacheck: ignore 212/self + return archive:delete(user); + end + function get_set:resize(size) -- luacheck: ignore 212/self + max_items = size; + return archive:delete(user, { + truncate = size; + }); + end + function get_set:head() + -- This should conveniently return the most recent item + local item = self:get(nil); + if item then + return item.attr.id, item; + end + end + return setmetatable(get_set, archive); +end +_M.archive_itemstore = archive_itemstore; + return _M; diff --git a/plugins/mod_register.lua b/plugins/mod_register.lua index b39ce090..49ff8a38 100644 --- a/plugins/mod_register.lua +++ b/plugins/mod_register.lua @@ -7,288 +7,11 @@ -- -local st = require "util.stanza"; -local dataform_new = require "util.dataforms".new; -local usermanager_user_exists = require "core.usermanager".user_exists; -local usermanager_create_user = require "core.usermanager".create_user; -local usermanager_set_password = require "core.usermanager".set_password; -local usermanager_delete_user = require "core.usermanager".delete_user; -local nodeprep = require "util.encodings".stringprep.nodeprep; -local jid_bare = require "util.jid".bare; -local create_throttle = require "util.throttle".create; -local new_cache = require "util.cache".new; - -local compat = module:get_option_boolean("registration_compat", true); local allow_registration = module:get_option_boolean("allow_registration", false); -local additional_fields = module:get_option("additional_registration_fields", {}); -local require_encryption = module:get_option("c2s_require_encryption") or module:get_option("require_encryption"); - -local account_details = module:open_store("account_details"); - -local field_map = { - username = { name = "username", type = "text-single", label = "Username", required = true }; - password = { name = "password", type = "text-private", label = "Password", required = true }; - nick = { name = "nick", type = "text-single", label = "Nickname" }; - name = { name = "name", type = "text-single", label = "Full Name" }; - first = { name = "first", type = "text-single", label = "Given Name" }; - last = { name = "last", type = "text-single", label = "Family Name" }; - email = { name = "email", type = "text-single", label = "Email" }; - address = { name = "address", type = "text-single", label = "Street" }; - city = { name = "city", type = "text-single", label = "City" }; - state = { name = "state", type = "text-single", label = "State" }; - zip = { name = "zip", type = "text-single", label = "Postal code" }; - phone = { name = "phone", type = "text-single", label = "Telephone number" }; - url = { name = "url", type = "text-single", label = "Webpage" }; - date = { name = "date", type = "text-single", label = "Birth date" }; -}; - -local title = module:get_option_string("registration_title", - "Creating a new account"); -local instructions = module:get_option_string("registration_instructions", - "Choose a username and password for use with this service."); - -local registration_form = dataform_new{ - title = title; - instructions = instructions; - - field_map.username; - field_map.password; -}; - -local registration_query = st.stanza("query", {xmlns = "jabber:iq:register"}) - :tag("instructions"):text(instructions):up() - :tag("username"):up() - :tag("password"):up(); - -for _, field in ipairs(additional_fields) do - if type(field) == "table" then - registration_form[#registration_form + 1] = field; - elseif field_map[field] or field_map[field:sub(1, -2)] then - if field:match("%+$") then - field = field:sub(1, -2); - field_map[field].required = true; - end - registration_form[#registration_form + 1] = field_map[field]; - registration_query:tag(field):up(); - else - module:log("error", "Unknown field %q", field); - end +if allow_registration then + module:depends("register_ibr"); + module:depends("register_limits"); end -registration_query:add_child(registration_form:form()); - -module:add_feature("jabber:iq:register"); - -local register_stream_feature = st.stanza("register", {xmlns="http://jabber.org/features/iq-register"}):up(); -module:hook("stream-features", function(event) - local session, features = event.origin, event.features; - - -- Advertise registration to unauthorized clients only. - if not(allow_registration) or session.type ~= "c2s_unauthed" or (require_encryption and not session.secure) then - return - end - - features:add_child(register_stream_feature); -end); - --- Password change and account deletion handler -local function handle_registration_stanza(event) - local session, stanza = event.origin, event.stanza; - local log = session.log or module._log; - - local query = stanza.tags[1]; - if stanza.attr.type == "get" then - local reply = st.reply(stanza); - reply:tag("query", {xmlns = "jabber:iq:register"}) - :tag("registered"):up() - :tag("username"):text(session.username):up() - :tag("password"):up(); - session.send(reply); - else -- stanza.attr.type == "set" - if query.tags[1] and query.tags[1].name == "remove" then - local username, host = session.username, session.host; - - -- This one weird trick sends a reply to this stanza before the user is deleted - local old_session_close = session.close; - session.close = function(self, ...) - self.send(st.reply(stanza)); - return old_session_close(self, ...); - end - - local ok, err = usermanager_delete_user(username, host); - - if not ok then - log("debug", "Removing user account %s@%s failed: %s", username, host, err); - session.close = old_session_close; - session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); - return true; - end - - log("info", "User removed their account: %s@%s", username, host); - module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session }); - else - local username = nodeprep(query:get_child_text("username")); - local password = query:get_child_text("password"); - if username and password then - if username == session.username then - if usermanager_set_password(username, password, session.host, session.resource) then - session.send(st.reply(stanza)); - else - -- TODO unable to write file, file may be locked, etc, what's the correct error? - session.send(st.error_reply(stanza, "wait", "internal-server-error")); - end - else - session.send(st.error_reply(stanza, "modify", "bad-request")); - end - else - session.send(st.error_reply(stanza, "modify", "bad-request")); - end - end - end - return true; -end - -module:hook("iq/self/jabber:iq:register:query", handle_registration_stanza); -if compat then - module:hook("iq/host/jabber:iq:register:query", function (event) - local session, stanza = event.origin, event.stanza; - if session.type == "c2s" and jid_bare(stanza.attr.to) == session.host then - return handle_registration_stanza(event); - end - end); -end - -local function parse_response(query) - local form = query:get_child("x", "jabber:x:data"); - if form then - return registration_form:data(form); - else - local data = {}; - local errors = {}; - for _, field in ipairs(registration_form) do - local name, required = field.name, field.required; - if field_map[name] then - data[name] = query:get_child_text(name); - if (not data[name] or #data[name] == 0) and required then - errors[name] = "Required value missing"; - end - end - end - if next(errors) then - return data, errors; - end - return data; - end -end - -local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations"); -local whitelist_only = module:get_option_boolean("whitelist_registration_only"); -local whitelisted_ips = module:get_option_set("registration_whitelist", { "127.0.0.1", "::1" })._items; -local blacklisted_ips = module:get_option_set("registration_blacklist", {})._items; - -local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1); -local throttle_period = module:get_option_number("registration_throttle_period", min_seconds_between_registrations); -local throttle_cache_size = module:get_option_number("registration_throttle_cache_size", 100); -local blacklist_overflow = module:get_option_boolean("blacklist_on_registration_throttle_overload", false); - -local throttle_cache = new_cache(throttle_cache_size, blacklist_overflow and function (ip, throttle) - if not throttle:peek() then - module:log("info", "Adding ip %s to registration blacklist", ip); - blacklisted_ips[ip] = true; - end -end or nil); - -local function check_throttle(ip) - if not throttle_max then return true end - local throttle = throttle_cache:get(ip); - if not throttle then - throttle = create_throttle(throttle_max, throttle_period); - end - throttle_cache:set(ip, throttle); - return throttle:poll(1); -end - --- In-band registration -module:hook("stanza/iq/jabber:iq:register:query", function(event) - local session, stanza = event.origin, event.stanza; - local log = session.log or module._log; - if not(allow_registration) or session.type ~= "c2s_unauthed" then - log("debug", "Attempted registration when disabled or already authenticated"); - session.send(st.error_reply(stanza, "cancel", "service-unavailable")); - elseif require_encryption and not session.secure then - session.send(st.error_reply(stanza, "modify", "policy-violation", "Encryption is required")); - else - local query = stanza.tags[1]; - if stanza.attr.type == "get" then - local reply = st.reply(stanza); - reply:add_child(registration_query); - session.send(reply); - elseif stanza.attr.type == "set" then - if query.tags[1] and query.tags[1].name == "remove" then - session.send(st.error_reply(stanza, "auth", "registration-required")); - else - local data, errors = parse_response(query); - if errors then - log("debug", "Error parsing registration form:"); - for field, err in pairs(errors) do - log("debug", "Field %q: %s", field, err); - end - session.send(st.error_reply(stanza, "modify", "not-acceptable")); - else - -- Check that the user is not blacklisted or registering too often - if not session.ip then - log("debug", "User's IP not known; can't apply blacklist/whitelist"); - elseif blacklisted_ips[session.ip] or (whitelist_only and not whitelisted_ips[session.ip]) then - session.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not allowed to register an account.")); - return true; - elseif throttle_max and not whitelisted_ips[session.ip] then - if not check_throttle(session.ip) then - log("debug", "Registrations over limit for ip %s", session.ip or "?"); - session.send(st.error_reply(stanza, "wait", "not-acceptable")); - return true; - end - end - local username, password = nodeprep(data.username), data.password; - data.username, data.password = nil, nil; - local host = module.host; - if not username or username == "" then - log("debug", "The requested username is invalid."); - session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is invalid.")); - return true; - end - local user = { username = username , host = host, additional = data, allowed = true } - module:fire_event("user-registering", user); - if not user.allowed then - log("debug", "Registration disallowed by module"); - session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is forbidden.")); - elseif usermanager_user_exists(username, host) then - log("debug", "Attempt to register with existing username"); - session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists.")); - else - -- TODO unable to write file, file may be locked, etc, what's the correct error? - local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk."); - if usermanager_create_user(username, password, host) then - data.registered = os.time(); - if not account_details:set(username, data) then - log("debug", "Could not store extra details"); - usermanager_delete_user(username, host); - session.send(error_reply); - return true; - end - session.send(st.reply(stanza)); -- user created! - log("info", "User account created: %s@%s", username, host); - module:fire_event("user-registered", { - username = username, host = host, source = "mod_register", - session = session }); - else - log("debug", "Could not create user"); - session.send(error_reply); - end - end - end - end - end - end - return true; -end); +module:depends("user_account_management"); diff --git a/plugins/mod_register_ibr.lua b/plugins/mod_register_ibr.lua new file mode 100644 index 00000000..abfc1079 --- /dev/null +++ b/plugins/mod_register_ibr.lua @@ -0,0 +1,195 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + + +local st = require "util.stanza"; +local dataform_new = require "util.dataforms".new; +local usermanager_user_exists = require "core.usermanager".user_exists; +local usermanager_create_user = require "core.usermanager".create_user; +local usermanager_delete_user = require "core.usermanager".delete_user; +local nodeprep = require "util.encodings".stringprep.nodeprep; + +local additional_fields = module:get_option("additional_registration_fields", {}); +local require_encryption = module:get_option_boolean("c2s_require_encryption", + module:get_option_boolean("require_encryption", false)); + +local account_details = module:open_store("account_details"); + +local field_map = { + username = { name = "username", type = "text-single", label = "Username", required = true }; + password = { name = "password", type = "text-private", label = "Password", required = true }; + nick = { name = "nick", type = "text-single", label = "Nickname" }; + name = { name = "name", type = "text-single", label = "Full Name" }; + first = { name = "first", type = "text-single", label = "Given Name" }; + last = { name = "last", type = "text-single", label = "Family Name" }; + email = { name = "email", type = "text-single", label = "Email" }; + address = { name = "address", type = "text-single", label = "Street" }; + city = { name = "city", type = "text-single", label = "City" }; + state = { name = "state", type = "text-single", label = "State" }; + zip = { name = "zip", type = "text-single", label = "Postal code" }; + phone = { name = "phone", type = "text-single", label = "Telephone number" }; + url = { name = "url", type = "text-single", label = "Webpage" }; + date = { name = "date", type = "text-single", label = "Birth date" }; +}; + +local title = module:get_option_string("registration_title", + "Creating a new account"); +local instructions = module:get_option_string("registration_instructions", + "Choose a username and password for use with this service."); + +local registration_form = dataform_new{ + title = title; + instructions = instructions; + + field_map.username; + field_map.password; +}; + +local registration_query = st.stanza("query", {xmlns = "jabber:iq:register"}) + :tag("instructions"):text(instructions):up() + :tag("username"):up() + :tag("password"):up(); + +for _, field in ipairs(additional_fields) do + if type(field) == "table" then + registration_form[#registration_form + 1] = field; + elseif field_map[field] or field_map[field:sub(1, -2)] then + if field:match("%+$") then + field = field:sub(1, -2); + field_map[field].required = true; + end + + registration_form[#registration_form + 1] = field_map[field]; + registration_query:tag(field):up(); + else + module:log("error", "Unknown field %q", field); + end +end +registration_query:add_child(registration_form:form()); + +local register_stream_feature = st.stanza("register", {xmlns="http://jabber.org/features/iq-register"}):up(); +module:hook("stream-features", function(event) + local session, features = event.origin, event.features; + + -- Advertise registration to unauthorized clients only. + if session.type ~= "c2s_unauthed" or (require_encryption and not session.secure) then + return + end + + features:add_child(register_stream_feature); +end); + +local function parse_response(query) + local form = query:get_child("x", "jabber:x:data"); + if form then + return registration_form:data(form); + else + local data = {}; + local errors = {}; + for _, field in ipairs(registration_form) do + local name, required = field.name, field.required; + if field_map[name] then + data[name] = query:get_child_text(name); + if (not data[name] or #data[name] == 0) and required then + errors[name] = "Required value missing"; + end + end + end + if next(errors) then + return data, errors; + end + return data; + end +end + +-- In-band registration +module:hook("stanza/iq/jabber:iq:register:query", function(event) + local session, stanza = event.origin, event.stanza; + local log = session.log or module._log; + + if session.type ~= "c2s_unauthed" then + log("debug", "Attempted registration when disabled or already authenticated"); + session.send(st.error_reply(stanza, "cancel", "service-unavailable")); + return true; + end + + if require_encryption and not session.secure then + session.send(st.error_reply(stanza, "modify", "policy-violation", "Encryption is required")); + return true; + end + + local query = stanza.tags[1]; + if stanza.attr.type == "get" then + local reply = st.reply(stanza); + reply:add_child(registration_query); + session.send(reply); + return true; + end + + -- stanza.attr.type == "set" + if query.tags[1] and query.tags[1].name == "remove" then + session.send(st.error_reply(stanza, "auth", "registration-required")); + return true; + end + + local data, errors = parse_response(query); + if errors then + log("debug", "Error parsing registration form:"); + local textual_errors = {}; + for field, err in pairs(errors) do + log("debug", "Field %q: %s", field, err); + table.insert(textual_errors, ("%s: %s"):format(field:gsub("^%a", string.upper), err)); + end + session.send(st.error_reply(stanza, "modify", "not-acceptable", table.concat(textual_errors, "\n"))); + return true; + end + + local username, password = nodeprep(data.username), data.password; + data.username, data.password = nil, nil; + local host = module.host; + if not username or username == "" then + log("debug", "The requested username is invalid."); + session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is invalid.")); + return true; + end + + local user = { username = username, password = password, host = host, additional = data, ip = session.ip, session = session, allowed = true } + module:fire_event("user-registering", user); + if not user.allowed then + log("debug", "Registration disallowed by module: %s", user.reason or "no reason given"); + session.send(st.error_reply(stanza, "modify", "not-acceptable", user.reason)); + return true; + end + + if usermanager_user_exists(username, host) then + log("debug", "Attempt to register with existing username"); + session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists.")); + return true; + end + + -- TODO unable to write file, file may be locked, etc, what's the correct error? + local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk."); + if usermanager_create_user(username, password, host) then + data.registered = os.time(); + if not account_details:set(username, data) then + log("debug", "Could not store extra details"); + usermanager_delete_user(username, host); + session.send(error_reply); + return true; + end + session.send(st.reply(stanza)); -- user created! + log("info", "User account created: %s@%s", username, host); + module:fire_event("user-registered", { + username = username, host = host, source = "mod_register", + session = session }); + else + log("debug", "Could not create user"); + session.send(error_reply); + end + return true; +end); diff --git a/plugins/mod_register_limits.lua b/plugins/mod_register_limits.lua new file mode 100644 index 00000000..736282a5 --- /dev/null +++ b/plugins/mod_register_limits.lua @@ -0,0 +1,78 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + + +local create_throttle = require "util.throttle".create; +local new_cache = require "util.cache".new; +local ip_util = require "util.ip"; +local new_ip = ip_util.new_ip; +local match_ip = ip_util.match; +local parse_cidr = ip_util.parse_cidr; + +local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations"); +local whitelist_only = module:get_option_boolean("whitelist_registration_only"); +local whitelisted_ips = module:get_option_set("registration_whitelist", { "127.0.0.1", "::1" })._items; +local blacklisted_ips = module:get_option_set("registration_blacklist", {})._items; + +local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1); +local throttle_period = module:get_option_number("registration_throttle_period", min_seconds_between_registrations); +local throttle_cache_size = module:get_option_number("registration_throttle_cache_size", 100); +local blacklist_overflow = module:get_option_boolean("blacklist_on_registration_throttle_overload", false); + +local throttle_cache = new_cache(throttle_cache_size, blacklist_overflow and function (ip, throttle) + if not throttle:peek() then + module:log("info", "Adding ip %s to registration blacklist", ip); + blacklisted_ips[ip] = true; + end +end or nil); + +local function check_throttle(ip) + if not throttle_max then return true end + local throttle = throttle_cache:get(ip); + if not throttle then + throttle = create_throttle(throttle_max, throttle_period); + end + throttle_cache:set(ip, throttle); + return throttle:poll(1); +end + +local function ip_in_set(set, ip) + if set[ip] then + return true; + end + ip = new_ip(ip); + for in_set in pairs(set) do + if match_ip(ip, parse_cidr(in_set)) then + return true; + end + end + return false; +end + +module:hook("user-registering", function (event) + local session = event.session; + local ip = event.ip or session and session.ip; + local log = session and session.log or module._log; + if not ip then + log("warn", "IP not known; can't apply blacklist/whitelist"); + elseif ip_in_set(blacklisted_ips, ip) then + log("debug", "Registration disallowed by blacklist"); + event.allowed = false; + event.reason = "Your IP address is blacklisted"; + elseif (whitelist_only and not ip_in_set(whitelisted_ips, ip)) then + log("debug", "Registration disallowed by whitelist"); + event.allowed = false; + event.reason = "Your IP address is not whitelisted"; + elseif throttle_max and not ip_in_set(whitelisted_ips, ip) then + if not check_throttle(ip) then + log("debug", "Registrations over limit for ip %s", ip or "?"); + event.allowed = false; + event.reason = "Too many registrations from this IP address recently"; + end + end +end); diff --git a/plugins/mod_roster.lua b/plugins/mod_roster.lua index 24c50678..39d59cbd 100644 --- a/plugins/mod_roster.lua +++ b/plugins/mod_roster.lua @@ -11,9 +11,8 @@ local st = require "util.stanza" local jid_split = require "util.jid".split; local jid_prep = require "util.jid".prep; -local t_concat = table.concat; local tonumber = tonumber; -local pairs, ipairs = pairs, ipairs; +local pairs = pairs; local rm_load_roster = require "core.rostermanager".load_roster; local rm_remove_from_roster = require "core.rostermanager".remove_from_roster; @@ -51,7 +50,7 @@ module:hook("iq/self/jabber:iq:roster:query", function(event) name = item.name, }); for group in pairs(item.groups) do - roster:tag("group"):text(group):up(); + roster:text_tag("group", group); end roster:up(); -- move out from item end @@ -96,12 +95,10 @@ module:hook("iq/self/jabber:iq:roster:query", function(event) else r_item.subscription = "none"; end - for _, child in ipairs(item) do - if child.name == "group" then - local text = t_concat(child); - if text and text ~= "" then - r_item.groups[text] = true; - end + for group in item:childtags("group") do + local text = group:get_text(); + if text then + r_item.groups[text] = true; end end local success, err_type, err_cond, err_msg = rm_add_to_roster(session, jid, r_item); diff --git a/plugins/mod_s2s/mod_s2s.lua b/plugins/mod_s2s/mod_s2s.lua index 0857f08e..aae37b7f 100644 --- a/plugins/mod_s2s/mod_s2s.lua +++ b/plugins/mod_s2s/mod_s2s.lua @@ -14,7 +14,7 @@ local core_process_stanza = prosody.core_process_stanza; local tostring, type = tostring, type; local t_insert = table.insert; -local xpcall, traceback = xpcall, debug.traceback; +local traceback = debug.traceback; local add_task = require "util.timer".add_task; local st = require "util.stanza"; @@ -26,6 +26,7 @@ local s2s_new_outgoing = require "core.s2smanager".new_outgoing; local s2s_destroy_session = require "core.s2smanager".destroy_session; local uuid_gen = require "util.uuid".generate; local fire_global_event = prosody.events.fire_event; +local runner = require "util.async".runner; local s2sout = module:require("s2sout"); @@ -38,17 +39,25 @@ local secure_domains, insecure_domains = local require_encryption = module:get_option_boolean("s2s_require_encryption", false); local measure_connections = module:measure("connections", "amount"); +local measure_ipv6 = module:measure("ipv6", "amount"); local sessions = module:shared("sessions"); +local runner_callbacks = {}; + local log = module._log; module:hook("stats-update", function () local count = 0; - for _ in pairs(sessions) do + local ipv6 = 0; + for _, session in pairs(sessions) do count = count + 1; + if session.ip and session.ip:match(":") then + ipv6 = ipv6 + 1; + end end measure_connections(count); + measure_ipv6(ipv6); end); --- Handle stanzas to remote domains @@ -57,19 +66,22 @@ local bouncy_stanzas = { message = true, presence = true, iq = true }; local function bounce_sendq(session, reason) local sendq = session.sendq; if not sendq then return; end - session.log("info", "Sending error replies for "..#sendq.." queued stanzas because of failed outgoing connection to "..tostring(session.to_host)); + session.log("info", "Sending error replies for %d queued stanzas because of failed outgoing connection to %s", #sendq, session.to_host); local dummy = { type = "s2sin"; - send = function(s) + send = function () (session.log or log)("error", "Replying to to an s2s error reply, please report this! Traceback: %s", traceback()); end; dummy = true; + close = function () + (session.log or log)("error", "Attempting to close the dummy origin of s2s error replies, please report this! Traceback: %s", traceback()); + end; }; for i, data in ipairs(sendq) do local reply = data[2]; if reply and not(reply.attr.xmlns) and bouncy_stanzas[reply.name] then reply.attr.type = "error"; - reply:tag("error", {type = "cancel"}) + reply:tag("error", {type = "cancel", by = session.from_host}) :tag("remote-server-not-found", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up(); if reason then reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}) @@ -100,8 +112,16 @@ function route_to_existing_session(event) (host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host); -- Queue stanza until we are able to send it - if host.sendq then t_insert(host.sendq, {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)}); - else host.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} }; end + local queued_item = { + tostring(stanza), + stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza); + }; + if host.sendq then + t_insert(host.sendq, queued_item); + else + -- luacheck: ignore 122 + host.sendq = { queued_item }; + end host.log("debug", "stanza [%s] queued ", stanza.name); return true; elseif host.type == "local" or host.type == "component" then @@ -113,7 +133,7 @@ function route_to_existing_session(event) -- FIXME if host.from_host ~= from_host then log("error", "WARNING! This might, possibly, be a bug, but it might not..."); - log("error", "We are going to send from %s instead of %s", tostring(host.from_host), tostring(from_host)); + log("error", "We are going to send from %s instead of %s", host.from_host, from_host); end if host.sends2s(stanza) then return true; @@ -149,14 +169,14 @@ module:hook("s2s-read-timeout", keepalive, -1); function module.add_host(module) if module:get_option_boolean("disallow_s2s", false) then - module:log("warn", "The 'disallow_s2s' config option is deprecated, please see http://prosody.im/doc/s2s#disabling"); + module:log("warn", "The 'disallow_s2s' config option is deprecated, please see https://prosody.im/doc/s2s#disabling"); return nil, "This host has disallow_s2s set"; end module:hook("route/remote", route_to_existing_session, -1); module:hook("route/remote", route_to_new_session, -10); module:hook("s2s-authenticated", make_authenticated, -1); module:hook("s2s-read-timeout", keepalive, -1); - module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) + module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) -- luacheck: ignore 212/stanza if session.type == "s2sout" then -- Stream is authenticated and we are seem to be done with feature negotiation, -- so the stream is ready for stanzas. RFC 6120 Section 4.3 @@ -265,11 +285,21 @@ end --- XMPP stream event handlers -local stream_callbacks = { default_ns = "jabber:server", handlestanza = core_process_stanza }; +local stream_callbacks = { default_ns = "jabber:server" }; + +function stream_callbacks.handlestanza(session, stanza) + stanza = session.filter("stanzas/in", stanza); + session.thread:run(stanza); +end local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; function stream_callbacks.streamopened(session, attr) + -- run _streamopened in async context + session.thread:run({ attr = attr }); +end + +function stream_callbacks._streamopened(session, attr) session.version = tonumber(attr.version) or 0; -- TODO: Rename session.secure to session.encrypted @@ -364,7 +394,7 @@ function stream_callbacks.streamopened(session, attr) end if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then - log("debug", "Sending stream features: %s", tostring(features)); + log("debug", "Sending stream features: %s", features); session.sends2s(features); else (session.log or log)("warn", "No stream features to offer, giving up"); @@ -421,7 +451,7 @@ function stream_callbacks.error(session, error, data) session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}"))); session:close("invalid-namespace"); elseif error == "parse-error" then - session.log("debug", "Server-to-server XML parse error: %s", tostring(error)); + session.log("debug", "Server-to-server XML parse error: %s", error); session:close("not-well-formed"); elseif error == "stream-error" then local condition, text = "undefined-condition"; @@ -441,14 +471,6 @@ function stream_callbacks.error(session, error, data) end end -local function handleerr(err) log("error", "Traceback[s2s]: %s", traceback(tostring(err), 2)); end -function stream_callbacks.handlestanza(session, stanza) - stanza = session.filter("stanzas/in", stanza); - if stanza then - return xpcall(function () return core_process_stanza(session, stanza) end, handleerr); - end -end - local listener = {}; --- Session methods @@ -476,10 +498,13 @@ local function session_close(session, reason, remote_reason) if reason.extra then stanza:add_child(reason.extra); end - log("debug", "Disconnecting %s[%s], <stream:error> is: %s", session.host or session.ip or "(unknown host)", session.type, tostring(stanza)); + log("debug", "Disconnecting %s[%s], <stream:error> is: %s", + session.host or session.ip or "(unknown host)", session.type, stanza); session.sends2s(stanza); elseif reason.name then -- a stanza - log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s", session.from_host or "(unknown host)", session.to_host or "(unknown host)", session.type, tostring(reason)); + log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s", + session.from_host or "(unknown host)", session.to_host or "(unknown host)", + session.type, reason); session.sends2s(reason); end end @@ -488,8 +513,11 @@ local function session_close(session, reason, remote_reason) session.sends2s("</stream:stream>"); function session.sends2s() return false; end + -- luacheck: ignore 422/reason + -- FIXME reason should be managed in a place common to c2s, s2s, bosh, component etc local reason = remote_reason or (reason and (reason.text or reason.condition)) or reason; - session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper), session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed"); + session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper), + session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed"); -- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote local conn = session.conn; @@ -508,7 +536,7 @@ local function session_close(session, reason, remote_reason) end end -function session_stream_attrs(session, from, to, attr) +function session_stream_attrs(session, from, to, attr) -- luacheck: ignore 212/session if not from or (hosts[from] and hosts[from].modules.dialback) then attr["xmlns:db"] = 'jabber:server:dialback'; end @@ -523,6 +551,15 @@ end -- Session initialization logic shared by incoming and outgoing local function initialize_session(session) local stream = new_xmpp_stream(session, stream_callbacks); + + session.thread = runner(function (stanza) + if stanza.name == nil then + stream_callbacks._streamopened(session, stanza.attr); + else + core_process_stanza(session, stanza); + end + end, runner_callbacks, session); + local log = session.log or log; session.stream = stream; @@ -567,7 +604,7 @@ local function initialize_session(session) session.close = session_close; local handlestanza = stream_callbacks.handlestanza; - function session.dispatch_stanza(session, stanza) + function session.dispatch_stanza(session, stanza) -- luacheck: ignore 432/session return handlestanza(session, stanza); end @@ -586,6 +623,20 @@ local function initialize_session(session) end); end +function runner_callbacks:ready() + self.data.log("debug", "Runner %s ready (%s)", self.thread, coroutine.status(self.thread)); + self.data.conn:resume(); +end + +function runner_callbacks:waiting() + self.data.log("debug", "Runner %s waiting (%s)", self.thread, coroutine.status(self.thread)); + self.data.conn:pause(); +end + +function runner_callbacks:error(err) + (self.data.log or log)("error", "Traceback[s2s]: %s", err); +end + function listener.onconnect(conn) conn:setoption("keepalive", opt_keepalives); local session = sessions[conn]; @@ -627,7 +678,7 @@ function listener.ondisconnect(conn, err) return; -- Session lives for now end end - (session.log or log)("debug", "s2s disconnected: %s->%s (%s)", tostring(session.from_host), tostring(session.to_host), tostring(err or "connection closed")); + (session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed"); s2s_destroy_session(session, err); end end diff --git a/plugins/mod_s2s/s2sout.lib.lua b/plugins/mod_s2s/s2sout.lib.lua index 122ab6a9..5f765da8 100644 --- a/plugins/mod_s2s/s2sout.lib.lua +++ b/plugins/mod_s2s/s2sout.lib.lua @@ -8,6 +8,8 @@ --- Module containing all the logic for connecting to a remote server +-- luacheck: ignore 432/err + local portmanager = require "core.portmanager"; local wrapclient = require "net.server".wrapclient; local initialize_filters = require "util.filters".initialize; @@ -16,7 +18,6 @@ local new_ip = require "util.ip".new_ip; local rfc6724_dest = require "util.rfc6724".destination; local socket = require "socket"; local adns = require "net.adns"; -local dns = require "net.dns"; local t_insert, t_sort, ipairs = table.insert, table.sort, ipairs; local local_addresses = require "util.net".local_addresses; @@ -30,6 +31,7 @@ local sources = {}; local has_ipv4, has_ipv6; local dns_timeout = module:get_option_number("dns_timeout", 15); +local resolvers = module:get_option_set("s2s_dns_resolvers") local s2sout = {}; @@ -45,11 +47,18 @@ local function compare_srv_priorities(a,b) end function s2sout.initiate_connection(host_session) + local log = host_session.log or log; + initialize_filters(host_session); host_session.version = 1; host_session.resolver = adns.resolver(); host_session.resolver._resolver:settimeout(dns_timeout); + if resolvers then + for resolver in resolvers do + host_session.resolver._resolver:addnameserver(resolver); + end + end -- Kick the connection attempting machine into life if not s2sout.attempt_connection(host_session) then @@ -68,9 +77,9 @@ function s2sout.initiate_connection(host_session) buffer = {}; host_session.send_buffer = buffer; end - log("debug", "Buffering data on unconnected s2sout to %s", tostring(host_session.to_host)); + log("debug", "Buffering data on unconnected s2sout to %s", host_session.to_host); buffer[#buffer+1] = data; - log("debug", "Buffered item %d: %s", #buffer, tostring(data)); + log("debug", "Buffered item %d: %s", #buffer, data); end end end @@ -78,6 +87,7 @@ end function s2sout.attempt_connection(host_session, err) local to_host = host_session.to_host; local connect_host, connect_port = to_host and idna_to_ascii(to_host), 5269; + local log = host_session.log or log; if not connect_host then return false; @@ -129,16 +139,16 @@ function s2sout.attempt_connection(host_session, err) host_session.srv_choice = host_session.srv_choice + 1; local srv_choice = host_session.srv_hosts[host_session.srv_choice]; connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port; - host_session.log("info", "Connection failed (%s). Attempt #%d: This time to %s:%d", tostring(err), host_session.srv_choice, connect_host, connect_port); + host_session.log("info", "Connection failed (%s). Attempt #%d: This time to %s:%d", err, host_session.srv_choice, connect_host, connect_port); else - host_session.log("info", "Failed in all attempts to connect to %s", tostring(host_session.to_host)); + host_session.log("info", "Failed in all attempts to connect to %s", host_session.to_host); -- We're out of options return false; end if not (connect_host and connect_port) then -- Likely we couldn't resolve DNS - log("warn", "Hmm, we're without a host (%s) and port (%s) to connect to for %s, giving up :(", tostring(connect_host), tostring(connect_port), tostring(to_host)); + log("warn", "Hmm, we're without a host (%s) and port (%s) to connect to for %s, giving up :(", connect_host, connect_port, to_host); return false; end @@ -160,10 +170,12 @@ end function s2sout.try_connect(host_session, connect_host, connect_port, err) host_session.connecting = true; + local log = host_session.log or log; if not err then local IPs = {}; host_session.ip_hosts = IPs; + -- luacheck: ignore 231/handle4 231/handle6 local handle4, handle6; local have_other_result = not(has_ipv4) or not(has_ipv6) or false; @@ -246,6 +258,7 @@ function s2sout.try_connect(host_session, connect_host, connect_port, err) elseif host_session.ip_hosts and #host_session.ip_hosts > host_session.ip_choice then -- Not our first attempt, and we also have IPs left to try s2sout.try_next_ip(host_session); else + log("debug", "Out of IP addresses, trying next SRV record (if any)"); host_session.ip_hosts = nil; if not s2sout.attempt_connection(host_session, "out of IP addresses") then -- Retry if we can log("debug", "No other records to try for %s - destroying", host_session.to_host); @@ -259,7 +272,8 @@ function s2sout.try_connect(host_session, connect_host, connect_port, err) end function s2sout.make_connect(host_session, connect_host, connect_port) - (host_session.log or log)("debug", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port); + local log = host_session.log or log; + log("debug", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port); -- Reset secure flag in case this is another -- connection attempt after a failed STARTTLS diff --git a/plugins/mod_scansion_record.lua b/plugins/mod_scansion_record.lua new file mode 100644 index 00000000..3fbc8b6b --- /dev/null +++ b/plugins/mod_scansion_record.lua @@ -0,0 +1,117 @@ +local names = { "Romeo", "Juliet", "Mercutio", "Tybalt", "Benvolio" }; +local devices = { "", "phone", "laptop", "tablet", "toaster", "fridge", "shoe" }; +local users = {}; + +local filters = require "util.filters"; +local id = require "util.id"; +local dt = require "util.datetime"; +local dm = require "util.datamanager"; + +local record_id = id.medium():lower(); +local record_date = os.date("%Y%b%d"):lower(); +local header_file = dm.getpath(record_id, "scansion", record_date, "scs", true); +local record_file = dm.getpath(record_id, "scansion", record_date, "log", true); + +local head = io.open(header_file, "w"); +local scan = io.open(record_file, "w+"); + +local function record(string) + scan:write(string); +end + +local function record_header(string) + head:write(string); +end + +local function record_object(class, name, props) + head:write(("[%s] %s\n"):format(class, name)); + for k,v in pairs(props) do + head:write(("\t%s: %s\n"):format(k, v)); + end + head:write("\n"); +end + +local function record_event(session, event) + record(session.scansion_id.." "..event.."\n\n"); +end + +local function record_stanza(stanza, session, verb) + record(session.scansion_id.." "..verb..":\n\t"..tostring(stanza).."\n\n"); +end + +local function record_stanza_in(stanza, session) + if stanza.attr.xmlns == nil then + record_stanza(stanza, session, "sends") + end + return stanza; +end + +local function record_stanza_out(stanza, session) + if stanza.attr.xmlns == nil then + if not (stanza.name == "iq" and stanza:get_child("bind", "urn:ietf:params:xml:ns:xmpp-bind")) then + record_stanza(stanza, session, "receives"); + end + end + return stanza; +end + +module:hook("resource-bind", function (event) + local session = event.session; + if not users[session.username] then + users[session.username] = { + character = table.remove(names, 1) or id.short(); + devices = {}; + n_devices = 0; + }; + end + local user = users[session.username]; + local device = user.devices[session.resource]; + if not device then + user.n_devices = user.n_devices + 1; + device = devices[user.n_devices] or ("device"..id.short()); + user.devices[session.resource] = device; + end + session.scansion_character = user.character; + session.scansion_device = device; + session.scansion_id = user.character..(device ~= "" and "'s "..device or device); + + record_object("Client", session.scansion_id, { + jid = session.full_jid, + password = "password", + }); + + module:log("info", "Connected: %s", session.scansion_id); + record_event(session, "connects"); + + filters.add_filter(session, "stanzas/in", record_stanza_in); + filters.add_filter(session, "stanzas/out", record_stanza_out); +end); + +module:hook("resource-unbind", function (event) + local session = event.session; + if session.scansion_id then + record_event(session, "disconnects"); + end +end) + +record_header("# mod_scansion_record on host '"..module.host.."' recording started "..dt.datetime().."\n\n"); + +record[[ +----- + +]] + +module:hook_global("server-stopping", function () + record("# recording ended on "..dt.datetime().."\n"); + module:log("info", "Scansion recording available in %s", header_file); +end); + +prosody.events.add_handler("server-cleanup", function () + scan:seek("set", 0); + for line in scan:lines() do + head:write(line, "\n"); + end + scan:close(); + os.remove(record_file); + head:close() +end); diff --git a/plugins/mod_server_contact_info.lua b/plugins/mod_server_contact_info.lua index 7ee8a08f..0110ceaf 100644 --- a/plugins/mod_server_contact_info.lua +++ b/plugins/mod_server_contact_info.lua @@ -1,49 +1,29 @@ -- XEP-0157: Contact Addresses for XMPP Services for Prosody -- --- Copyright (C) 2011-2016 Kim Alvefur +-- Copyright (C) 2011-2018 Kim Alvefur -- --- This file is MIT/X11 licensed. +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. -- -local t_insert = table.insert; local array = require "util.array"; -local df_new = require "util.dataforms".new; -- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo -local valid_types = { - abuse = true; - admin = true; - feedback = true; - sales = true; - security = true; - support = true; -} +local form_layout = require "util.dataforms".new({ + { var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo"; }; + { name = "abuse", var = "abuse-addresses", type = "list-multi" }, + { name = "admin", var = "admin-addresses", type = "list-multi" }, + { name = "feedback", var = "feedback-addresses", type = "list-multi" }, + { name = "sales", var = "sales-addresses", type = "list-multi" }, + { name = "security", var = "security-addresses", type = "list-multi" }, + { name = "support", var = "support-addresses", type = "list-multi" }, +}); -local contact_config = module:get_option("contact_info"); -if not contact_config or not next(contact_config) then -- we'll use admins from the config as default - local admins = module:get_option_inherited_set("admins", {}); - if admins:empty() then - module:log("error", "No contact_info or admins set in config"); - return -- Nothing to attach, so we'll just skip it. - end - module:log("info", "No contact_info in config, using admins as fallback"); - contact_config = { - admin = array.collect( admins / function(admin) return "xmpp:" .. admin; end); - }; -end +-- JIDs of configured service admins are used as fallback +local admins = module:get_option_inherited_set("admins", {}); -local form_layout = { - { value = "http://jabber.org/network/serverinfo"; type = "hidden"; name = "FORM_TYPE"; }; -}; +local contact_config = module:get_option("contact_info", { + admin = array.collect( admins / function(admin) return "xmpp:" .. admin; end); +}); -local form_values = {}; - -for t in pairs(valid_types) do - local addresses = contact_config[t]; - if addresses then - t_insert(form_layout, { name = t .. "-addresses", type = "list-multi" }); - form_values[t .. "-addresses"] = addresses; - end -end - -module:add_extension(df_new(form_layout):form(form_values, "result")); +module:add_extension(form_layout:form(contact_config, "result")); diff --git a/plugins/mod_storage_internal.lua b/plugins/mod_storage_internal.lua index 76052575..42b451bd 100644 --- a/plugins/mod_storage_internal.lua +++ b/plugins/mod_storage_internal.lua @@ -44,17 +44,36 @@ local archive = {}; driver.archive = { __index = archive }; function archive:append(username, key, value, when, with) - key = key or id(); when = when or now(); if not st.is_stanza(value) then return nil, "unsupported-datatype"; end value = st.preserialize(st.clone(value)); - value.key = key; value.when = when; value.with = with; value.attr.stamp = datetime.datetime(when); value.attr.stamp_legacy = datetime.legacy(when); + + if key then + local items, err = datamanager.list_load(username, host, self.store); + if not items and err then return items, err; end + if items then + items = array(items); + items:filter(function (item) + return item.key ~= key; + end); + value.key = key; + items:push(value); + local ok, err = datamanager.list_store(username, host, self.store, items); + if not ok then return ok, err; end + return key; + end + else + key = id(); + end + + value.key = key; + local ok, err = datamanager.list_append(username, host, self.store, value); if not ok then return ok, err; end return key; @@ -141,9 +160,6 @@ function archive:delete(username, query) if not query or next(query) == nil then return datamanager.list_store(username, host, self.store, nil); end - for k in pairs(query) do - if k ~= "end" then return nil, "unsupported-query-field"; end - end local items, err = datamanager.list_load(username, host, self.store); if not items then if err then @@ -154,10 +170,48 @@ function archive:delete(username, query) end items = array(items); local count_before = #items; - items:filter(function (item) - return item.when > query["end"]; - end); + if query then + if query.key then + items:filter(function (item) + return item.key ~= query.key; + end); + end + if query.with then + items:filter(function (item) + return item.with ~= query.with; + end); + end + if query.start then + items:filter(function (item) + return item.when < query.start; + end); + end + if query["end"] then + items:filter(function (item) + return item.when > query["end"]; + end); + end + if query.truncate and #items > query.truncate then + if query.reverse then + -- Before: { 1, 2, 3, 4, 5, } + -- After: { 1, 2, 3 } + for i = #items, query.truncate + 1, -1 do + items[i] = nil; + end + else + -- Before: { 1, 2, 3, 4, 5, } + -- After: { 3, 4, 5 } + local offset = #items - query.truncate; + for i = 1, #items do + items[i] = items[i+offset]; + end + end + end + end local count = count_before - #items; + if count == 0 then + return 0; -- No changes, skip write + end local ok, err = datamanager.list_store(username, host, self.store, items); if not ok then return ok, err; end return count; diff --git a/plugins/mod_storage_memory.lua b/plugins/mod_storage_memory.lua new file mode 100644 index 00000000..ec29fba6 --- /dev/null +++ b/plugins/mod_storage_memory.lua @@ -0,0 +1,178 @@ +local serialize = require "util.serialization".serialize; +local envload = require "util.envload".envload; +local st = require "util.stanza"; +local is_stanza = st.is_stanza or function (s) return getmetatable(s) == st.stanza_mt end + +local auto_purge_enabled = module:get_option_boolean("storage_memory_temporary", false); +local auto_purge_stores = module:get_option_set("storage_memory_temporary_stores", {}); + +local memory = setmetatable({}, { + __index = function(t, k) + local store = module:shared(k) + t[k] = store; + return store; + end +}); + +local function NULL() return nil end + +local function _purge_store(self, username) + self.store[username or NULL] = nil; + return true; +end + +local keyval_store = {}; +keyval_store.__index = keyval_store; + +function keyval_store:get(username) + return (self.store[username or NULL] or NULL)(); +end + +function keyval_store:set(username, data) + if data ~= nil then + data = envload("return "..serialize(data), "=(data)", {}); + end + self.store[username or NULL] = data; + return true; +end + +keyval_store.purge = _purge_store; + +local archive_store = {}; +archive_store.__index = archive_store; + +function archive_store:append(username, key, value, when, with) + if type(when) ~= "number" then + when, with, value = value, when, with; + end + if is_stanza(value) then + value = st.preserialize(value); + value = envload("return xml"..serialize(value), "=(stanza)", { xml = st.deserialize }) + else + value = envload("return "..serialize(value), "=(data)", {}); + end + local a = self.store[username or NULL]; + if not a then + a = {}; + self.store[username or NULL] = a; + end + local i = #a+1; + local v = { key = key, when = when, with = with, value = value }; + if not key then + key = tostring(a):match"%x+$"..tostring(v):match"%x+$"; + v.key = key; + end + if a[key] then + table.remove(a, a[key]); + end + a[i] = v; + a[key] = i; + return key; +end + +local function archive_iter (a, start, stop, step, limit, when_start, when_end, match_with) + local item, when, with; + local count = 0; + coroutine.yield(true); -- Ready + for i = start, stop, step do + item = a[i]; + when, with = item.when, item.with; + if when >= when_start and when_end >= when and (not match_with or match_with == with) then + coroutine.yield(item.key, item.value(), when, with); + count = count + 1; + if limit and count >= limit then return end + end + end +end + +function archive_store:find(username, query) + local a = self.store[username or NULL] or {}; + local start, stop, step = 1, #a, 1; + local qstart, qend, qwith = -math.huge, math.huge; + local limit; + if query then + module:log("debug", "query included") + if query.reverse then + start, stop, step = stop, start, -1; + if query.before then + start = a[query.before]; + end + elseif query.after then + start = a[query.after]; + end + limit = query.limit; + qstart = query.start or qstart; + qend = query["end"] or qend; + qwith = query.with; + end + if not start then return nil, "invalid-key"; end + local iter = coroutine.wrap(archive_iter); + iter(a, start, stop, step, limit, qstart, qend, qwith); + return iter; +end + +function archive_store:delete(username, query) + if not query or next(query) == nil then + self.store[username or NULL] = nil; + return true; + end + local old = self.store[username or NULL]; + if not old then return true; end + local qstart = query.start or -math.huge; + local qend = query["end"] or math.huge; + local qwith = query.with; + local new = {}; + self.store[username or NULL] = new; + local t; + for i = 1, #old do + i = old[i]; + t = i.when; + if not(qstart >= t and qend <= t and (not qwith or i.with == qwith)) then + self:append(username, i.key, i.value(), t, i.with); + end + end + if #new == 0 then + self.store[username or NULL] = nil; + end + return true; +end + +archive_store.purge = _purge_store; + +local stores = { + keyval = keyval_store; + archive = archive_store; +} + +local driver = {}; + +function driver:open(store, typ) -- luacheck: ignore 212/self + local store_mt = stores[typ or "keyval"]; + if store_mt then + return setmetatable({ store = memory[store] }, store_mt); + end + return nil, "unsupported-store"; +end + +if auto_purge_enabled then + module:hook("resource-unbind", function (event) + local user_bare_jid = event.session.username.."@"..event.session.host; + if not prosody.bare_sessions[user_bare_jid] then -- User went offline + module:log("debug", "Clearing store for offline user %s", user_bare_jid); + local f, s, v; + if auto_purge_stores:empty() then + f, s, v = pairs(memory); + else + f, s, v = auto_purge_stores:items(); + end + + for store_name in f, s, v do + if memory[store_name] then + memory[store_name][event.session.username] = nil; + end + end + end + end); +end + +module:provides("storage", driver); diff --git a/plugins/mod_storage_sql.lua b/plugins/mod_storage_sql.lua index 13c961f8..ae6a2676 100644 --- a/plugins/mod_storage_sql.lua +++ b/plugins/mod_storage_sql.lua @@ -43,12 +43,17 @@ local function deserialize(t, value) elseif t == "boolean" then if value == "true" then return true; elseif value == "false" then return false; end - elseif t == "number" then return tonumber(value); + return nil, "invalid-boolean"; + elseif t == "number" then + value = tonumber(value); + if value then return value; end + return nil, "invalid-number"; elseif t == "json" then return json.decode(value); elseif t == "xml" then return xml_parse(value); end + return nil, "Unhandled value type: "..t; end local host = module.host; @@ -65,7 +70,8 @@ local function keyval_store_get() for row in engine:select(select_sql, host, user or "", store) do haveany = true; local k = row[1]; - local v = deserialize(row[2], row[3]); + local v, e = deserialize(row[2], row[3]); + assert(v ~= nil, e); if k and v then if k ~= "" then result[k] = v; elseif type(v) == "table" then for a,b in pairs(v) do @@ -136,7 +142,7 @@ function keyval_store:users() ]]; return engine:select(select_sql, host, self.store); end); - if not ok then return ok, result end + if not ok then error(result); end return iterator(result); end @@ -154,15 +160,17 @@ function map_store:get(username, key) WHERE "host"=? AND "user"=? AND "store"=? AND "key"=? LIMIT 1 ]]; - local data; + local data, err; if type(key) == "string" and key ~= "" then for row in engine:select(query, host, username or "", self.store, key) do - data = deserialize(row[1], row[2]); + data, err = deserialize(row[1], row[2]); + assert(data ~= nil, err); end return data; else for row in engine:select(query, host, username or "", self.store, "") do - data = deserialize(row[1], row[2]); + data, err = deserialize(row[1], row[2]); + assert(data ~= nil, err); end return data and data[key] or nil; end @@ -200,9 +208,10 @@ function map_store:set_keys(username, keydatas) engine:insert(insert_sql, host, username or "", self.store, key, t, value); end else - local extradata = {}; + local extradata, err = {}; for row in engine:select(select_extradata_sql, host, username or "", self.store, "") do - extradata = deserialize(row[1], row[2]); + extradata, err = deserialize(row[1], row[2]); + assert(extradata ~= nil, err); end engine:delete(delete_sql, host, username or "", self.store, ""); extradata[key] = data; @@ -356,7 +365,9 @@ function archive_store:find(username, query) return function() local row = result(); if row ~= nil then - return row[1], deserialize(row[2], row[3]), row[4], row[5]; + local value, err = deserialize(row[2], row[3]); + assert(value ~= nil, err); + return row[1], value, row[4], row[5]; end end, total; end @@ -374,7 +385,41 @@ function archive_store:delete(username, query) end archive_where(query, args, where); archive_where_id_range(query, args, where); - sql_query = sql_query:format(t_concat(where, " AND ")); + if query.truncate == nil then + sql_query = sql_query:format(t_concat(where, " AND ")); + else + args[#args+1] = query.truncate; + local unlimited = "ALL"; + if engine.params.driver == "SQLite3" then + sql_query = [[ + DELETE FROM "prosodyarchive" + WHERE %s + ORDER BY "sort_id" %s + LIMIT %s OFFSET ?; + ]]; + unlimited = "-1"; + elseif engine.params.driver == "MySQL" then + sql_query = [[ + DELETE result FROM prosodyarchive AS result JOIN ( + SELECT sort_id FROM prosodyarchive + WHERE %s + ORDER BY "sort_id" %s + LIMIT %s OFFSET ? + ) AS limiter on result.sort_id = limiter.sort_id;]]; + unlimited = "18446744073709551615"; + else + sql_query = [[ + DELETE FROM "prosodyarchive" + WHERE "sort_id" IN ( + SELECT "sort_id" FROM "prosodyarchive" + WHERE %s + ORDER BY "sort_id" %s + LIMIT %s OFFSET ? + );]]; + end + sql_query = string.format(sql_query, t_concat(where, " AND "), + query.reverse and "ASC" or "DESC", unlimited); + end return engine:delete(sql_query, unpack(args)); end); return ok and stmt:affected(), stmt; @@ -423,11 +468,11 @@ end --- Initialization -local function create_table(engine, name) -- luacheck: ignore 431/engine +local function create_table(engine) -- luacheck: ignore 431/engine local Table, Column, Index = sql.Table, sql.Column, sql.Index; local ProsodyTable = Table { - name= name or "prosody"; + name = "prosody"; Column { name="host", type="TEXT", nullable=false }; Column { name="user", type="TEXT", nullable=false }; Column { name="store", type="TEXT", nullable=false }; @@ -451,7 +496,7 @@ local function create_table(engine, name) -- luacheck: ignore 431/engine Column { name="with", type="TEXT", nullable=false }; -- related id Column { name="type", type="TEXT", nullable=false }; Column { name="value", type="MEDIUMTEXT", nullable=false }; - Index { name="prosodyarchive_index", unique = true, "host", "user", "store", "key" }; + Index { name="prosodyarchive_index", unique = engine.params.driver ~= "MySQL", "host", "user", "store", "key" }; Index { name="prosodyarchive_with_when", "host", "user", "store", "with", "when" }; Index { name="prosodyarchive_when", "host", "user", "store", "when" }; }; @@ -464,20 +509,37 @@ local function upgrade_table(engine, params, apply_changes) -- luacheck: ignore local changes = false; if params.driver == "MySQL" then local success,err = engine:transaction(function() - local result = engine:execute("SHOW COLUMNS FROM \"prosody\" WHERE \"Field\"='value' and \"Type\"='text'"); - if result:rowcount() > 0 then - changes = true; - if apply_changes then - module:log("info", "Upgrading database schema..."); - engine:execute("ALTER TABLE \"prosody\" MODIFY COLUMN \"value\" MEDIUMTEXT"); - module:log("info", "Database table automatically upgraded"); + do + local result = engine:execute("SHOW COLUMNS FROM \"prosody\" WHERE \"Field\"='value' and \"Type\"='text'"); + if result:rowcount() > 0 then + changes = true; + if apply_changes then + module:log("info", "Upgrading database schema (value column size)..."); + engine:execute("ALTER TABLE \"prosody\" MODIFY COLUMN \"value\" MEDIUMTEXT"); + module:log("info", "Database table automatically upgraded"); + end + end + end + + do + -- Ensure index is not unique (issue #1073) + local result = assert(engine:execute([[SHOW INDEX FROM prosodyarchive WHERE key_name='prosodyarchive_index' and non_unique=0]])); + if result:rowcount() > 0 then + changes = true; + if apply_changes then + module:log("info", "Upgrading database schema (prosodyarchive_index)..."); + engine:execute[[ALTER TABLE "prosodyarchive" DROP INDEX prosodyarchive_index;]]; + local new_index = sql.Index { table = "prosodyarchive", name="prosodyarchive_index", "host", "user", "store", "key" }; + engine:_create_index(new_index); + module:log("info", "Database table automatically upgraded"); + end end end return true; end); if not success then module:log("error", "Failed to check/upgrade database schema (%s), please see " - .."http://prosody.im/doc/mysql for help", + .."https://prosody.im/doc/mysql for help", err or "unknown error"); return false; end diff --git a/plugins/mod_storage_sql1.lua b/plugins/mod_storage_sql1.lua index a5bb5bfa..e1041bca 100644 --- a/plugins/mod_storage_sql1.lua +++ b/plugins/mod_storage_sql1.lua @@ -130,7 +130,7 @@ local function create_table() module:log("info", "Database table automatically upgraded"); else module:log("error", "Failed to upgrade database schema (%s), please see " - .."http://prosody.im/doc/mysql for help", + .."https://prosody.im/doc/mysql for help", err or "unknown error"); end end @@ -139,7 +139,7 @@ local function create_table() end elseif params.driver ~= "SQLite3" then -- SQLite normally fails to prepare for existing table module:log("warn", "Prosody was not able to automatically check/create the database table (%s), " - .."see http://prosody.im/doc/modules/mod_storage_sql#table_management for help.", + .."see https://prosody.im/doc/modules/mod_storage_sql#table_management for help.", err or "unknown error"); end end @@ -151,7 +151,7 @@ do -- process options to get a db connection if not ok then package.loaded["DBI"] = {}; module:log("error", "Failed to load the LuaDBI library for accessing SQL databases: %s", DBI); - module:log("error", "More information on installing LuaDBI can be found at http://prosody.im/doc/depends#luadbi"); + module:log("error", "More information on installing LuaDBI can be found at https://prosody.im/doc/depends#luadbi"); end prosody.lock_globals(); if not ok or not DBI.Connect then diff --git a/plugins/mod_storage_xep0227.lua b/plugins/mod_storage_xep0227.lua index ef227ca3..229ad6b5 100644 --- a/plugins/mod_storage_xep0227.lua +++ b/plugins/mod_storage_xep0227.lua @@ -164,10 +164,84 @@ handlers.private = { end; }; +handlers.roster = { + get = function(self, user) + user = getUserElement(getXml(user, self.host)); + if user then + local roster = user:get_child("query", "jabber:iq:roster"); + if roster then + local r = { + [false] = { + version = roster.attr.version; + pending = {}; + } + }; + for item in roster:childtags("item") do + r[item.attr.jid] = { + jid = item.attr.jid, + subscription = item.attr.subscription, + ask = item.attr.ask, + name = item.attr.name, + groups = {}; + }; + for group in item:childtags("group") do + r[item.attr.jid].groups[group:get_text()] = true; + end + for pending in user:childtags("presence", "jabber:client") do + r[false].pending[pending.attr.from] = true; + end + end + return r; + end + end + end; + set = function(self, user, data) + local xml = getXml(user, self.host); + local usere = xml and getUserElement(xml); + if usere then + local roster = usere:get_child("query", 'jabber:iq:roster'); + if roster then removeStanzaChild(usere, roster); end + usere:maptags(function (tag) + if tag.attr.xmlns == "jabber:client" and tag.name == "presence" and tag.attr.type == "subscribe" then + return nil; + end + return tag; + end); + if data and next(data) ~= nil then + roster = st.stanza("query", {xmlns='jabber:iq:roster'}); + usere:add_child(roster); + for jid, item in pairs(data) do + if jid then + roster:tag("item", { + jid = jid, + subscription = item.subscription, + ask = item.ask, + name = item.name, + }); + for group in pairs(item.groups) do + roster:tag("group"):text(group):up(); + end + roster:up(); -- move out from item + else + roster.attr.version = item.version; + for pending_jid in pairs(item.pending) do + usere:add_child(st.presence({ from = pending_jid, type = "subscribe" })); + end + end + end + end + return setXml(user, self.host, xml); + end + return true; + end; +}; + + ----------------------------- local driver = {}; -function driver:open(datastore, typ) +function driver:open(datastore, typ) -- luacheck: ignore 212/self + if typ and typ ~= "keyval" then return nil, "unsupported-store"; end local handler = handlers[datastore]; if not handler then return nil, "unsupported-datastore"; end local instance = setmetatable({ host = module.host; datastore = datastore; }, { __index = handler }); diff --git a/plugins/mod_time.lua b/plugins/mod_time.lua index ae7da916..0cd5a4ea 100644 --- a/plugins/mod_time.lua +++ b/plugins/mod_time.lua @@ -16,16 +16,14 @@ module:add_feature("urn:xmpp:time"); local function time_handler(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type == "get" then - origin.send(st.reply(stanza):tag("time", {xmlns="urn:xmpp:time"}) - :tag("tzo"):text("+00:00"):up() -- TODO get the timezone in a platform independent fashion - :tag("utc"):text(datetime())); - return true; - end + origin.send(st.reply(stanza):tag("time", {xmlns="urn:xmpp:time"}) + :tag("tzo"):text("+00:00"):up() -- TODO get the timezone in a platform independent fashion + :tag("utc"):text(datetime())); + return true; end -module:hook("iq/bare/urn:xmpp:time:time", time_handler); -module:hook("iq/host/urn:xmpp:time:time", time_handler); +module:hook("iq-get/bare/urn:xmpp:time:time", time_handler); +module:hook("iq-get/host/urn:xmpp:time:time", time_handler); -- XEP-0090: Entity Time (deprecated) @@ -33,12 +31,10 @@ module:add_feature("jabber:iq:time"); local function legacy_time_handler(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type == "get" then - origin.send(st.reply(stanza):tag("query", {xmlns="jabber:iq:time"}) - :tag("utc"):text(legacy())); - return true; - end + origin.send(st.reply(stanza):tag("query", {xmlns="jabber:iq:time"}) + :tag("utc"):text(legacy())); + return true; end -module:hook("iq/bare/jabber:iq:time:query", legacy_time_handler); -module:hook("iq/host/jabber:iq:time:query", legacy_time_handler); +module:hook("iq-get/bare/jabber:iq:time:query", legacy_time_handler); +module:hook("iq-get/host/jabber:iq:time:query", legacy_time_handler); diff --git a/plugins/mod_uptime.lua b/plugins/mod_uptime.lua index 2e369b16..24985ef1 100644 --- a/plugins/mod_uptime.lua +++ b/plugins/mod_uptime.lua @@ -14,15 +14,14 @@ module:hook_global("server-started", function() start_time = prosody.start_time -- XEP-0012: Last activity module:add_feature("jabber:iq:last"); -module:hook("iq/host/jabber:iq:last:query", function(event) +module:hook("iq-get/host/jabber:iq:last:query", function(event) local origin, stanza = event.origin, event.stanza; - if stanza.attr.type == "get" then - origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))})); - return true; - end + origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))})); + return true; end); -- Ad-hoc command +module:depends "adhoc"; local adhoc_new = module:require "adhoc".new; function uptime_text() @@ -39,7 +38,7 @@ function uptime_text() minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time)); end -function uptime_command_handler (self, data, state) +function uptime_command_handler () return { info = uptime_text(), status = "completed" }; end diff --git a/plugins/mod_user_account_management.lua b/plugins/mod_user_account_management.lua new file mode 100644 index 00000000..615c1ed6 --- /dev/null +++ b/plugins/mod_user_account_management.lua @@ -0,0 +1,86 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + + +local st = require "util.stanza"; +local usermanager_set_password = require "core.usermanager".set_password; +local usermanager_delete_user = require "core.usermanager".delete_user; +local nodeprep = require "util.encodings".stringprep.nodeprep; +local jid_bare = require "util.jid".bare; + +local compat = module:get_option_boolean("registration_compat", true); + +module:add_feature("jabber:iq:register"); + +-- Password change and account deletion handler +local function handle_registration_stanza(event) + local session, stanza = event.origin, event.stanza; + local log = session.log or module._log; + + local query = stanza.tags[1]; + if stanza.attr.type == "get" then + local reply = st.reply(stanza); + reply:tag("query", {xmlns = "jabber:iq:register"}) + :tag("registered"):up() + :tag("username"):text(session.username):up() + :tag("password"):up(); + session.send(reply); + else -- stanza.attr.type == "set" + if query.tags[1] and query.tags[1].name == "remove" then + local username, host = session.username, session.host; + + -- This one weird trick sends a reply to this stanza before the user is deleted + local old_session_close = session.close; + session.close = function(self, ...) + self.send(st.reply(stanza)); + return old_session_close(self, ...); + end + + local ok, err = usermanager_delete_user(username, host); + + if not ok then + log("debug", "Removing user account %s@%s failed: %s", username, host, err); + session.close = old_session_close; + session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); + return true; + end + + log("info", "User removed their account: %s@%s", username, host); + module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session }); + else + local username = nodeprep(query:get_child_text("username")); + local password = query:get_child_text("password"); + if username and password then + if username == session.username then + if usermanager_set_password(username, password, session.host, session.resource) then + session.send(st.reply(stanza)); + else + -- TODO unable to write file, file may be locked, etc, what's the correct error? + session.send(st.error_reply(stanza, "wait", "internal-server-error")); + end + else + session.send(st.error_reply(stanza, "modify", "bad-request")); + end + else + session.send(st.error_reply(stanza, "modify", "bad-request")); + end + end + end + return true; +end + +module:hook("iq/self/jabber:iq:register:query", handle_registration_stanza); +if compat then + module:hook("iq/host/jabber:iq:register:query", function (event) + local session, stanza = event.origin, event.stanza; + if session.type == "c2s" and jid_bare(stanza.attr.to) == session.host then + return handle_registration_stanza(event); + end + end); +end + diff --git a/plugins/mod_vcard.lua b/plugins/mod_vcard.lua index 72f92ef7..b1a4c6e8 100644 --- a/plugins/mod_vcard.lua +++ b/plugins/mod_vcard.lua @@ -29,7 +29,7 @@ local function handle_vcard(event) else session.send(st.error_reply(stanza, "cancel", "item-not-found")); end - else + else -- stanza.attr.type == "set" if not to then if vcards:set(session.username, st.preserialize(stanza.tags[1])) then session.send(st.reply(stanza)); @@ -46,9 +46,3 @@ end module:hook("iq/bare/vcard-temp:vCard", handle_vcard); module:hook("iq/host/vcard-temp:vCard", handle_vcard); - --- COMPAT w/0.8 -if module:get_option("vcard_compatibility") ~= nil then - module:log("error", "The vcard_compatibility option has been removed, see".. - "mod_compat_vcard in prosody-modules if you still need this."); -end diff --git a/plugins/mod_vcard4.lua b/plugins/mod_vcard4.lua new file mode 100644 index 00000000..378c0602 --- /dev/null +++ b/plugins/mod_vcard4.lua @@ -0,0 +1,45 @@ +local st = require "util.stanza" +local jid_split = require "util.jid".split; + +local mod_pep = module:depends("pep"); + +module:hook("account-disco-info", function (event) + event.reply:tag("feature", { var = "urn:ietf:params:xml:ns:vcard-4.0" }):up(); +end); + +module:hook("iq-get/bare/urn:ietf:params:xml:ns:vcard-4.0:vcard", function (event) + local origin, stanza = event.origin, event.stanza; + + local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username); + local ok, id, item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from); + if ok and item then + origin.send(st.reply(stanza):add_child(item.tags[1])); + elseif item == "item-not-found" or not id then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + elseif item == "forbidden" then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + else + origin.send(st.error_reply(stanza, "modify", "undefined-condition")); + end + return true; +end); + +module:hook("iq-set/self/urn:ietf:params:xml:ns:vcard-4.0:vcard", function (event) + local origin, stanza = event.origin, event.stanza; + + local vcard4 = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = "current" }) + :add_child(stanza.tags[1]); + + local pep_service = mod_pep.get_pep_service(origin.username); + + local ok, err = pep_service:publish("urn:xmpp:vcard4", origin.full_jid, "current", vcard4); + if ok then + origin.send(st.reply(stanza)); + elseif err == "forbidden" then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + else + origin.send(st.error_reply(stanza, "modify", "undefined-condition", err)); + end + return true; +end); + diff --git a/plugins/mod_vcard_legacy.lua b/plugins/mod_vcard_legacy.lua new file mode 100644 index 00000000..b822e56c --- /dev/null +++ b/plugins/mod_vcard_legacy.lua @@ -0,0 +1,285 @@ +local st = require "util.stanza" +local jid_split = require "util.jid".split; + +local mod_pep = module:depends("pep"); + +local sha1 = require "util.hashes".sha1; +local base64_decode = require "util.encodings".base64.decode; + +local vcards = module:open_store("vcard"); + +module:add_feature("vcard-temp"); +module:hook("account-disco-info", function (event) + event.reply:tag("feature", { var = "urn:xmpp:pep-vcard-conversion:0" }):up(); +end); + +local function handle_error(origin, stanza, err) + if err == "forbidden" then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + elseif err == "internal-server-error" then + origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + else + origin.send(st.error_reply(stanza, "modify", "undefined-condition", err)); + end +end + +-- Simple translations +-- <foo><text>hey</text></foo> -> <FOO>hey</FOO> +local simple_map = { + nickname = "text"; + title = "text"; + role = "text"; + categories = "text"; + note = "text"; + url = "uri"; + bday = "date"; +} + +module:hook("iq-get/bare/vcard-temp:vCard", function (event) + local origin, stanza = event.origin, event.stanza; + local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username); + local ok, id, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from); + + local vcard_temp = st.stanza("vCard", { xmlns = "vcard-temp" }); + if ok and vcard4_item then + local vcard4 = vcard4_item.tags[1]; + + local fn = vcard4:get_child("fn"); + vcard_temp:text_tag("FN", fn and fn:get_child_text("text")); + + local v4n = vcard4:get_child("n"); + vcard_temp:tag("N") + :text_tag("FAMILY", v4n and v4n:get_child_text("surname")) + :text_tag("GIVEN", v4n and v4n:get_child_text("given")) + :text_tag("MIDDLE", v4n and v4n:get_child_text("additional")) + :text_tag("PREFIX", v4n and v4n:get_child_text("prefix")) + :text_tag("SUFFIX", v4n and v4n:get_child_text("suffix")) + :up(); + + for tag in vcard4:childtags() do + local typ = simple_map[tag.name]; + if typ then + local text = tag:get_child_text(typ); + if text then + vcard_temp:text_tag(tag.name:upper(), text); + end + elseif tag.name == "email" then + local text = tag:get_child_text("text"); + if text then + vcard_temp:tag("EMAIL") + :text_tag("USERID", text) + :tag("INTERNET"):up(); + if tag:find"parameters/type/text#" == "home" then + vcard_temp:tag("HOME"):up(); + elseif tag:find"parameters/type/text#" == "work" then + vcard_temp:tag("WORK"):up(); + end + vcard_temp:up(); + end + elseif tag.name == "tel" then + local text = tag:get_child_text("uri"); + if text then + if text:sub(1, 4) == "tel:" then + text = text:sub(5) + end + vcard_temp:tag("TEL"):text_tag("NUMBER", text); + if tag:find"parameters/type/text#" == "home" then + vcard_temp:tag("HOME"):up(); + elseif tag:find"parameters/type/text#" == "work" then + vcard_temp:tag("WORK"):up(); + end + vcard_temp:up(); + end + elseif tag.name == "adr" then + vcard_temp:tag("ADR") + :text_tag("POBOX", tag:get_child_text("pobox")) + :text_tag("EXTADD", tag:get_child_text("ext")) + :text_tag("STREET", tag:get_child_text("street")) + :text_tag("LOCALITY", tag:get_child_text("locality")) + :text_tag("REGION", tag:get_child_text("region")) + :text_tag("PCODE", tag:get_child_text("code")) + :text_tag("CTRY", tag:get_child_text("country")); + if tag:find"parameters/type/text#" == "home" then + vcard_temp:tag("HOME"):up(); + elseif tag:find"parameters/type/text#" == "work" then + vcard_temp:tag("WORK"):up(); + end + vcard_temp:up(); + end + end + end + + local meta_ok, avatar_meta = pep_service:get_items("urn:xmpp:avatar:metadata", stanza.attr.from); + local data_ok, avatar_data = pep_service:get_items("urn:xmpp:avatar:data", stanza.attr.from); + + if data_ok then + for _, hash in ipairs(avatar_data) do + local meta = meta_ok and avatar_meta[hash]; + local data = avatar_data[hash]; + local info = meta and meta.tags[1]:get_child("info"); + vcard_temp:tag("PHOTO"); + if info and info.attr.type then + vcard_temp:text_tag("TYPE", info.attr.type); + end + if data then + vcard_temp:text_tag("BINVAL", data.tags[1]:get_text()); + elseif info and info.attr.url then + vcard_temp:text_tag("EXTVAL", info.attr.url); + end + vcard_temp:up(); + end + end + + if not vcard_temp.tags[1] then + vcard_temp = st.deserialize(vcards:get(jid_split(stanza.attr.to) or origin.username)) or vcard_temp; + end + + origin.send(st.reply(stanza):add_child(vcard_temp)); + return true; +end); + +module:hook("iq-set/self/vcard-temp:vCard", function (event) + local origin, stanza = event.origin, event.stanza; + local pep_service = mod_pep.get_pep_service(origin.username); + + local vcard_temp = stanza.tags[1]; + + local vcard4 = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = "current" }) + :tag("vcard", { xmlns = 'urn:ietf:params:xml:ns:vcard-4.0' }); + + if pep_service:purge("urn:xmpp:avatar:metadata", origin.full_jid) then + pep_service:purge("urn:xmpp:avatar:data", origin.full_jid); + end + + vcard4:tag("fn"):text_tag("text", vcard_temp:get_child_text("FN")):up(); + + local N = vcard_temp:get_child("N"); + + vcard4:tag("n") + :text_tag("surname", N and N:get_child_text("FAMILY")) + :text_tag("given", N and N:get_child_text("GIVEN")) + :text_tag("additional", N and N:get_child_text("MIDDLE")) + :text_tag("prefix", N and N:get_child_text("PREFIX")) + :text_tag("suffix", N and N:get_child_text("SUFFIX")) + :up(); + + for tag in vcard_temp:childtags() do + local typ = simple_map[tag.name:lower()]; + if typ then + local text = tag:get_text(); + if text then + vcard4:tag(tag.name:lower()):text_tag(typ, text):up(); + end + elseif tag.name == "EMAIL" then + local text = tag:get_child_text("USERID"); + if text then + vcard4:tag("email") + vcard4:text_tag("text", text) + vcard4:tag("parameters"):tag("type"); + if tag:get_child("HOME") then + vcard4:text_tag("text", "home"); + elseif tag:get_child("WORK") then + vcard4:text_tag("text", "work"); + end + vcard4:up():up():up(); + end + elseif tag.name == "TEL" then + local text = tag:get_child_text("NUMBER"); + if text then + vcard4:tag("tel"):text_tag("uri", "tel:"..text); + end + vcard4:tag("parameters"):tag("type"); + if tag:get_child("HOME") then + vcard4:text_tag("text", "home"); + elseif tag:get_child("WORK") then + vcard4:text_tag("text", "work"); + end + vcard4:up():up():up(); + elseif tag.name == "ORG" then + local text = tag:get_child_text("ORGNAME"); + if text then + vcard4:tag("org"):text_tag("text", text):up(); + end + elseif tag.name == "DESC" then + local text = tag:get_text(); + if text then + vcard4:tag("note"):text_tag("text", text):up(); + end + -- <note> gets mapped into <NOTE> in the other direction + elseif tag.name == "ADR" then + vcard4:tag("adr") + :text_tag("pobox", tag:get_child_text("POBOX")) + :text_tag("ext", tag:get_child_text("EXTADD")) + :text_tag("street", tag:get_child_text("STREET")) + :text_tag("locality", tag:get_child_text("LOCALITY")) + :text_tag("region", tag:get_child_text("REGION")) + :text_tag("code", tag:get_child_text("PCODE")) + :text_tag("country", tag:get_child_text("CTRY")); + vcard4:tag("parameters"):tag("type"); + if tag:get_child("HOME") then + vcard4:text_tag("text", "home"); + elseif tag:get_child("WORK") then + vcard4:text_tag("text", "work"); + end + vcard4:up():up():up(); + elseif tag.name == "PHOTO" then + local avatar_type = tag:get_child_text("TYPE"); + local avatar_payload = tag:get_child_text("BINVAL"); + -- Can EXTVAL be translated? No way to know the sha1 of the data? + + if avatar_payload then + local avatar_raw = base64_decode(avatar_payload); + local avatar_hash = sha1(avatar_raw, true); + + local avatar_meta = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("metadata", { xmlns="urn:xmpp:avatar:metadata" }) + :tag("info", { + bytes = tostring(#avatar_raw), + id = avatar_hash, + type = avatar_type, + }); + + local avatar_data = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("data", { xmlns="urn:xmpp:avatar:data" }) + :text(avatar_payload); + + local ok, err = pep_service:publish("urn:xmpp:avatar:data", origin.full_jid, avatar_hash, avatar_data) + if ok then + ok, err = pep_service:publish("urn:xmpp:avatar:metadata", origin.full_jid, avatar_hash, avatar_meta); + end + if not ok then + handle_error(origin, stanza, err); + return true; + end + end + end + end + + local ok, err = pep_service:publish("urn:xmpp:vcard4", origin.full_jid, "current", vcard4); + if ok then + origin.send(st.reply(stanza)); + else + handle_error(origin, stanza, err); + end + + return true; +end); + +local function inject_xep153(event) + local origin, stanza = event.origin, event.stanza; + local username = origin.username; + if not username then return end + local pep_service = mod_pep.get_pep_service(username); + + stanza:remove_children("x", "vcard-temp:x:update"); + local x_update = st.stanza("x", { xmlns = "vcard-temp:x:update" }); + local ok, avatar_hash = pep_service:get_last_item("urn:xmpp:avatar:metadata", true); + if ok and avatar_hash then + x_update:text_tag("photo", avatar_hash); + end + stanza:add_direct_child(x_update); +end + +module:hook("pre-presence/full", inject_xep153, 1); +module:hook("pre-presence/bare", inject_xep153, 1); +module:hook("pre-presence/host", inject_xep153, 1); diff --git a/plugins/mod_version.lua b/plugins/mod_version.lua index 7f045415..1d24001c 100644 --- a/plugins/mod_version.lua +++ b/plugins/mod_version.lua @@ -10,39 +10,36 @@ local st = require "util.stanza"; module:add_feature("jabber:iq:version"); -local version; - local query = st.stanza("query", {xmlns = "jabber:iq:version"}) - :tag("name"):text("Prosody"):up() - :tag("version"):text(prosody.version):up(); + :text_tag("name", "Prosody") + :text_tag("version", prosody.version); if not module:get_option_boolean("hide_os_type") then + local platform; if os.getenv("WINDIR") then - version = "Windows"; + platform = "Windows"; else local os_version_command = module:get_option_string("os_version_command"); local ok, pposix = pcall(require, "util.pposix"); if not os_version_command and (ok and pposix and pposix.uname) then - version = pposix.uname().sysname; + platform = pposix.uname().sysname; end - if not version then + if not platform then local uname = io.popen(os_version_command or "uname"); if uname then - version = uname:read("*a"); + platform = uname:read("*a"); end uname:close(); end end - if version then - version = version:match("^%s*(.-)%s*$") or version; - query:tag("os"):text(version):up(); + if platform then + platform = platform:match("^%s*(.-)%s*$") or platform; + query:text_tag("os", platform); end end -module:hook("iq/host/jabber:iq:version:query", function(event) - local stanza = event.stanza; - if stanza.attr.type == "get" and stanza.attr.to == module.host then - event.origin.send(st.reply(stanza):add_child(query)); - return true; - end +module:hook("iq-get/host/jabber:iq:version:query", function(event) + local origin, stanza = event.origin, event.stanza; + origin.send(st.reply(stanza):add_child(query)); + return true; end); diff --git a/plugins/mod_watchregistrations.lua b/plugins/mod_watchregistrations.lua index 82666b09..825b8a73 100644 --- a/plugins/mod_watchregistrations.lua +++ b/plugins/mod_watchregistrations.lua @@ -13,12 +13,13 @@ local jid_prep = require "util.jid".prep; local registration_watchers = module:get_option_set("registration_watchers", module:get_option("admins", {})) / jid_prep; local registration_from = module:get_option_string("registration_from", host); local registration_notification = module:get_option_string("registration_notification", "User $username just registered on $host from $ip"); +local msg_type = module:get_option_string("registration_notification_type", "chat"); local st = require "util.stanza"; module:hook("user-registered", function (user) module:log("debug", "Notifying of new registration"); - local message = st.message{ type = "chat", from = registration_from } + local message = st.message{ type = msg_type, from = registration_from } :tag("body") :text(registration_notification:gsub("%$(%w+)", function (v) return user[v] or user.session and user.session[v] or nil; diff --git a/plugins/mod_websocket.lua b/plugins/mod_websocket.lua index edc104df..a668b4fa 100644 --- a/plugins/mod_websocket.lua +++ b/plugins/mod_websocket.lua @@ -95,6 +95,8 @@ local function session_close(session, reason) session.send(st.stanza("close", { xmlns = xmlns_framing })); function session.send() return false; end + -- luacheck: ignore 422/reason + -- FIXME reason should be handled in common place local reason = (reason and (reason.name or reason.text or reason.condition)) or reason; session.log("debug", "c2s stream for %s closed: %s", session.full_jid or ("<"..session.ip..">"), reason or "session closed"); @@ -256,6 +258,10 @@ function handle_request(event) local session = sessions[conn]; + -- Use upstream IP if a HTTP proxy was used + -- See mod_http and #540 + session.ip = request.ip; + session.secure = consider_websocket_secure or session.secure; session.websocket_request = request; @@ -311,16 +317,17 @@ end module:hook("c2s-read-timeout", keepalive, -0.9); +module:depends("http"); +module:provides("http", { + name = "websocket"; + default_path = "xmpp-websocket"; + route = { + ["GET"] = handle_request; + ["GET /"] = handle_request; + }; +}); + function module.add_host(module) - module:depends("http"); - module:provides("http", { - name = "websocket"; - default_path = "xmpp-websocket"; - route = { - ["GET"] = handle_request; - ["GET /"] = handle_request; - }; - }); module:hook("c2s-read-timeout", keepalive, -0.9); if cross_domain ~= true then diff --git a/plugins/muc/config_form_sections.lib.lua b/plugins/muc/config_form_sections.lib.lua new file mode 100644 index 00000000..34be5dc6 --- /dev/null +++ b/plugins/muc/config_form_sections.lib.lua @@ -0,0 +1,27 @@ +module:hook("muc-config-form", function(event) + table.insert(event.form, { + type = "fixed"; + value = "Room information"; + }); +end, 100); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + type = "fixed"; + value = "Access to the room"; + }); +end, 90); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + type = "fixed"; + value = "Permissions in the room"; + }); +end, 80); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + type = "fixed"; + value = "Other options"; + }); +end, 70); diff --git a/plugins/muc/description.lib.lua b/plugins/muc/description.lib.lua new file mode 100644 index 00000000..eeda83d2 --- /dev/null +++ b/plugins/muc/description.lib.lua @@ -0,0 +1,52 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local function get_description(room) + return room._data.description; +end + +local function set_description(room, description) + if description == "" then description = nil; end + if get_description(room) == description then return false; end + room._data.description = description; + return true; +end + +local function add_disco_form(event) + table.insert(event.form, { + name = "muc#roominfo_description"; + label = "Description"; + value = ""; + }); + event.formdata["muc#roominfo_description"] = get_description(event.room); +end + +local function add_form_option(event) + table.insert(event.form, { + name = "muc#roomconfig_roomdesc"; + type = "text-single"; + label = "Description"; + desc = "A brief description of the room"; + value = get_description(event.room) or ""; + }); +end + +module:hook("muc-disco#info", add_disco_form); +module:hook("muc-config-form", add_form_option, 100-2); + +module:hook("muc-config-submitted/muc#roomconfig_roomdesc", function(event) + if set_description(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +return { + get = get_description; + set = set_description; +}; diff --git a/plugins/muc/hidden.lib.lua b/plugins/muc/hidden.lib.lua new file mode 100644 index 00000000..153df21a --- /dev/null +++ b/plugins/muc/hidden.lib.lua @@ -0,0 +1,54 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local restrict_public = not module:get_option_boolean("muc_room_allow_public", true); +local um_is_admin = require "core.usermanager".is_admin; + +local function get_hidden(room) + return room._data.hidden; +end + +local function set_hidden(room, hidden) + hidden = hidden and true or nil; + if get_hidden(room) == hidden then return false; end + room._data.hidden = hidden; + return true; +end + +module:hook("muc-config-form", function(event) + if restrict_public and not um_is_admin(event.actor, module.host) then + -- Don't show option if public rooms are restricted and user is not admin of this host + return; + end + table.insert(event.form, { + name = "muc#roomconfig_publicroom"; + type = "boolean"; + label = "Include room information in public lists"; + desc = "Enable this to allow people to find the room"; + value = not get_hidden(event.room); + }); +end, 100-9); + +module:hook("muc-config-submitted/muc#roomconfig_publicroom", function(event) + if restrict_public and not um_is_admin(event.actor, module.host) then + return; -- Not allowed + end + if set_hidden(event.room, not event.value) then + event.status_codes["104"] = true; + end +end); + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = get_hidden(event.room) and "muc_hidden" or "muc_public"}):up(); +end); + +return { + get = get_hidden; + set = set_hidden; +}; diff --git a/plugins/muc/history.lib.lua b/plugins/muc/history.lib.lua new file mode 100644 index 00000000..445aacb9 --- /dev/null +++ b/plugins/muc/history.lib.lua @@ -0,0 +1,210 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local gettime = os.time; +local datetime = require "util.datetime"; +local st = require "util.stanza"; + +local default_history_length = 20; +local max_history_length = module:get_option_number("max_history_messages", math.huge); + +local function set_max_history_length(_max_history_length) + max_history_length = _max_history_length or math.huge; +end + +local function get_historylength(room) + return math.min(room._data.history_length or default_history_length, max_history_length); +end + +local function set_historylength(room, length) + if length then + length = assert(tonumber(length), "Length not a valid number"); + end + if length == default_history_length then length = nil; end + room._data.history_length = length; + return true; +end + +-- Fix for clients who don't support XEP-0045 correctly +-- Default number of history messages the room returns +local function get_defaulthistorymessages(room) + return room._data.default_history_messages or default_history_length; +end +local function set_defaulthistorymessages(room, number) + number = math.min(tonumber(number) or default_history_length, room._data.history_length or default_history_length); + if number == default_history_length then + number = nil; + end + room._data.default_history_messages = number; +end + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_historylength"; + type = "text-single"; + label = "Maximum number of history messages returned by room"; + desc = "Specify the maximum number of previous messages that should be sent to users when they join the room"; + value = tostring(get_historylength(event.room)); + }); + table.insert(event.form, { + name = 'muc#roomconfig_defaulthistorymessages', + type = 'text-single', + label = 'Default number of history messages returned by room', + desc = "Specify the number of previous messages sent to new users when they join the room"; + value = tostring(get_defaulthistorymessages(event.room)) + }); +end, 70-5); + +module:hook("muc-config-submitted/muc#roomconfig_historylength", function(event) + if set_historylength(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +module:hook("muc-config-submitted/muc#roomconfig_defaulthistorymessages", function(event) + if set_defaulthistorymessages(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +local function parse_history(stanza) + local x_tag = stanza:get_child("x", "http://jabber.org/protocol/muc"); + local history_tag = x_tag and x_tag:get_child("history", "http://jabber.org/protocol/muc"); + if not history_tag then + return nil, nil, nil; + end + + local maxchars = tonumber(history_tag.attr.maxchars); + + local maxstanzas = tonumber(history_tag.attr.maxstanzas); + + -- messages received since the UTC datetime specified + local since = history_tag.attr.since; + if since then + since = datetime.parse(since); + end + + -- messages received in the last "X" seconds. + local seconds = tonumber(history_tag.attr.seconds); + if seconds then + seconds = gettime() - seconds; + if since then + since = math.max(since, seconds); + else + since = seconds; + end + end + + return maxchars, maxstanzas, since; +end + +module:hook("muc-get-history", function(event) + local room = event.room; + local history = room._history; -- send discussion history + if not history then return nil end + local history_len = #history; + + local to = event.to; + local maxchars = event.maxchars; + local maxstanzas = event.maxstanzas or history_len; + local since = event.since; + local n = 0; + local charcount = 0; + for i=history_len,1,-1 do + local entry = history[i]; + if maxchars then + if not entry.chars then + entry.stanza.attr.to = ""; + entry.chars = #tostring(entry.stanza); + end + charcount = charcount + entry.chars + #to; + if charcount > maxchars then break; end + end + if since and since > entry.timestamp then break; end + if n + 1 > maxstanzas then break; end + n = n + 1; + end + + local i = history_len-n+1 + function event.next_stanza() + if i > history_len then return nil end + local entry = history[i]; + local msg = entry.stanza; + msg.attr.to = to; + i = i + 1; + return msg; + end + return true; +end, -1); + +local function send_history(room, stanza) + local maxchars, maxstanzas, since = parse_history(stanza); + if not(maxchars or maxstanzas or since) then + maxstanzas = get_defaulthistorymessages(room); + end + local event = { + room = room; + stanza = stanza; + to = stanza.attr.from; -- `to` is required to calculate the character count for `maxchars` + maxchars = maxchars, + maxstanzas = maxstanzas, + since = since; + next_stanza = function() end; -- events should define this iterator + }; + module:fire_event("muc-get-history", event); + for msg in event.next_stanza, event do + room:route_stanza(msg); + end +end + +-- Send history on join +module:hook("muc-occupant-session-new", function(event) + send_history(event.room, event.stanza); +end, 50); -- Before subject(20) + +-- add to history +module:hook("muc-add-history", function(event) + local room = event.room + local history = room._history; + if not history then history = {}; room._history = history; end + local stanza = st.clone(event.stanza); + stanza.attr.to = ""; + local ts = gettime(); + local stamp = datetime.datetime(ts); + stanza:tag("delay", { -- XEP-0203 + xmlns = "urn:xmpp:delay", from = module.host, stamp = stamp + }):up(); + stanza:tag("x", { -- XEP-0091 (deprecated) + xmlns = "jabber:x:delay", from = module.host, stamp = datetime.legacy() + }):up(); + local entry = { stanza = stanza, timestamp = ts }; + table.insert(history, entry); + while #history > get_historylength(room) do table.remove(history, 1) end + return true; +end, -1); + +-- Have a single muc-add-history event, so that plugins can mark it +-- as handled without stopping other muc-broadcast-message handlers +module:hook("muc-broadcast-message", function(event) + if module:fire_event("muc-message-is-historic", event) then + module:fire_event("muc-add-history", event); + end +end); + +module:hook("muc-message-is-historic", function (event) + return event.stanza:get_child("body"); +end, -1); + +return { + set_max_length = set_max_history_length; + parse_history = parse_history; + send = send_history; + get_length = get_historylength; + set_length = set_historylength; +}; diff --git a/plugins/muc/language.lib.lua b/plugins/muc/language.lib.lua new file mode 100644 index 00000000..ee80806b --- /dev/null +++ b/plugins/muc/language.lib.lua @@ -0,0 +1,51 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local function get_language(room) + return room._data.language; +end + +local function set_language(room, language) + if language == "" then language = nil; end + if get_language(room) == language then return false; end + room._data.language = language; + return true; +end + +local function add_disco_form(event) + table.insert(event.form, { + name = "muc#roominfo_lang"; + value = ""; + }); + event.formdata["muc#roominfo_lang"] = get_language(event.room); +end + +local function add_form_option(event) + table.insert(event.form, { + name = "muc#roomconfig_lang"; + label = "Language tag for room (e.g. 'en', 'de', 'fr' etc.)"; + type = "text-single"; + desc = "Indicate the primary language spoken in this room"; + value = get_language(event.room) or ""; + }); +end + +module:hook("muc-disco#info", add_disco_form); +module:hook("muc-config-form", add_form_option, 100-3); + +module:hook("muc-config-submitted/muc#roomconfig_lang", function(event) + if set_language(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +return { + get = get_language; + set = set_language; +}; diff --git a/plugins/muc/lock.lib.lua b/plugins/muc/lock.lib.lua new file mode 100644 index 00000000..062ab615 --- /dev/null +++ b/plugins/muc/lock.lib.lua @@ -0,0 +1,62 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; + +local lock_rooms = module:get_option_boolean("muc_room_locking", true); +local lock_room_timeout = module:get_option_number("muc_room_lock_timeout", 300); + +local function lock(room) + module:fire_event("muc-room-locked", {room = room;}); + room._data.locked = os.time() + lock_room_timeout; +end +local function unlock(room) + module:fire_event("muc-room-unlocked", {room = room;}); + room._data.locked = nil; +end +local function is_locked(room) + local ts = room._data.locked; + if ts then + if os.time() < ts then return true; end + unlock(room); + end + return false; +end + +if lock_rooms then + module:hook("muc-room-pre-create", function(event) + -- Older groupchat protocol doesn't lock + if not event.stanza:get_child("x", "http://jabber.org/protocol/muc") then return end + -- Lock room at creation + local room = event.room; + lock(room); + end, 10); +end + +-- Don't let users into room while it is locked +module:hook("muc-occupant-pre-join", function(event) + if not event.is_new_room and is_locked(event.room) then -- Deny entry + module:log("debug", "Room is locked, denying entry"); + event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found")); + return true; + end +end, -30); + +-- When config is submitted; unlock the room +module:hook("muc-config-submitted", function(event) + if is_locked(event.room) then + unlock(event.room); + end +end, -1); + +return { + lock = lock; + unlock = unlock; + is_locked = is_locked; +}; diff --git a/plugins/muc/members_only.lib.lua b/plugins/muc/members_only.lib.lua new file mode 100644 index 00000000..cd9f777e --- /dev/null +++ b/plugins/muc/members_only.lib.lua @@ -0,0 +1,165 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; + +local muc_util = module:require "muc/util"; +local valid_affiliations = muc_util.valid_affiliations; + +local function get_members_only(room) + return room._data.members_only; +end + +local function set_members_only(room, members_only) + members_only = members_only and true or nil; + if room._data.members_only == members_only then return false; end + room._data.members_only = members_only; + if members_only then + --[[ + If as a result of a change in the room configuration the room type is + changed to members-only but there are non-members in the room, + the service MUST remove any non-members from the room and include a + status code of 322 in the presence unavailable stanzas sent to those users + as well as any remaining occupants. + ]] + local occupants_changed = {}; + for _, occupant in room:each_occupant() do + local affiliation = room:get_affiliation(occupant.bare_jid); + if valid_affiliations[affiliation or "none"] <= valid_affiliations.none then + occupant.role = nil; + room:save_occupant(occupant); + occupants_changed[occupant] = true; + end + end + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("status", {code="322"}):up(); + for occupant in pairs(occupants_changed) do + room:publicise_occupant_status(occupant, x); + module:fire_event("muc-occupant-left", {room = room; nick = occupant.nick; occupant = occupant;}); + end + end + return true; +end + +local function get_allow_member_invites(room) + return room._data.allow_member_invites; +end + +-- Allows members to invite new members into a members-only room, +-- effectively creating an invite-only room +local function set_allow_member_invites(room, allow_member_invites) + allow_member_invites = allow_member_invites and true or nil; + if room._data.allow_member_invites == allow_member_invites then return false; end + room._data.allow_member_invites = allow_member_invites; + return true; +end + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = get_members_only(event.room) and "muc_membersonly" or "muc_open"}):up(); + table.insert(event.form, { + name = "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites"; + label = "Allow members to invite new members"; + type = "boolean"; + value = not not get_allow_member_invites(event.room); + }); +end); + + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_membersonly"; + type = "boolean"; + label = "Only allow members to join"; + desc = "Enable this to only allow access for room owners, admins and members"; + value = get_members_only(event.room); + }); + table.insert(event.form, { + name = "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites"; + type = "boolean"; + label = "Allow members to invite new members"; + value = get_allow_member_invites(event.room); + }); +end, 90-3); + +module:hook("muc-config-submitted/muc#roomconfig_membersonly", function(event) + if set_members_only(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +module:hook("muc-config-submitted/{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites", function(event) + if set_allow_member_invites(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +-- No affiliation => role of "none" +module:hook("muc-get-default-role", function(event) + if not event.affiliation and get_members_only(event.room) then + return false; + end +end); + +-- registration required for entering members-only room +module:hook("muc-occupant-pre-join", function(event) + local room = event.room; + if get_members_only(room) then + local stanza = event.stanza; + local affiliation = room:get_affiliation(stanza.attr.from); + if valid_affiliations[affiliation or "none"] <= valid_affiliations.none then + local reply = st.error_reply(stanza, "auth", "registration-required"):up(); + reply.tags[1].attr.code = "407"; + event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end + end +end, -5); + +-- Invitation privileges in members-only rooms SHOULD be restricted to room admins; +-- if a member without privileges to edit the member list attempts to invite another user +-- the service SHOULD return a <forbidden/> error to the occupant +module:hook("muc-pre-invite", function(event) + local room = event.room; + if get_members_only(room) then + local stanza = event.stanza; + local inviter_affiliation = room:get_affiliation(stanza.attr.from) or "none"; + local required_affiliation = room._data.allow_member_invites and "member" or "admin"; + if valid_affiliations[inviter_affiliation] < valid_affiliations[required_affiliation] then + event.origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + end +end); + +-- When an invite is sent; add an affiliation for the invitee +module:hook("muc-invite", function(event) + local room = event.room; + if get_members_only(room) then + local stanza = event.stanza; + local invitee = stanza.attr.to; + local affiliation = room:get_affiliation(invitee); + local invited_unaffiliated = valid_affiliations[affiliation or "none"] <= valid_affiliations.none; + if invited_unaffiliated then + local from = stanza:get_child("x", "http://jabber.org/protocol/muc#user") + :get_child("invite").attr.from; + module:log("debug", "%s invited %s into members only room %s, granting membership", + from, invitee, room.jid); + -- This might fail; ignore for now + room:set_affiliation(true, invitee, "member", "Invited by " .. from); + room:save(); + end + end +end); + +return { + get = get_members_only; + set = set_members_only; + get_allow_member_invites = get_allow_member_invites; + set_allow_member_invites = set_allow_member_invites; +}; diff --git a/plugins/muc/mod_muc.lua b/plugins/muc/mod_muc.lua index 0f58bfbc..fe40a78a 100644 --- a/plugins/muc/mod_muc.lua +++ b/plugins/muc/mod_muc.lua @@ -6,288 +6,504 @@ -- COPYING file in the source package for more information. -- -local array = require "util.array"; +-- Exposed functions: +-- +-- create_room(jid) -> room +-- track_room(room) +-- delete_room(room) +-- forget_room(room) +-- get_room_from_jid(jid) -> room +-- each_room(live_only) -> () -> room [DEPRECATED] +-- all_rooms() -> room +-- live_rooms() -> room +-- shutdown_component() if module:get_host_type() ~= "component" then - error("MUC should be loaded as a component, please see http://prosody.im/doc/components", 0); + error("MUC should be loaded as a component, please see https://prosody.im/doc/components", 0); end -local muc_host = module:get_host(); -local muc_name = module:get_option_string("name", "Prosody Chatrooms"); -local restrict_room_creation = module:get_option("restrict_room_creation"); -if restrict_room_creation then - if restrict_room_creation == true then - restrict_room_creation = "admin"; - elseif restrict_room_creation ~= "admin" and restrict_room_creation ~= "local" then - restrict_room_creation = nil; - end +local muclib = module:require "muc"; +room_mt = muclib.room_mt; -- Yes, global. +new_room = muclib.new_room; + +local name = module:require "muc/name"; +room_mt.get_name = name.get; +room_mt.set_name = name.set; + +local description = module:require "muc/description"; +room_mt.get_description = description.get; +room_mt.set_description = description.set; + +local language = module:require "muc/language"; +room_mt.get_language = language.get; +room_mt.set_language = language.set; + +local hidden = module:require "muc/hidden"; +room_mt.get_hidden = hidden.get; +room_mt.set_hidden = hidden.set; +function room_mt:get_public() + return not self:get_hidden(); +end +function room_mt:set_public(public) + return self:set_hidden(not public); end -local lock_rooms = module:get_option_boolean("muc_room_locking", false); -local lock_room_timeout = module:get_option_number("muc_room_lock_timeout", 300); -local muclib = module:require "muc"; -local muc_new_room = muclib.new_room; +local password = module:require "muc/password"; +room_mt.get_password = password.get; +room_mt.set_password = password.set; + +local members_only = module:require "muc/members_only"; +room_mt.get_members_only = members_only.get; +room_mt.set_members_only = members_only.set; +room_mt.get_allow_member_invites = members_only.get_allow_member_invites; +room_mt.set_allow_member_invites = members_only.set_allow_member_invites; + +local moderated = module:require "muc/moderated"; +room_mt.get_moderated = moderated.get; +room_mt.set_moderated = moderated.set; + +local request = module:require "muc/request"; +room_mt.handle_role_request = request.handle_request; + +local persistent = module:require "muc/persistent"; +room_mt.get_persistent = persistent.get; +room_mt.set_persistent = persistent.set; + +local subject = module:require "muc/subject"; +room_mt.get_changesubject = subject.get_changesubject; +room_mt.set_changesubject = subject.set_changesubject; +room_mt.get_subject = subject.get; +room_mt.set_subject = subject.set; +room_mt.send_subject = subject.send; + +local history = module:require "muc/history"; +room_mt.send_history = history.send; +room_mt.get_historylength = history.get_length; +room_mt.set_historylength = history.set_length; + +local register = module:require "muc/register"; +room_mt.get_registered_nick = register.get_registered_nick; +room_mt.get_registered_jid = register.get_registered_jid; +room_mt.handle_register_iq = register.handle_register_iq; + local jid_split = require "util.jid".split; local jid_bare = require "util.jid".bare; local st = require "util.stanza"; -local uuid_gen = require "util.uuid".generate; +local cache = require "util.cache"; local um_is_admin = require "core.usermanager".is_admin; -local hosts = prosody.hosts; - -rooms = {}; -local rooms = rooms; -local persistent_rooms_storage = module:open_store("persistent"); -local persistent_rooms, err = persistent_rooms_storage:get(); -if not persistent_rooms then - if err then - module:log("error", "Error loading list of persistent rooms from storage. Reload mod_muc or restart to recover."); - error("Storage error: "..err); - end - module:log("debug", "No persistent rooms found in the database"); - persistent_rooms = {}; -end -local room_configs = module:open_store("config"); --- Configurable options -muclib.set_max_history_length(module:get_option_number("max_history_messages")); +module:require "muc/config_form_sections"; module:depends("disco"); -module:add_identity("conference", "text", muc_name); +module:add_identity("conference", "text", module:get_option_string("name", "Prosody Chatrooms")); module:add_feature("http://jabber.org/protocol/muc"); +module:depends "muc_unique" +module:require "muc/lock"; local function is_admin(jid) return um_is_admin(jid, module.host); end -room_mt = muclib.room_mt; -- Yes, global. -local _set_affiliation = room_mt.set_affiliation; -local _get_affiliation = room_mt.get_affiliation; -function muclib.room_mt:get_affiliation(jid) - if is_admin(jid) then return "owner"; end - return _get_affiliation(self, jid); -end -function muclib.room_mt:set_affiliation(actor, jid, affiliation, callback, reason) - if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end - return _set_affiliation(self, actor, jid, affiliation, callback, reason); +do -- Monkey patch to make server admins room owners + local _get_affiliation = room_mt.get_affiliation; + function room_mt:get_affiliation(jid) + if is_admin(jid) then return "owner"; end + return _get_affiliation(self, jid); + end + + local _set_affiliation = room_mt.set_affiliation; + function room_mt:set_affiliation(actor, jid, affiliation, reason, data) + if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end + return _set_affiliation(self, actor, jid, affiliation, reason, data); + end end -local function room_route_stanza(room, stanza) module:send(stanza); end -local function room_save(room, forced) +local persistent_rooms_storage = module:open_store("persistent"); +local persistent_rooms = module:open_store("persistent", "map"); +local room_configs = module:open_store("config"); +local room_state = module:open_store("state"); + +local room_items_cache = {}; + +local function room_save(room, forced, savestate) local node = jid_split(room.jid); - persistent_rooms[room.jid] = room._data.persistent; - if room._data.persistent then - local history = room._data.history; - room._data.history = nil; - local data = { - jid = room.jid; - _data = room._data; - _affiliations = room._affiliations; - }; - room_configs:set(node, data); - room._data.history = history; + local is_persistent = persistent.get(room); + room_items_cache[room.jid] = room:get_public() and room:get_name() or nil; + if is_persistent or savestate then + persistent_rooms:set(nil, room.jid, true); + local data, state = room:freeze(savestate); + room_state:set(node, state); + return room_configs:set(node, data); elseif forced then - room_configs:set(node, nil); - if not next(room._occupants) then -- Room empty - rooms[room.jid] = nil; - end + persistent_rooms:set(nil, room.jid, nil); + room_state:set(node, nil); + return room_configs:set(node, nil); end - if forced then persistent_rooms_storage:set(nil, persistent_rooms); end end -function create_room(jid, locked) - local room = muc_new_room(jid); - room.route_stanza = room_route_stanza; - room.save = room_save; - rooms[jid] = room; - if locked then - room.locked = true; - if lock_room_timeout and lock_room_timeout > 0 then - module:add_timer(lock_room_timeout, function () - if room.locked then - room:destroy(); -- Not unlocked in time - end - end); - end +local max_rooms = module:get_option_number("muc_max_rooms"); +local max_live_rooms = module:get_option_number("muc_room_cache_size", 100); + +local room_hit = module:measure("room_hit", "rate"); +local room_miss = module:measure("room_miss", "rate") +local room_eviction = module:measure("room_eviction", "rate"); +local rooms = cache.new(max_rooms or max_live_rooms, function (jid, room) + if max_rooms then + module:log("info", "Room limit of %d reached, no new rooms allowed", max_rooms); + return false; + end + module:log("debug", "Evicting room %s", jid); + room_eviction(); + room_items_cache[room.jid] = room:get_public() and room:get_name() or nil; + local ok, err = room_save(room, nil, true); -- Force to disk + if not ok then + module:log("error", "Failed to swap inactive room %s to disk: %s", jid, err); + return false; + end +end); + +-- Automatically destroy empty non-persistent rooms +module:hook("muc-occupant-left",function(event) + local room = event.room + if room.destroying then return end + if not room:has_occupant() and not persistent.get(room) then -- empty, non-persistent room + module:log("debug", "%q empty, destroying", room.jid); + module:fire_event("muc-room-destroyed", { room = room }); + end +end, -1); + +function track_room(room) + if rooms:set(room.jid, room) then + -- When room is created, over-ride 'save' method + room.save = room_save; + return room; end - module:fire_event("muc-room-created", { room = room }); - return room; + -- Resource limit reached + return false; end -local persistent_errors = false; -for jid in pairs(persistent_rooms) do +local function handle_broken_room(room, origin, stanza) + module:log("debug", "Returning error from broken room %s", room.jid); + origin.send(st.error_reply(stanza, "wait", "internal-server-error")); + return true; +end + +local function restore_room(jid) local node = jid_split(jid); local data, err = room_configs:get(node); if data then - local room = create_room(jid); - room._data = data._data; - room._affiliations = data._affiliations; - elseif not err then -- missing room data - persistent_rooms[jid] = nil; - module:log("error", "Missing data for room '%s', removing from persistent room list", jid); - persistent_errors = true; - else -- error - module:log("error", "Error loading data for room '%s', locking it until service restart. Error was: %s", jid, err); - local room = muc_new_room(jid); - room.locked = true; - room._affiliations = { [muc_host] = "owner" }; -- To prevent unlocking - rooms[jid] = room; + module:log("debug", "Restoring room %s from storage", jid); + if module:fire_event("muc-room-pre-restore", { jid = jid, data = data }) == false then + return false; + end + local state, s_err = room_state:get(node); + if not state and s_err then + module:log("debug", "Could not restore state of room %s: %s", jid, s_err); + end + local room = muclib.restore_room(data, state); + if track_room(room) then + room_state:set(node, nil); + module:fire_event("muc-room-restored", { jid = jid, room = room }); + return room; + else + return false; + end + elseif err then + module:log("error", "Error restoring room %s from storage: %s", jid, err); + local room = muclib.new_room(jid, { locked = math.huge }); + room.handle_normal_presence = handle_broken_room; + room.handle_first_presence = handle_broken_room; + return room; end end -if persistent_errors then persistent_rooms_storage:set(nil, persistent_rooms); end -local host_room = muc_new_room(muc_host); -host_room.route_stanza = room_route_stanza; -host_room.save = room_save; +-- Removes a room from memory, without saving it (save first if required) +function forget_room(room) + module:log("debug", "Forgetting %s", room.jid); + rooms.save = nil; + rooms:set(room.jid, nil); +end -module:hook("host-disco-items", function(event) - local reply = event.reply; - module:log("debug", "host-disco-items called"); - for jid, room in pairs(rooms) do - if not room:get_hidden() then - reply:tag("item", {jid=jid, name=room:get_name()}):up(); - end +-- Removes a room from the database (may remain in memory) +function delete_room(room) + module:log("debug", "Deleting %s", room.jid); + room_configs:set(jid_split(room.jid), nil); + room_state:set(jid_split(room.jid), nil); + persistent_rooms:set(nil, room.jid, nil); + room_items_cache[room.jid] = nil; +end + +function module.unload() + for room in live_rooms() do + room:save(nil, true); + forget_room(room); end -end); +end -local function handle_to_domain(event) - local origin, stanza = event.origin, event.stanza; - local type = stanza.attr.type; - if type == "error" or type == "result" then return; end - if stanza.name == "iq" and type == "get" then - local xmlns = stanza.tags[1].attr.xmlns; - local node = stanza.tags[1].attr.node; - if xmlns == "http://jabber.org/protocol/muc#unique" then - origin.send(st.reply(stanza):tag("unique", {xmlns = xmlns}):text(uuid_gen())); -- FIXME Random UUIDs can theoretically have collisions - else - origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); -- TODO disco/etc - end - else - host_room:handle_stanza(origin, stanza); - --origin.send(st.error_reply(stanza, "cancel", "service-unavailable", "The muc server doesn't deal with messages and presence directed at it")); +function get_room_from_jid(room_jid) + local room = rooms:get(room_jid); + if room then + room_hit(); + rooms:set(room_jid, room); -- bump to top; + return room; end - return true; + room_miss(); + return restore_room(room_jid); end -function stanza_handler(event) - local origin, stanza = event.origin, event.stanza; - local bare = jid_bare(stanza.attr.to); - local room = rooms[bare]; - if not room then - if stanza.name ~= "presence" or stanza.attr.type ~= nil then - if stanza.attr.type ~= "error" then - origin.send(st.error_reply(stanza, "cancel", "item-not-found")); +function create_room(room_jid, config) + local exists = get_room_from_jid(room_jid); + if exists then + return nil, "room-exists"; + end + local room = muclib.new_room(room_jid, config); + module:fire_event("muc-room-created", { + room = room; + }); + return track_room(room); +end + +function all_rooms() + return coroutine.wrap(function () + local seen = {}; -- Don't iterate over persistent rooms twice + for room in live_rooms() do + coroutine.yield(room); + seen[room.jid] = true; + end + local all_persistent_rooms, err = persistent_rooms_storage:get(nil); + if not all_persistent_rooms then + if err then + module:log("error", "Error loading list of persistent rooms, only rooms live in memory were iterated over"); + module:log("debug", "%s", debug.traceback(err)); end - return true; + return nil; end - if not(restrict_room_creation) or - is_admin(stanza.attr.from) or - (restrict_room_creation == "local" and select(2, jid_split(stanza.attr.from)) == module.host:gsub("^[^%.]+%.", "")) then - room = create_room(bare, lock_rooms); + for room_jid in pairs(all_persistent_rooms) do + if not seen[room_jid] then + local room = restore_room(room_jid); + if room then + coroutine.yield(room); + else + module:log("error", "Missing data for room '%s', omitting from iteration", room_jid); + end + end end + end); +end + +function live_rooms() + return rooms:values(); +end + +function each_room(live_only) + if live_only then + return live_rooms(); end - if room then - room:handle_stanza(origin, stanza); - if not next(room._occupants) and not persistent_rooms[room.jid] then -- empty, non-persistent room - module:fire_event("muc-room-destroyed", { room = room }); - rooms[bare] = nil; -- discard room + return all_rooms(); +end + +module:hook("host-disco-items", function(event) + local reply = event.reply; + module:log("debug", "host-disco-items called"); + if next(room_items_cache) ~= nil then + for jid, room_name in pairs(room_items_cache) do + reply:tag("item", { jid = jid, name = room_name }):up(); end else - origin.send(st.error_reply(stanza, "cancel", "not-allowed")); + for room in all_rooms() do + if not room:get_hidden() then + local jid, room_name = room.jid, room:get_name(); + room_items_cache[jid] = room_name; + reply:tag("item", { jid = jid, name = room_name }):up(); + end + end end - return true; -end -module:hook("iq/bare", stanza_handler, -1); -module:hook("message/bare", stanza_handler, -1); -module:hook("presence/bare", stanza_handler, -1); -module:hook("iq/full", stanza_handler, -1); -module:hook("message/full", stanza_handler, -1); -module:hook("presence/full", stanza_handler, -1); -module:hook("iq/host", handle_to_domain, -1); -module:hook("message/host", handle_to_domain, -1); -module:hook("presence/host", handle_to_domain, -1); - -hosts[module.host].send = function(stanza) -- FIXME do a generic fix - if stanza.attr.type == "result" or stanza.attr.type == "error" then - module:send(stanza); - else error("component.send only supports result and error stanzas at the moment"); end -end +end); -hosts[module:get_host()].muc = { rooms = rooms }; +module:hook("muc-room-pre-create", function (event) + local room = event.room; + room:set_public(module:get_option_boolean("muc_room_default_public", false)); + room:set_persistent(module:get_option_boolean("muc_room_default_persistent", room:get_persistent())); + room:set_members_only(module:get_option_boolean("muc_room_default_members_only", room:get_members_only())); + room:set_allow_member_invites(module:get_option_boolean("muc_room_default_allow_member_invites", + room:get_allow_member_invites())); + room:set_moderated(module:get_option_boolean("muc_room_default_moderated", room:get_moderated())); + room:set_whois(module:get_option_boolean("muc_room_default_public_jids", + room:get_whois() == "anyone") and "anyone" or "moderators"); + room:set_changesubject(module:get_option_boolean("muc_room_default_change_subject", room:get_changesubject())); + room:set_historylength(module:get_option_number("muc_room_default_history_length", room:get_historylength())); + room:set_language(event.stanza.attr["xml:lang"] or module:get_option_string("muc_room_default_language")); +end, 1); -local saved = false; -module.save = function() - saved = true; - return {rooms = rooms}; +module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + if not track_room(event.room) then + origin.send(st.error_reply(stanza, "wait", "resource-constraint")); + return true; + end +end, -1000); + +module:hook("muc-room-destroyed",function(event) + local room = event.room; + forget_room(room); + delete_room(room); +end); + +if module:get_option_boolean("muc_tombstones", true) then + + local ttl = module:get_option_number("muc_tombstone_expiry", 86400 * 31); + + module:hook("muc-room-destroyed",function(event) + local room = event.room; + if not room:get_persistent() then return end + if room._data.destroyed then + return -- Allow destruction of tombstone + end + + local tombstone = new_room(room.jid, { + locked = os.time() + ttl; + destroyed = true; + reason = event.reason; + newjid = event.newjid; + -- password? + }); + tombstone.save = room_save; + tombstone:set_persistent(true); + tombstone:set_hidden(true); + tombstone:save(true); + return true; + end, -10); end -module.restore = function(data) - for jid, oldroom in pairs(data.rooms or {}) do - local room = create_room(jid); - room._jid_nick = oldroom._jid_nick; - room._occupants = oldroom._occupants; - room._data = oldroom._data; - room._affiliations = oldroom._affiliations; + +do + local restrict_room_creation = module:get_option("restrict_room_creation"); + if restrict_room_creation == true then + restrict_room_creation = "admin"; + end + if restrict_room_creation then + local host_suffix = module.host:gsub("^[^%.]+%.", ""); + module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + local user_jid = stanza.attr.from; + if not is_admin(user_jid) and not ( + restrict_room_creation == "local" and + select(2, jid_split(user_jid)) == host_suffix + ) then + origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted")); + return true; + end + end); end - hosts[module:get_host()].muc = { rooms = rooms }; end -function shutdown_room(room, stanza) - for nick, occupant in pairs(room._occupants) do - stanza.attr.from = nick; - for jid in pairs(occupant.sessions) do - stanza.attr.to = jid; - room:_route_stanza(stanza); - room._jid_nick[jid] = nil; +for event_name, method in pairs { + -- Normal room interactions + ["iq-get/bare/http://jabber.org/protocol/disco#info:query"] = "handle_disco_info_get_query" ; + ["iq-get/bare/http://jabber.org/protocol/disco#items:query"] = "handle_disco_items_get_query" ; + ["iq-set/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ; + ["iq-get/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_get_command" ; + ["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + ["iq-get/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_get_to_room" ; + ["message/bare"] = "handle_message_to_room" ; + ["presence/bare"] = "handle_presence_to_room" ; + ["iq/bare/jabber:iq:register:query"] = "handle_register_iq"; + -- Host room + ["iq-get/host/http://jabber.org/protocol/disco#info:query"] = "handle_disco_info_get_query" ; + ["iq-get/host/http://jabber.org/protocol/disco#items:query"] = "handle_disco_items_get_query" ; + ["iq-set/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ; + ["iq-get/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_get_command" ; + ["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + ["iq-get/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_get_to_room" ; + ["message/host"] = "handle_message_to_room" ; + ["presence/host"] = "handle_presence_to_room" ; + -- Direct to occupant (normal rooms and host room) + ["presence/full"] = "handle_presence_to_occupant" ; + ["iq/full"] = "handle_iq_to_occupant" ; + ["message/full"] = "handle_message_to_occupant" ; +} do + module:hook(event_name, function (event) + local origin, stanza = event.origin, event.stanza; + local room_jid = jid_bare(stanza.attr.to); + local room = get_room_from_jid(room_jid); + + if room and room._data.destroyed then + if room._data.locked < os.time() + or (is_admin(stanza.attr.from) and stanza.name == "presence" and stanza.attr.type == nil) then + -- Allow the room to be recreated by admin or after time has passed + delete_room(room); + room = nil; + else + if stanza.attr.type ~= "error" then + local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason) + if room._data.newjid then + local uri = "xmpp:"..room._data.newjid.."?join"; + reply:get_child("error"):child_with_name("gone"):text(uri); + end + event.origin.send(reply); + end + return true; + end end - room._occupants[nick] = nil; - end + + if room == nil then + -- Watch presence to create rooms + if stanza.attr.type == nil and stanza.name == "presence" then + room = muclib.new_room(room_jid); + return room:handle_first_presence(origin, stanza); + elseif stanza.attr.type ~= "error" then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + else + return; + end + elseif room == false then -- Error loading room + origin.send(st.error_reply(stanza, "wait", "resource-constraint")); + return true; + end + return room[method](room, origin, stanza); + end, -2) end + function shutdown_component() - if not saved then - local stanza = st.presence({type = "unavailable"}) - :tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) - :tag("item", { affiliation='none', role='none' }):up() - :tag("status", { code = "332"}):up(); - for roomjid, room in pairs(rooms) do - shutdown_room(room, stanza); - end - shutdown_room(host_room, stanza); + for room in live_rooms() do + room:save(nil, true); end end -module.unload = shutdown_component; -module:hook_global("server-stopping", shutdown_component); - --- Ad-hoc commands -module:depends("adhoc") -local t_concat = table.concat; -local keys = require "util.iterators".keys; -local adhoc_new = module:require "adhoc".new; -local adhoc_initial = require "util.adhoc".new_initial_data_form; -local dataforms_new = require "util.dataforms".new; - -local destroy_rooms_layout = dataforms_new { - title = "Destroy rooms"; - instructions = "Select the rooms to destroy"; - - { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#destroy" }; - { name = "rooms", type = "list-multi", required = true, label = "Rooms to destroy:"}; -}; - -local destroy_rooms_handler = adhoc_initial(destroy_rooms_layout, function() - return { rooms = array.collect(keys(rooms)):sort() }; -end, function(fields, errors) - if errors then - local errmsg = {}; - for name, err in pairs(errors) do - errmsg[#errmsg + 1] = name .. ": " .. err; +module:hook_global("server-stopping", shutdown_component, -300); + +do -- Ad-hoc commands + module:depends "adhoc"; + local t_concat = table.concat; + local adhoc_new = module:require "adhoc".new; + local adhoc_initial = require "util.adhoc".new_initial_data_form; + local array = require "util.array"; + local dataforms_new = require "util.dataforms".new; + + local destroy_rooms_layout = dataforms_new { + title = "Destroy rooms"; + instructions = "Select the rooms to destroy"; + + { name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#destroy" }; + { name = "rooms", type = "list-multi", required = true, label = "Rooms to destroy:"}; + }; + + local destroy_rooms_handler = adhoc_initial(destroy_rooms_layout, function() + return { rooms = array.collect(all_rooms()):pluck("jid"):sort(); }; + end, function(fields, errors) + if errors then + local errmsg = {}; + for field, err in pairs(errors) do + errmsg[#errmsg + 1] = field .. ": " .. err; + end + return { status = "completed", error = { message = t_concat(errmsg, "\n") } }; end - return { status = "completed", error = { message = t_concat(errmsg, "\n") } }; - end - for _, room in ipairs(fields.rooms) do - rooms[room]:destroy(); - rooms[room] = nil; - end - return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(fields.rooms, "\n") }; -end); -local destroy_rooms_desc = adhoc_new("Destroy Rooms", "http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin"); + for _, room in ipairs(fields.rooms) do + get_room_from_jid(room):destroy(); + end + return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(fields.rooms, "\n") }; + end); + local destroy_rooms_desc = adhoc_new("Destroy Rooms", + "http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin"); -module:provides("adhoc", destroy_rooms_desc); + module:provides("adhoc", destroy_rooms_desc); +end diff --git a/plugins/muc/moderated.lib.lua b/plugins/muc/moderated.lib.lua new file mode 100644 index 00000000..64ddb542 --- /dev/null +++ b/plugins/muc/moderated.lib.lua @@ -0,0 +1,54 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local function get_moderated(room) + return room._data.moderated; +end + +local function set_moderated(room, moderated) + moderated = moderated and true or nil; + if get_moderated(room) == moderated then return false; end + room._data.moderated = moderated; + return true; +end + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = get_moderated(event.room) and "muc_moderated" or "muc_unmoderated"}):up(); +end); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_moderatedroom"; + type = "boolean"; + label = "Moderated (require permission to speak)"; + desc = "In moderated rooms occupants must be given permission to speak by a room moderator"; + value = get_moderated(event.room); + }); +end, 80-3); + +module:hook("muc-config-submitted/muc#roomconfig_moderatedroom", function(event) + if set_moderated(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +module:hook("muc-get-default-role", function(event) + if event.affiliation == nil then + if get_moderated(event.room) then + -- XEP-0045: + -- An implementation MAY grant voice by default to visitors in unmoderated rooms. + return "visitor" + end + end +end, 1); + +return { + get = get_moderated; + set = set_moderated; +}; diff --git a/plugins/muc/muc.lib.lua b/plugins/muc/muc.lib.lua index 8257b0b7..8e039457 100644 --- a/plugins/muc/muc.lib.lua +++ b/plugins/muc/muc.lib.lua @@ -1,66 +1,35 @@ -- Prosody IM -- Copyright (C) 2008-2010 Matthew Wild -- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator -- -- This project is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. -- local select = select; -local pairs, ipairs = pairs, ipairs; - -local datetime = require "util.datetime"; +local pairs = pairs; +local next = next; +local setmetatable = setmetatable; local dataform = require "util.dataforms"; - +local iterators = require "util.iterators"; local jid_split = require "util.jid".split; local jid_bare = require "util.jid".bare; local jid_prep = require "util.jid".prep; +local jid_join = require "util.jid".join; +local jid_resource = require "util.jid".resource; +local resourceprep = require "util.encodings".stringprep.resourceprep; local st = require "util.stanza"; -local log = require "util.logger".init("mod_muc"); -local t_insert, t_remove = table.insert, table.remove; -local setmetatable = setmetatable; local base64 = require "util.encodings".base64; local md5 = require "util.hashes".md5; -local muc_domain = nil; --module:get_host(); -local default_history_length, max_history_length = 20, math.huge; - ------------- -local presence_filters = {["http://jabber.org/protocol/muc"]=true;["http://jabber.org/protocol/muc#user"]=true}; -local function presence_filter(tag) - if presence_filters[tag.attr.xmlns] then - return nil; - end - return tag; -end - -local function get_filtered_presence(stanza) - return st.clone(stanza):maptags(presence_filter); -end -local kickable_error_conditions = { - ["gone"] = true; - ["internal-server-error"] = true; - ["item-not-found"] = true; - ["jid-malformed"] = true; - ["recipient-unavailable"] = true; - ["redirect"] = true; - ["remote-server-not-found"] = true; - ["remote-server-timeout"] = true; - ["service-unavailable"] = true; - ["malformed error"] = true; -}; - -local function get_error_condition(stanza) - local _, condition = stanza:get_error(); - return condition or "malformed error"; -end +local log = module._log; -local function is_kickable_error(stanza) - local cond = get_error_condition(stanza); - return kickable_error_conditions[cond] and cond; -end ------------ +local occupant_lib = module:require "muc/occupant" +local muc_util = module:require "muc/util"; +local is_kickable_error = muc_util.is_kickable_error; +local valid_roles, valid_affiliations = muc_util.valid_roles, muc_util.valid_affiliations; local room_mt = {}; room_mt.__index = room_mt; @@ -69,37 +38,149 @@ function room_mt:__tostring() return "MUC room ("..self.jid..")"; end +function room_mt.save() + -- overriden by mod_muc.lua +end + +function room_mt:get_occupant_jid(real_jid) + return self._jid_nick[real_jid] +end + function room_mt:get_default_role(affiliation) - if affiliation == "owner" or affiliation == "admin" then + local role = module:fire_event("muc-get-default-role", { + room = self; + affiliation = affiliation; + affiliation_rank = valid_affiliations[affiliation or "none"]; + }); + role = role ~= "none" and role or nil; -- coerces `role == false` to `nil` + return role, valid_roles[role or "none"]; +end +module:hook("muc-get-default-role", function(event) + if event.affiliation_rank >= valid_affiliations.admin then return "moderator"; - elseif affiliation == "member" then + elseif event.affiliation_rank >= valid_affiliations.none then return "participant"; - elseif not affiliation then - if not self:get_members_only() then - return self:get_moderated() and "visitor" or "participant"; - end end +end); + +--- Occupant functions +function room_mt:new_occupant(bare_real_jid, nick) + local occupant = occupant_lib.new(bare_real_jid, nick); + local affiliation = self:get_affiliation(bare_real_jid); + occupant.role = self:get_default_role(affiliation); + return occupant; +end + +-- nick is in the form of an in-room JID +function room_mt:get_occupant_by_nick(nick) + local occupant = self._occupants[nick]; + if occupant == nil then return nil end + return occupant_lib.copy(occupant); +end + +do + local function next_copied_occupant(occupants, occupant_jid) + local next_occupant_jid, raw_occupant = next(occupants, occupant_jid); + if next_occupant_jid == nil then return nil end + return next_occupant_jid, occupant_lib.copy(raw_occupant); + end + -- FIXME Explain what 'read_only' is supposed to be + function room_mt:each_occupant(read_only) -- luacheck: ignore 212 + return next_copied_occupant, self._occupants, nil; + end +end + +function room_mt:has_occupant() + return next(self._occupants, nil) ~= nil +end + +function room_mt:get_occupant_by_real_jid(real_jid) + local occupant_jid = self:get_occupant_jid(real_jid); + if occupant_jid == nil then return nil end + return self:get_occupant_by_nick(occupant_jid); end -function room_mt:broadcast_presence(stanza, sid, code, nick) - stanza = get_filtered_presence(stanza); - local occupant = self._occupants[stanza.attr.from]; - stanza:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) - :tag("item", {affiliation=occupant.affiliation or "none", role=occupant.role or "none", nick=nick}):up(); - if code then - stanza:tag("status", {code=code}):up(); - end - self:broadcast_except_nick(stanza, stanza.attr.from); - local me = self._occupants[stanza.attr.from]; - if me then - stanza:tag("status", {code='110'}):up(); - stanza.attr.to = sid; - self:_route_stanza(stanza); +function room_mt:save_occupant(occupant) + occupant = occupant_lib.copy(occupant); -- So that occupant can be modified more + local id = occupant.nick + + -- Need to maintain _jid_nick secondary index + local old_occupant = self._occupants[id]; + if old_occupant then + for real_jid in old_occupant:each_session() do + self._jid_nick[real_jid] = nil; + end + end + + local has_live_session = false + if occupant.role ~= nil then + for real_jid, presence in occupant:each_session() do + if presence.attr.type == nil then + has_live_session = true + self._jid_nick[real_jid] = occupant.nick; + end + end + if not has_live_session then + -- Has no live sessions left; they have left the room. + occupant.role = nil + end + end + if not has_live_session then + occupant = nil end + self._occupants[id] = occupant + return occupant end -function room_mt:broadcast_message(stanza, historic) + +function room_mt:route_to_occupant(occupant, stanza) local to = stanza.attr.to; - local room_jid = self.jid; + for jid in occupant:each_session() do + stanza.attr.to = jid; + self:route_stanza(stanza); + end + stanza.attr.to = to; +end + +-- actor is the attribute table +local function add_item(x, affiliation, role, jid, nick, actor_nick, actor_jid, reason) + x:tag("item", {affiliation = affiliation; role = role; jid = jid; nick = nick;}) + if actor_nick or actor_jid then + x:tag("actor", {nick = actor_nick; jid = actor_jid;}):up() + end + if reason then + x:tag("reason"):text(reason):up() + end + x:up(); + return x +end + +-- actor is (real) jid +function room_mt:build_item_list(occupant, x, is_anonymous, nick, actor_nick, actor_jid, reason) + local affiliation = self:get_affiliation(occupant.bare_jid) or "none"; + local role = occupant.role or "none"; + if is_anonymous then + add_item(x, affiliation, role, nil, nick, actor_nick, actor_jid, reason); + else + for real_jid in occupant:each_session() do + add_item(x, affiliation, role, real_jid, nick, actor_nick, actor_jid, reason); + end + end + return x +end + +function room_mt:broadcast_message(stanza) + if module:fire_event("muc-broadcast-message", {room = self, stanza = stanza}) then + return true; + end + self:broadcast(stanza); + return true; +end + +-- Strip delay tags claiming to be from us +module:hook("muc-occupant-groupchat", function (event) + local stanza = event.stanza; + local room = event.room; + local room_jid = room.jid; stanza:maptags(function (child) if child.name == "delay" and child.attr["xmlns"] == "urn:xmpp:delay" then @@ -114,509 +195,597 @@ function room_mt:broadcast_message(stanza, historic) end return child; end) +end); - for occupant, o_data in pairs(self._occupants) do - for jid in pairs(o_data.sessions) do - stanza.attr.to = jid; - self:_route_stanza(stanza); +-- Broadcast a stanza to all occupants in the room. +-- optionally checks conditional called with (nick, occupant) +function room_mt:broadcast(stanza, cond_func) + for nick, occupant in self:each_occupant() do + if cond_func == nil or cond_func(nick, occupant) then + self:route_to_occupant(occupant, stanza) end end - stanza.attr.to = to; - if historic then -- add to history - return self:save_to_history(stanza) - end end -function room_mt:save_to_history(stanza) - local history = self._data['history']; - if not history then history = {}; self._data['history'] = history; end - stanza = st.clone(stanza); - stanza.attr.to = ""; - local stamp = datetime.datetime(); - stanza:tag("delay", {xmlns = "urn:xmpp:delay", from = self.jid, stamp = stamp}):up(); -- XEP-0203 - stanza:tag("x", {xmlns = "jabber:x:delay", from = self.jid, stamp = datetime.legacy()}):up(); -- XEP-0091 (deprecated) - local entry = { stanza = stanza, stamp = stamp }; - t_insert(history, entry); - while #history > (self._data.history_length or default_history_length) do t_remove(history, 1) end + +local function can_see_real_jids(whois, occupant) + if whois == "anyone" then + return true; + elseif whois == "moderators" then + return valid_roles[occupant.role or "none"] >= valid_roles.moderator; + end end -function room_mt:broadcast_except_nick(stanza, nick) - for rnick, occupant in pairs(self._occupants) do - if rnick ~= nick then - for jid in pairs(occupant.sessions) do - stanza.attr.to = jid; - self:_route_stanza(stanza); + +-- Broadcasts an occupant's presence to the whole room +-- Takes the x element that goes into the stanzas +function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason) + local base_x = x.base or x; + -- Build real jid and (optionally) occupant jid template presences + local base_presence do + -- Try to use main jid's presence + local pr = occupant:get_presence(); + if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") then + base_presence = st.clone(pr); + else -- user is leaving but didn't send a leave presence. make one for them + base_presence = st.presence {from = occupant.nick; type = "unavailable";}; + end + end + + -- Fire event (before full_p and anon_p are created) + local event = { + room = self; stanza = base_presence; x = base_x; + occupant = occupant; nick = nick; actor = actor; + reason = reason; + } + module:fire_event("muc-broadcast-presence", event); + + -- Allow muc-broadcast-presence listeners to change things + nick = event.nick; + actor = event.actor; + reason = event.reason; + + local whois = self:get_whois(); + + local actor_nick; + if actor then + actor_nick = jid_resource(self:get_occupant_jid(actor)); + end + + local full_p, full_x; + local function get_full_p() + if full_p == nil then + full_x = st.clone(x.full or base_x); + self:build_item_list(occupant, full_x, false, nick, actor_nick, actor, reason); + full_p = st.clone(base_presence):add_child(full_x); + end + return full_p, full_x; + end + + local anon_p, anon_x; + local function get_anon_p() + if anon_p == nil then + anon_x = st.clone(x.anon or base_x); + self:build_item_list(occupant, anon_x, true, nick, actor_nick, nil, reason); + anon_p = st.clone(base_presence):add_child(anon_x); + end + return anon_p, anon_x; + end + + local self_p, self_x; + do + -- Can always see your own full jids + -- But not allowed to see actor's + self_x = st.clone(x.self or base_x); + self:build_item_list(occupant, self_x, false, nick, actor_nick, nil, reason); + self_p = st.clone(base_presence):add_child(self_x); + end + + -- General populance + for occupant_nick, n_occupant in self:each_occupant() do + if occupant_nick ~= occupant.nick then + local pr; + if can_see_real_jids(whois, n_occupant) then + pr = get_full_p(); + elseif occupant.bare_jid == n_occupant.bare_jid then + pr = self_p; + else + pr = get_anon_p(); end + self:route_to_occupant(n_occupant, pr); end end -end -function room_mt:send_occupant_list(to) - local current_nick = self._jid_nick[to]; - for occupant, o_data in pairs(self._occupants) do - if occupant ~= current_nick then - local pres = get_filtered_presence(o_data.sessions[o_data.jid]); - pres.attr.to, pres.attr.from = to, occupant; - pres:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) - :tag("item", {affiliation=o_data.affiliation or "none", role=o_data.role or "none"}):up(); - self:_route_stanza(pres); + -- Presences for occupant itself + self_x:tag("status", {code = "110";}):up(); + if occupant.role == nil then + -- They get an unavailable + self:route_to_occupant(occupant, self_p); + else + -- use their own presences as templates + for full_jid, pr in occupant:each_session() do + pr = st.clone(pr); + pr.attr.to = full_jid; + pr:add_child(self_x); + self:route_stanza(pr); end end end -function room_mt:send_history(to, stanza) - local history = self._data['history']; -- send discussion history - if history then - local x_tag = stanza and stanza:get_child("x", "http://jabber.org/protocol/muc"); - local history_tag = x_tag and x_tag:get_child("history", "http://jabber.org/protocol/muc"); - - local maxchars = history_tag and tonumber(history_tag.attr.maxchars); - if maxchars then maxchars = math.floor(maxchars); end - - local maxstanzas = math.floor(history_tag and tonumber(history_tag.attr.maxstanzas) or #history); - if not history_tag then maxstanzas = 20; end - - local seconds = history_tag and tonumber(history_tag.attr.seconds); - if seconds then seconds = datetime.datetime(os.time() - math.floor(seconds)); end - - local since = history_tag and history_tag.attr.since; - if since then since = datetime.parse(since); since = since and datetime.datetime(since); end - if seconds and (not since or since < seconds) then since = seconds; end - - local n = 0; - local charcount = 0; - - for i=#history,1,-1 do - local entry = history[i]; - if maxchars then - if not entry.chars then - entry.stanza.attr.to = ""; - entry.chars = #tostring(entry.stanza); - end - charcount = charcount + entry.chars + #to; - if charcount > maxchars then break; end + +function room_mt:send_occupant_list(to, filter) + local to_bare = jid_bare(to); + local is_anonymous = false; + local whois = self:get_whois(); + if whois ~= "anyone" then + local affiliation = self:get_affiliation(to); + if affiliation ~= "admin" and affiliation ~= "owner" then + local occupant = self:get_occupant_by_real_jid(to); + if not (occupant and can_see_real_jids(whois, occupant)) then + is_anonymous = true; end - if since and since > entry.stamp then break; end - if n + 1 > maxstanzas then break; end - n = n + 1; end - for i=#history-n+1,#history do - local msg = history[i].stanza; - msg.attr.to = to; - self:_route_stanza(msg); + end + for occupant_jid, occupant in self:each_occupant() do + if filter == nil or filter(occupant_jid, occupant) then + local x = st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'}); + self:build_item_list(occupant, x, is_anonymous and to_bare ~= occupant.bare_jid); -- can always see your own jids + local pres = st.clone(occupant:get_presence()); + pres.attr.to = to; + pres:add_child(x); + self:route_stanza(pres); end end end -function room_mt:send_subject(to) - self:_route_stanza(st.message({type='groupchat', from=self._data['subject_from'] or self.jid, to=to}):tag("subject"):text(self._data['subject'])); -end function room_mt:get_disco_info(stanza) - local count = 0; for _ in pairs(self._occupants) do count = count + 1; end - local reply = st.reply(stanza):query("http://jabber.org/protocol/disco#info") - :tag("identity", {category="conference", type="text", name=self:get_name()}):up() - :tag("feature", {var="http://jabber.org/protocol/muc"}):up() - :tag("feature", {var="http://jabber.org/protocol/muc#stable_id"}):up() - :tag("feature", {var=self:get_password() and "muc_passwordprotected" or "muc_unsecured"}):up() - :tag("feature", {var=self:get_moderated() and "muc_moderated" or "muc_unmoderated"}):up() - :tag("feature", {var=self:get_members_only() and "muc_membersonly" or "muc_open"}):up() - :tag("feature", {var=self:get_persistent() and "muc_persistent" or "muc_temporary"}):up() - :tag("feature", {var=self:get_hidden() and "muc_hidden" or "muc_public"}):up() - :tag("feature", {var=self._data.whois ~= "anyone" and "muc_semianonymous" or "muc_nonanonymous"}):up() - ; - local dataform = dataform.new({ - { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#roominfo" }, - { name = "muc#roominfo_description", label = "Description", value = "" }, - { name = "muc#roominfo_occupants", label = "Number of occupants", value = "" } - }); - local formdata = { - ["muc#roominfo_description"] = self:get_description(), - ["muc#roominfo_occupants"] = tostring(count), - }; - module:fire_event("muc-disco#info", { room = self, reply = reply, form = dataform, formdata = formdata }); - reply:add_child(dataform:form(formdata, 'result')) + local node = stanza.tags[1].attr.node or ""; + local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#info", node = node }); + local event_name = "muc-disco#info"; + local event_data = { room = self, reply = reply, stanza = stanza }; + + if node ~= "" then + event_name = event_name.."/"..node; + else + event_data.form = dataform.new { + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#roominfo" }; + }; + event_data.formdata = {}; + end + module:fire_event(event_name, event_data); + if event_data.form then + reply:add_child(event_data.form:form(event_data.formdata, "result")); + end return reply; end -function room_mt:get_disco_items(stanza) +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = "http://jabber.org/protocol/muc"}):up(); + event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#stable_id"}):up(); +end); +module:hook("muc-disco#info", function(event) + table.insert(event.form, { name = "muc#roominfo_occupants", label = "Number of occupants" }); + event.formdata["muc#roominfo_occupants"] = tostring(iterators.count(event.room:each_occupant())); +end); + +function room_mt:get_disco_items(stanza) -- luacheck: ignore 212 return st.reply(stanza):query("http://jabber.org/protocol/disco#items"); end -function room_mt:set_subject(current_nick, subject) - if subject == "" then subject = nil; end - self._data['subject'] = subject; - self._data['subject_from'] = current_nick; - if self.save then self:save(); end - local msg = st.message({type='groupchat', from=current_nick}) - :tag('subject'):text(subject):up(); - self:broadcast_message(msg, false); - return true; -end -local function build_unavailable_presence_from_error(stanza) +function room_mt:handle_kickable(origin, stanza) -- luacheck: ignore 212 + local real_jid = stanza.attr.from; + local occupant = self:get_occupant_by_real_jid(real_jid); + if occupant == nil then return nil; end local type, condition, text = stanza:get_error(); local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error"); - if text then + if text and self:get_whois() == "anyone" then error_message = error_message..": "..text; end - return st.presence({type='unavailable', from=stanza.attr.from, to=stanza.attr.to}) - :tag('status'):text(error_message); + occupant:set_session(real_jid, st.presence({type="unavailable"}) + :tag('status'):text(error_message)); + local is_last_session = occupant.jid == real_jid; + if is_last_session then + occupant.role = nil; + end + local new_occupant = self:save_occupant(occupant); + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + if is_last_session then + x:tag("status", {code = "333"}); + end + self:publicise_occupant_status(new_occupant or occupant, x); + if is_last_session then + module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + end + return true; end -function room_mt:set_name(name) - if name == "" or type(name) ~= "string" or name == (jid_split(self.jid)) then name = nil; end - if self._data.name ~= name then - self._data.name = name; - if self.save then self:save(true); end +-- Give the room creator owner affiliation +module:hook("muc-room-pre-create", function(event) + event.room:set_affiliation(true, jid_bare(event.stanza.attr.from), "owner"); +end, -1); + +-- check if user is banned +module:hook("muc-occupant-pre-join", function(event) + local room, stanza = event.room, event.stanza; + local affiliation = room:get_affiliation(stanza.attr.from); + if affiliation == "outcast" then + local reply = st.error_reply(stanza, "auth", "forbidden"):up(); + reply.tags[1].attr.code = "403"; + event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; end -end -function room_mt:get_name() - return self._data.name or jid_split(self.jid); -end -function room_mt:set_description(description) - if description == "" or type(description) ~= "string" then description = nil; end - if self._data.description ~= description then - self._data.description = description; - if self.save then self:save(true); end +end, -10); + +module:hook("muc-occupant-pre-join", function(event) + local nick = jid_resource(event.occupant.nick); + if not nick:find("%S") then + event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden")); + return true; end -end -function room_mt:get_description() - return self._data.description; -end -function room_mt:set_password(password) - if password == "" or type(password) ~= "string" then password = nil; end - if self._data.password ~= password then - self._data.password = password; - if self.save then self:save(true); end +end, 1); + +module:hook("muc-occupant-pre-change", function(event) + if not jid_resource(event.dest_occupant.nick):find("%S") then + event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden")); + return true; end -end -function room_mt:get_password() - return self._data.password; -end -function room_mt:set_moderated(moderated) - moderated = moderated and true or nil; - if self._data.moderated ~= moderated then - self._data.moderated = moderated; - if self.save then self:save(true); end +end, 1); + +function room_mt:handle_first_presence(origin, stanza) + if not stanza:get_child("x", "http://jabber.org/protocol/muc") then + module:log("debug", "Room creation without <x>, possibly desynced"); + + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; end -end -function room_mt:get_moderated() - return self._data.moderated; -end -function room_mt:set_members_only(members_only) - members_only = members_only and true or nil; - if self._data.members_only ~= members_only then - self._data.members_only = members_only; - if self.save then self:save(true); end + + local real_jid = stanza.attr.from; + local dest_jid = stanza.attr.to; + local bare_jid = jid_bare(real_jid); + if module:fire_event("muc-room-pre-create", { + room = self; + origin = origin; + stanza = stanza; + }) then return true; end + local is_first_dest_session = true; + local dest_occupant = self:new_occupant(bare_jid, dest_jid); + + local orig_nick = dest_occupant.nick; + if module:fire_event("muc-occupant-pre-join", { + room = self; + origin = origin; + stanza = stanza; + is_first_session = is_first_dest_session; + is_new_room = true; + occupant = dest_occupant; + }) then return true; end + local nick_changed = orig_nick ~= dest_occupant.nick; + + dest_occupant:set_session(real_jid, stanza); + local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + dest_x:tag("status", {code = "201"}):up(); + if self:get_whois() == "anyone" then + dest_x:tag("status", {code = "100"}):up(); end -end -function room_mt:get_members_only() - return self._data.members_only; -end -function room_mt:set_persistent(persistent) - persistent = persistent and true or nil; - if self._data.persistent ~= persistent then - self._data.persistent = persistent; - if self.save then self:save(true); end + if nick_changed then + dest_x:tag("status", {code = "210"}):up(); end + self:save_occupant(dest_occupant); + + self:publicise_occupant_status(dest_occupant, dest_x); + + module:fire_event("muc-occupant-joined", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + }); + module:fire_event("muc-occupant-session-new", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + jid = real_jid; + }); + module:fire_event("muc-room-created", { + room = self; + creator = dest_occupant; + stanza = stanza; + origin = origin; + }); + return true; end -function room_mt:get_persistent() - return self._data.persistent; -end -function room_mt:set_hidden(hidden) - hidden = hidden and true or nil; - if self._data.hidden ~= hidden then - self._data.hidden = hidden; - if self.save then self:save(true); end + +function room_mt:handle_normal_presence(origin, stanza) + local type = stanza.attr.type; + local real_jid = stanza.attr.from; + local bare_jid = jid_bare(real_jid); + local orig_occupant = self:get_occupant_by_real_jid(real_jid); + local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc"); + + if orig_occupant == nil and not muc_x and stanza.attr.type == nil then + module:log("debug", "Attempted join without <x>, possibly desynced"); + origin.send(st.error_reply(stanza, "cancel", "item-not-found", + "You must join the room before sending presence updates")); + return true; end -end -function room_mt:get_hidden() - return self._data.hidden; -end -function room_mt:get_public() - return not self:get_hidden(); -end -function room_mt:set_public(public) - return self:set_hidden(not public); -end -function room_mt:set_changesubject(changesubject) - changesubject = changesubject and true or nil; - if self._data.changesubject ~= changesubject then - self._data.changesubject = changesubject; - if self.save then self:save(true); end + + local is_first_dest_session; + local dest_occupant; + if type == "unavailable" then + if orig_occupant == nil then return true; end -- Unavailable from someone not in the room + -- dest_occupant = nil + elseif orig_occupant and orig_occupant.nick == stanza.attr.to then -- Just a presence update + log("debug", "presence update for %s from session %s", orig_occupant.nick, real_jid); + dest_occupant = orig_occupant; + else + local dest_jid = stanza.attr.to; + dest_occupant = self:get_occupant_by_nick(dest_jid); + if dest_occupant == nil then + log("debug", "no occupant found for %s; creating new occupant object for %s", dest_jid, real_jid); + is_first_dest_session = true; + dest_occupant = self:new_occupant(bare_jid, dest_jid); + else + is_first_dest_session = false; + end end -end -function room_mt:get_changesubject() - return self._data.changesubject; -end -function room_mt:get_historylength() - return self._data.history_length or default_history_length; -end -function room_mt:set_historylength(length) - length = math.min(tonumber(length) or default_history_length, max_history_length or math.huge); - if length == default_history_length then - length = nil; + local is_last_orig_session; + if orig_occupant ~= nil then + -- Is there are least 2 sessions? + local iter, ob, last = orig_occupant:each_session(); + is_last_orig_session = iter(ob, iter(ob, last)) == nil; end - self._data.history_length = length; -end + local orig_nick = dest_occupant and dest_occupant.nick; + + local event, event_name = { + room = self; + origin = origin; + stanza = stanza; + is_first_session = is_first_dest_session; + is_last_session = is_last_orig_session; + }; + if orig_occupant == nil then + event_name = "muc-occupant-pre-join"; + event.occupant = dest_occupant; + elseif dest_occupant == nil then + event_name = "muc-occupant-pre-leave"; + event.occupant = orig_occupant; + else + event_name = "muc-occupant-pre-change"; + event.orig_occupant = orig_occupant; + event.dest_occupant = dest_occupant; + end + if module:fire_event(event_name, event) then return true; end -local valid_whois = { moderators = true, anyone = true }; + local nick_changed = dest_occupant and orig_nick ~= dest_occupant.nick; -function room_mt:set_whois(whois) - if valid_whois[whois] and self._data.whois ~= whois then - self._data.whois = whois; - if self.save then self:save(true); end + -- Check for nick conflicts + if dest_occupant ~= nil and not is_first_dest_session + and bare_jid ~= jid_bare(dest_occupant.bare_jid) then + -- new nick or has different bare real jid + log("debug", "%s couldn't join due to nick conflict: %s", real_jid, dest_occupant.nick); + local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + reply.tags[1].attr.code = "409"; + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; end -end -function room_mt:get_whois() - return self._data.whois; -end + -- Send presence stanza about original occupant + if orig_occupant ~= nil and orig_occupant ~= dest_occupant then + local orig_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + local dest_nick; + if dest_occupant == nil then -- Session is leaving + log("debug", "session %s is leaving occupant %s", real_jid, orig_occupant.nick); + if is_last_orig_session then + orig_occupant.role = nil; + end + orig_occupant:set_session(real_jid, stanza); + else + log("debug", "session %s is changing from occupant %s to %s", real_jid, orig_occupant.nick, dest_occupant.nick); + local generated_unavail = st.presence {from = orig_occupant.nick, to = real_jid, type = "unavailable"}; + orig_occupant:set_session(real_jid, generated_unavail); + dest_nick = jid_resource(dest_occupant.nick); + if not is_first_dest_session then -- User is swapping into another pre-existing session + log("debug", "session %s is swapping into multisession %s, showing it leave.", real_jid, dest_occupant.nick); + -- Show the other session leaving + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + add_item(x, self:get_affiliation(bare_jid), "none"); + local pr = st.presence{from = dest_occupant.nick, to = real_jid, type = "unavailable"} + :tag("status"):text("you are joining pre-existing session " .. dest_nick):up() + :add_child(x); + self:route_stanza(pr); + end + if is_first_dest_session and is_last_orig_session then -- Normal nick change + log("debug", "no sessions in %s left; publicly marking as nick change", orig_occupant.nick); + orig_x:tag("status", {code = "303";}):up(); + else -- The session itself always needs to see a nick change + -- don't want to get our old nick's available presence, + -- so remove our session from there, and manually generate an unavailable + orig_occupant:remove_session(real_jid); + log("debug", "generating nick change for %s", real_jid); + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + -- COMPAT: clients get confused if they see other items besides their own + -- self:build_item_list(orig_occupant, x, false, dest_nick); + add_item(x, self:get_affiliation(bare_jid), orig_occupant.role, real_jid, dest_nick); + x:tag("status", {code = "303";}):up(); + x:tag("status", {code = "110";}):up(); + self:route_stanza(generated_unavail:add_child(x)); + dest_nick = nil; -- set dest_nick to nil; so general populance doesn't see it for whole orig_occupant + end + end -local function construct_stanza_id(room, stanza) - local from_jid, to_nick = stanza.attr.from, stanza.attr.to; - local from_nick = room._jid_nick[from_jid]; - local occupant = room._occupants[to_nick]; - local to_jid = occupant.jid; + self:save_occupant(orig_occupant); + self:publicise_occupant_status(orig_occupant, orig_x, dest_nick); - return from_nick, to_jid, base64.encode(to_jid.."\0"..stanza.attr.id.."\0"..md5(from_jid)); -end -local function deconstruct_stanza_id(room, stanza) - local from_jid_possiblybare, to_nick = stanza.attr.from, stanza.attr.to; - local from_jid, id, to_jid_hash = (base64.decode(stanza.attr.id) or ""):match("^(%Z+)%z(%Z*)%z(.+)$"); - local from_nick = room._jid_nick[from_jid]; - - if not(from_nick) then return; end - if not(from_jid_possiblybare == from_jid or from_jid_possiblybare == jid_bare(from_jid)) then return; end - - local occupant = room._occupants[to_nick]; - for to_jid in pairs(occupant and occupant.sessions or {}) do - if md5(to_jid) == to_jid_hash then - return from_nick, to_jid, id; + if is_last_orig_session then + module:fire_event("muc-occupant-left", { + room = self; + nick = orig_occupant.nick; + occupant = orig_occupant; + origin = origin; + stanza = stanza; + }); + end + end + + if dest_occupant ~= nil then + dest_occupant:set_session(real_jid, stanza); + self:save_occupant(dest_occupant); + + if orig_occupant == nil or muc_x then + -- Send occupant list to newly joined or desynced user + self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212 + -- Don't include self + return occupant:get_presence(real_jid) == nil; + end) + end + local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + local self_x = st.clone(dest_x); + if orig_occupant == nil and self:get_whois() == "anyone" then + self_x:tag("status", {code = "100"}):up(); + end + if nick_changed then + self_x:tag("status", {code="210"}):up(); + end + self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x}); + + if orig_occupant ~= nil and orig_occupant ~= dest_occupant and not is_last_orig_session then + -- If user is swapping and wasn't last original session + log("debug", "session %s split nicks; showing %s rejoining", real_jid, orig_occupant.nick); + -- Show the original nick joining again + local pr = st.clone(orig_occupant:get_presence()); + pr.attr.to = real_jid; + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + self:build_item_list(orig_occupant, x, false); + -- TODO: new status code to inform client this was the multi-session it left? + pr:add_child(x); + self:route_stanza(pr); + end + + if orig_occupant == nil or muc_x then + if is_first_dest_session then + module:fire_event("muc-occupant-joined", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + }); + end + module:fire_event("muc-occupant-session-new", { + room = self; + nick = dest_occupant.nick; + occupant = dest_occupant; + stanza = stanza; + origin = origin; + jid = real_jid; + }); end end + return true; end +function room_mt:handle_presence_to_occupant(origin, stanza) + local type = stanza.attr.type; + if type == "error" then -- error, kick em out! + return self:handle_kickable(origin, stanza) + elseif type == nil or type == "unavailable" then + return self:handle_normal_presence(origin, stanza); + elseif type ~= 'result' then -- bad type + if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences + origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error? + end + end + return true; +end -function room_mt:handle_to_occupant(origin, stanza) -- PM, vCards, etc +function room_mt:handle_iq_to_occupant(origin, stanza) local from, to = stanza.attr.from, stanza.attr.to; - local room = jid_bare(to); - local current_nick = self._jid_nick[from]; local type = stanza.attr.type; - log("debug", "room: %s, current_nick: %s, stanza: %s", room or "nil", current_nick or "nil", stanza:top_tag()); - if (select(2, jid_split(from)) == muc_domain) then error("Presence from the MUC itself!!!"); end - if stanza.name == "presence" then - local pr = get_filtered_presence(stanza); - pr.attr.from = current_nick; - if type == "error" then -- error, kick em out! - if current_nick then - log("debug", "kicking %s from %s", current_nick, room); - self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); - end - elseif type == "unavailable" then -- unavailable - if current_nick then - log("debug", "%s leaving %s", current_nick, room); - self._jid_nick[from] = nil; - local occupant = self._occupants[current_nick]; - local new_jid = next(occupant.sessions); - if new_jid == from then new_jid = next(occupant.sessions, new_jid); end - if new_jid then - local jid = occupant.jid; - occupant.jid = new_jid; - occupant.sessions[from] = nil; - pr.attr.to = from; - pr:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) - :tag("item", {affiliation=occupant.affiliation or "none", role='none'}):up() - :tag("status", {code='110'}):up(); - self:_route_stanza(pr); - if jid ~= new_jid then - pr = st.clone(occupant.sessions[new_jid]) - :tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) - :tag("item", {affiliation=occupant.affiliation or "none", role=occupant.role or "none"}); - pr.attr.from = current_nick; - self:broadcast_except_nick(pr, current_nick); - end - else - occupant.role = 'none'; - self:broadcast_presence(pr, from); - self._occupants[current_nick] = nil; + local id = stanza.attr.id; + local occupant = self:get_occupant_by_nick(to); + if (type == "error" or type == "result") then + do -- deconstruct_stanza_id + if not occupant then return nil; end + local from_jid, orig_id, to_jid_hash = (base64.decode(id) or ""):match("^(%Z+)%z(%Z*)%z(.+)$"); + if not(from == from_jid or from == jid_bare(from_jid)) then return nil; end + local from_occupant_jid = self:get_occupant_jid(from_jid); + if from_occupant_jid == nil then return nil; end + local session_jid + for to_jid in occupant:each_session() do + if md5(to_jid) == to_jid_hash then + session_jid = to_jid; + break; end end - elseif not type then -- available - if current_nick then - --if #pr == #stanza or current_nick ~= to then -- commented because google keeps resending directed presence - if current_nick == to then -- simple presence - log("debug", "%s broadcasted presence", current_nick); - self._occupants[current_nick].sessions[from] = pr; - self:broadcast_presence(pr, from); - else -- change nick - -- a MUC service MUST NOT allow empty or invisible Room Nicknames - -- (i.e., Room Nicknames that consist only of one or more space characters). - if not select(3, jid_split(to)):find("[^ ]") then -- resourceprep turns all whitespace into 0x20 - module:log("debug", "Rejecting invisible nickname"); - origin.send(st.error_reply(stanza, "cancel", "not-allowed")); - return; - end - local occupant = self._occupants[current_nick]; - local is_multisession = next(occupant.sessions, next(occupant.sessions)); - if self._occupants[to] or is_multisession then - log("debug", "%s couldn't change nick", current_nick); - local reply = st.error_reply(stanza, "cancel", "conflict"):up(); - reply.tags[1].attr.code = "409"; - origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); - else - local data = self._occupants[current_nick]; - local to_nick = select(3, jid_split(to)); - if to_nick then - log("debug", "%s (%s) changing nick to %s", current_nick, data.jid, to); - local p = st.presence({type='unavailable', from=current_nick}); - self:broadcast_presence(p, from, '303', to_nick); - self._occupants[current_nick] = nil; - self._occupants[to] = data; - self._jid_nick[from] = to; - pr.attr.from = to; - self._occupants[to].sessions[from] = pr; - self:broadcast_presence(pr, from); - else - --TODO malformed-jid - end - end - end - --else -- possible rejoin - -- log("debug", "%s had connection replaced", current_nick); - -- self:handle_to_occupant(origin, st.presence({type='unavailable', from=from, to=to}) - -- :tag('status'):text('Replaced by new connection'):up()); -- send unavailable - -- self:handle_to_occupant(origin, stanza); -- resend available - --end - else -- enter room - -- a MUC service MUST NOT allow empty or invisible Room Nicknames - -- (i.e., Room Nicknames that consist only of one or more space characters). - if not select(3, jid_split(to)):find("[^ ]") then -- resourceprep turns all whitespace into 0x20 - module:log("debug", "Rejecting invisible nickname"); - origin.send(st.error_reply(stanza, "cancel", "not-allowed")); - return; - end - local new_nick = to; - local is_merge; - if self._occupants[to] then - if jid_bare(from) ~= jid_bare(self._occupants[to].jid) then - new_nick = nil; - end - is_merge = true; - end - local password = stanza:get_child("x", "http://jabber.org/protocol/muc"); - password = password and password:get_child("password", "http://jabber.org/protocol/muc"); - password = password and password[1] ~= "" and password[1]; - if self:get_password() and self:get_password() ~= password then - log("debug", "%s couldn't join due to invalid password: %s", from, to); - local reply = st.error_reply(stanza, "auth", "not-authorized"):up(); - reply.tags[1].attr.code = "401"; - origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); - elseif not new_nick then - log("debug", "%s couldn't join due to nick conflict: %s", from, to); - local reply = st.error_reply(stanza, "cancel", "conflict"):up(); - reply.tags[1].attr.code = "409"; - origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); - else - log("debug", "%s joining as %s", from, to); - if not next(self._affiliations) then -- new room, no owners - self._affiliations[jid_bare(from)] = "owner"; - if self.locked and not stanza:get_child("x", "http://jabber.org/protocol/muc") then - self.locked = nil; -- Older groupchat protocol doesn't lock - end - elseif self.locked then -- Deny entry - module:log("debug", "Room is locked, denying entry"); - origin.send(st.error_reply(stanza, "cancel", "item-not-found")); - return; - end - local affiliation = self:get_affiliation(from); - local role = self:get_default_role(affiliation) - if role then -- new occupant - if not is_merge then - self._occupants[to] = {affiliation=affiliation, role=role, jid=from, sessions={[from]=get_filtered_presence(stanza)}}; - else - self._occupants[to].sessions[from] = get_filtered_presence(stanza); - end - self._jid_nick[from] = to; - self:send_occupant_list(from); - pr.attr.from = to; - pr:tag("x", {xmlns='http://jabber.org/protocol/muc#user'}) - :tag("item", {affiliation=affiliation or "none", role=role or "none"}):up(); - if not is_merge then - self:broadcast_except_nick(pr, to); - end - pr:tag("status", {code='110'}):up(); - if self._data.whois == 'anyone' then - pr:tag("status", {code='100'}):up(); - end - if self.locked then - pr:tag("status", {code='201'}):up(); - end - pr.attr.to = from; - self:_route_stanza(pr); - self:send_history(from, stanza); - self:send_subject(from); - elseif not affiliation then -- registration required for entering members-only room - local reply = st.error_reply(stanza, "auth", "registration-required"):up(); - reply.tags[1].attr.code = "407"; - origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); - else -- banned - local reply = st.error_reply(stanza, "auth", "forbidden"):up(); - reply.tags[1].attr.code = "403"; - origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); - end - end - end - elseif type ~= 'result' then -- bad type - if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences - origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error? - end + if session_jid == nil then return nil; end + stanza.attr.from, stanza.attr.to, stanza.attr.id = from_occupant_jid, session_jid, orig_id; end - elseif not current_nick then -- not in room - if (type == "error" or type == "result") and stanza.name == "iq" then - local id = stanza.attr.id; - stanza.attr.from, stanza.attr.to, stanza.attr.id = deconstruct_stanza_id(self, stanza); - if stanza.attr.id then - self:_route_stanza(stanza); - end - stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; - elseif type ~= "error" then + log("debug", "%s sent private iq stanza to %s (%s)", from, to, stanza.attr.to); + self:route_stanza(stanza); + stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; + return true; + else -- Type is "get" or "set" + local current_nick = self:get_occupant_jid(from); + if not current_nick then origin.send(st.error_reply(stanza, "cancel", "not-acceptable")); + return true; end - elseif stanza.name == "message" and type == "groupchat" then -- groupchat messages not allowed in PM - origin.send(st.error_reply(stanza, "modify", "bad-request")); - elseif current_nick and stanza.name == "message" and type == "error" and is_kickable_error(stanza) then - log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid); - self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); -- send unavailable - else -- private stanza - local o_data = self._occupants[to]; - if o_data then - log("debug", "%s sent private stanza to %s (%s)", from, to, o_data.jid); - if stanza.name == "iq" then - local id = stanza.attr.id; - if stanza.attr.type == "get" or stanza.attr.type == "set" then - stanza.attr.from, stanza.attr.to, stanza.attr.id = construct_stanza_id(self, stanza); - else - stanza.attr.from, stanza.attr.to, stanza.attr.id = deconstruct_stanza_id(self, stanza); - end - if type == 'get' and stanza.tags[1].attr.xmlns == 'vcard-temp' then - stanza.attr.to = jid_bare(stanza.attr.to); - end - if stanza.attr.id then - self:_route_stanza(stanza); - end - stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; - else -- message - stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up(); - stanza.attr.from = current_nick; - for jid in pairs(o_data.sessions) do - stanza.attr.to = jid; - self:_route_stanza(stanza); - end - stanza.attr.from, stanza.attr.to = from, to; - end - elseif type ~= "error" and type ~= "result" then -- recipient not in room + if not occupant then -- recipient not in room origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room")); + return true; + end + -- XEP-0410 MUC Self-Ping #1220 + if to == current_nick and stanza.attr.type == "get" and stanza:get_child("ping", "urn:xmpp:ping") then + self:route_stanza(st.reply(stanza)); + return true; end + do -- construct_stanza_id + stanza.attr.id = base64.encode(occupant.jid.."\0"..stanza.attr.id.."\0"..md5(from)); + end + stanza.attr.from, stanza.attr.to = current_nick, occupant.jid; + log("debug", "%s sent private iq stanza to %s (%s)", from, to, occupant.jid); + local iq_ns = stanza.tags[1].attr.xmlns; + if iq_ns == 'vcard-temp' or iq_ns == "http://jabber.org/protocol/pubsub" or iq_ns == "urn:ietf:params:xml:ns:vcard-4.0" then + stanza.attr.to = jid_bare(stanza.attr.to); + end + self:route_stanza(stanza); + stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id; + return true; end end +function room_mt:handle_message_to_occupant(origin, stanza) + local from, to = stanza.attr.from, stanza.attr.to; + local current_nick = self:get_occupant_jid(from); + local type = stanza.attr.type; + if not current_nick then -- not in room + if type ~= "error" then + origin.send(st.error_reply(stanza, "cancel", "not-acceptable")); + end + return true; + end + if type == "groupchat" then -- groupchat messages not allowed in PM + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + elseif type == "error" and is_kickable_error(stanza) then + log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid); + return self:handle_kickable(origin, stanza); -- send unavailable + end + + local o_data = self:get_occupant_by_nick(to); + if not o_data then + origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room")); + return true; + end + log("debug", "%s sent private message stanza to %s (%s)", from, to, o_data.jid); + stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up(); + stanza.attr.from = current_nick; + self:route_to_occupant(o_data, stanza) + -- TODO: Remove x tag? + stanza.attr.from = from; + return true; +end + function room_mt:send_form(origin, stanza) origin.send(st.reply(stanza):query("http://jabber.org/protocol/muc#owner") :add_child(self:get_form_layout(stanza.attr.from):form()) @@ -631,547 +800,612 @@ function room_mt:get_form_layout(actor) name = 'FORM_TYPE', type = 'hidden', value = 'http://jabber.org/protocol/muc#roomconfig' - }, - { - name = 'muc#roomconfig_roomname', - type = 'text-single', - label = 'Name', - value = self:get_name() or "", - }, - { - name = 'muc#roomconfig_roomdesc', - type = 'text-single', - label = 'Description', - value = self:get_description() or "", - }, - { - name = 'muc#roomconfig_persistentroom', - type = 'boolean', - label = 'Make Room Persistent?', - value = self:get_persistent() - }, - { - name = 'muc#roomconfig_publicroom', - type = 'boolean', - label = 'Make Room Publicly Searchable?', - value = not self:get_hidden() - }, - { - name = 'muc#roomconfig_changesubject', - type = 'boolean', - label = 'Allow Occupants to Change Subject?', - value = self:get_changesubject() - }, - { - name = 'muc#roomconfig_whois', - type = 'list-single', - label = 'Who May Discover Real JIDs?', - value = { - { value = 'moderators', label = 'Moderators Only', default = self._data.whois == 'moderators' }, - { value = 'anyone', label = 'Anyone', default = self._data.whois == 'anyone' } - } - }, - { - name = 'muc#roomconfig_roomsecret', - type = 'text-private', - label = 'Password', - value = self:get_password() or "", - }, - { - name = 'muc#roomconfig_moderatedroom', - type = 'boolean', - label = 'Make Room Moderated?', - value = self:get_moderated() - }, - { - name = 'muc#roomconfig_membersonly', - type = 'boolean', - label = 'Make Room Members-Only?', - value = self:get_members_only() - }, - { - name = 'muc#roomconfig_historylength', - type = 'text-single', - label = 'Maximum Number of History Messages Returned by Room', - value = tostring(self:get_historylength()) } }); return module:fire_event("muc-config-form", { room = self, actor = actor, form = form }) or form; end function room_mt:process_form(origin, stanza) - local query = stanza.tags[1]; - local form; - for _, tag in ipairs(query.tags) do if tag.name == "x" and tag.attr.xmlns == "jabber:x:data" then form = tag; break; end end - if not form then origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); return; end - if form.attr.type == "cancel" then origin.send(st.reply(stanza)); return; end - if form.attr.type ~= "submit" then origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); return; end - - if form.tags[1] == nil then - -- instant room - if self.save then self:save(true); end + local form = stanza.tags[1]:get_child("x", "jabber:x:data"); + if form.attr.type == "cancel" then origin.send(st.reply(stanza)); - return true; - end + elseif form.attr.type == "submit" then + local fields, errors, present; + if form.tags[1] == nil then -- Instant room + fields, present = {}, {}; + else + fields, errors, present = self:get_form_layout(stanza.attr.from):data(form); + if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then + origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration")); + return true; + end + end - local fields, errors, present = self:get_form_layout(stanza.attr.from):data(form); - if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then - origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration")); - return; - end + local event = { + room = self; + origin = origin; + stanza = stanza; + fields = fields; + status_codes = {}; + actor = stanza.attr.from; + }; + function event.update_option(name, field, allowed) + local new = fields[field]; + if new == nil then return; end + if allowed and not allowed[new] then return; end + if new == self["get_"..name](self) then return; end + event.status_codes["104"] = true; + self["set_"..name](self, new); + return true; + end + module:fire_event("muc-config-submitted", event); + for submitted_field in pairs(present) do + event.field, event.value = submitted_field, fields[submitted_field]; + module:fire_event("muc-config-submitted/"..submitted_field, event); + end + event.field, event.value = nil, nil; - local changed = {}; + self:save(true); + origin.send(st.reply(stanza)); - local function handle_option(name, field, allowed) - if not present[field] then return; end - local new = fields[field]; - if allowed and not allowed[new] then return; end - if new == self["get_"..name](self) then return; end - changed[name] = true; - self["set_"..name](self, new); + if next(event.status_codes) then + local msg = st.message({type='groupchat', from=self.jid}) + :tag('x', {xmlns='http://jabber.org/protocol/muc#user'}) + for code in pairs(event.status_codes) do + msg:tag("status", {code = code;}):up(); + end + msg:up(); + self:broadcast_message(msg); + end + else + origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); end + return true; +end - local event = { room = self, fields = fields, changed = changed, stanza = stanza, origin = origin, update_option = handle_option }; - module:fire_event("muc-config-submitted", event); - - handle_option("name", "muc#roomconfig_roomname"); - handle_option("description", "muc#roomconfig_roomdesc"); - handle_option("persistent", "muc#roomconfig_persistentroom"); - handle_option("moderated", "muc#roomconfig_moderatedroom"); - handle_option("members_only", "muc#roomconfig_membersonly"); - handle_option("public", "muc#roomconfig_publicroom"); - handle_option("changesubject", "muc#roomconfig_changesubject"); - handle_option("historylength", "muc#roomconfig_historylength"); - handle_option("whois", "muc#roomconfig_whois", valid_whois); - handle_option("password", "muc#roomconfig_roomsecret"); - - if self.save then self:save(true); end - if self.locked then - module:fire_event("muc-room-unlocked", { room = self }); - self.locked = nil; +-- Removes everyone from the room +function room_mt:clear(x) + x = x or st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'}); + local occupants_updated = {}; + for nick, occupant in self:each_occupant() do -- luacheck: ignore 213 + occupant.role = nil; + self:save_occupant(occupant); + occupants_updated[occupant] = true; end - origin.send(st.reply(stanza)); - - if next(changed) then - local msg = st.message({type='groupchat', from=self.jid}) - :tag('x', {xmlns='http://jabber.org/protocol/muc#user'}) - :tag('status', {code = '104'}):up(); - if changed.whois then - local code = (self:get_whois() == 'moderators') and "173" or "172"; - msg.tags[1]:tag('status', {code = code}):up(); - end - self:broadcast_message(msg, false) + for occupant in pairs(occupants_updated) do + self:publicise_occupant_status(occupant, x); + module:fire_event("muc-occupant-left", { room = self; nick = occupant.nick; occupant = occupant;}); end end function room_mt:destroy(newjid, reason, password) - local pr = st.presence({type = "unavailable"}) - :tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) - :tag("item", { affiliation='none', role='none' }):up() - :tag("destroy", {jid=newjid}) - if reason then pr:tag("reason"):text(reason):up(); end - if password then pr:tag("password"):text(password):up(); end - for nick, occupant in pairs(self._occupants) do - pr.attr.from = nick; - for jid in pairs(occupant.sessions) do - pr.attr.to = jid; - self:_route_stanza(pr); - self._jid_nick[jid] = nil; + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("destroy", {jid=newjid}); + if reason then x:tag("reason"):text(reason):up(); end + if password then x:tag("password"):text(password):up(); end + x:up(); + self.destroying = reason or true; + self:clear(x); + module:fire_event("muc-room-destroyed", { room = self, reason = reason, newjid = newjid, password = password }); + return true; +end + +function room_mt:handle_disco_info_get_query(origin, stanza) + origin.send(self:get_disco_info(stanza)); + return true; +end + +function room_mt:handle_disco_items_get_query(origin, stanza) + origin.send(self:get_disco_items(stanza)); + return true; +end + +function room_mt:handle_admin_query_set_command(origin, stanza) + local item = stanza.tags[1].tags[1]; + if not item then + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + if item.attr.jid then -- Validate provided JID + item.attr.jid = jid_prep(item.attr.jid); + if not item.attr.jid then + origin.send(st.error_reply(stanza, "modify", "jid-malformed")); + return true; + end + end + if item.attr.nick then -- Validate provided nick + item.attr.nick = resourceprep(item.attr.nick); + if not item.attr.nick then + origin.send(st.error_reply(stanza, "modify", "jid-malformed", "invalid nickname")); + return true; end - self._occupants[nick] = nil; end - self:set_persistent(false); - module:fire_event("muc-room-destroyed", { room = self }); + if not item.attr.jid and item.attr.nick then + -- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation + local occupant = self:get_occupant_by_nick(self.jid.."/"..item.attr.nick); + if occupant then item.attr.jid = occupant.jid; end + elseif item.attr.role and not item.attr.nick and item.attr.jid then + -- Role changes should use nick, but we have a JID so pull the nick from that + local nick = self:get_occupant_jid(item.attr.jid); + if nick then item.attr.nick = jid_resource(nick); end + end + local actor = stanza.attr.from; + local reason = item:get_child_text("reason"); + local success, errtype, err + if item.attr.affiliation and item.attr.jid and not item.attr.role then + local registration_data; + if item.attr.nick then + local room_nick = self.jid.."/"..item.attr.nick; + local existing_occupant = self:get_occupant_by_nick(room_nick); + if existing_occupant and existing_occupant.bare_jid ~= item.attr.jid then + module:log("debug", "Existing occupant for %s: %s does not match %s", room_nick, existing_occupant.bare_jid, item.attr.jid); + self:set_role(true, room_nick, nil, "This nickname is reserved"); + end + module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation); + registration_data = { reserved_nickname = item.attr.nick }; + end + success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data); + elseif item.attr.role and item.attr.nick and not item.attr.affiliation then + success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, reason); + else + success, errtype, err = nil, "cancel", "bad-request"; + end + self:save(true); + if not success then + origin.send(st.error_reply(stanza, errtype, err)); + else + origin.send(st.reply(stanza)); + end return true; end -function room_mt:handle_to_room(origin, stanza) -- presence changes and groupchat messages, along with disco/etc - local type = stanza.attr.type; - local xmlns = stanza.tags[1] and stanza.tags[1].attr.xmlns; - if stanza.name == "iq" then - if xmlns == "http://jabber.org/protocol/disco#info" and type == "get" and not stanza.tags[1].attr.node then - origin.send(self:get_disco_info(stanza)); - elseif xmlns == "http://jabber.org/protocol/disco#items" and type == "get" and not stanza.tags[1].attr.node then - origin.send(self:get_disco_items(stanza)); - elseif xmlns == "http://jabber.org/protocol/muc#admin" then - local actor = stanza.attr.from; - local affiliation = self:get_affiliation(actor); - local current_nick = self._jid_nick[actor]; - local role = current_nick and self._occupants[current_nick].role or self:get_default_role(affiliation); - local item = stanza.tags[1].tags[1]; - if item and item.name == "item" then - if type == "set" then - local callback = function() origin.send(st.reply(stanza)); end - if item.attr.jid then -- Validate provided JID - item.attr.jid = jid_prep(item.attr.jid); - if not item.attr.jid then - origin.send(st.error_reply(stanza, "modify", "jid-malformed")); - return; - end - end - if not item.attr.jid and item.attr.nick then -- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation - local occupant = self._occupants[self.jid.."/"..item.attr.nick]; - if occupant then item.attr.jid = occupant.jid; end - elseif not item.attr.nick and item.attr.jid then - local nick = self._jid_nick[item.attr.jid]; - if nick then item.attr.nick = select(3, jid_split(nick)); end - end - local reason = item.tags[1] and item.tags[1].name == "reason" and #item.tags[1] == 1 and item.tags[1][1]; - if item.attr.affiliation and item.attr.jid and not item.attr.role then - local success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, callback, reason); - if not success then origin.send(st.error_reply(stanza, errtype, err)); end - elseif item.attr.role and item.attr.nick and not item.attr.affiliation then - local success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, callback, reason); - if not success then origin.send(st.error_reply(stanza, errtype, err)); end - else - origin.send(st.error_reply(stanza, "cancel", "bad-request")); - end - elseif type == "get" then - local _aff = item.attr.affiliation; - local _rol = item.attr.role; - if _aff and not _rol then - if affiliation == "owner" or (affiliation == "admin" and _aff ~= "owner" and _aff ~= "admin") - or (affiliation and affiliation ~= "outcast" and self:get_members_only() and self:get_whois() == "anyone") then - local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); - for jid, affiliation in pairs(self._affiliations) do - if affiliation == _aff then - reply:tag("item", {affiliation = _aff, jid = jid}):up(); - end - end - origin.send(reply); - else - origin.send(st.error_reply(stanza, "auth", "forbidden")); - end - elseif _rol and not _aff then - if role == "moderator" then - -- TODO allow admins and owners not in room? Provide read-only access to everyone who can see the participants anyway? - if _rol == "none" then _rol = nil; end - local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); - for occupant_jid, occupant in pairs(self._occupants) do - if occupant.role == _rol then - reply:tag("item", { - nick = select(3, jid_split(occupant_jid)), - role = _rol or "none", - affiliation = occupant.affiliation or "none", - jid = occupant.jid - }):up(); - end - end - origin.send(reply); - else - origin.send(st.error_reply(stanza, "auth", "forbidden")); - end - else - origin.send(st.error_reply(stanza, "cancel", "bad-request")); - end - end - elseif type == "set" or type == "get" then - origin.send(st.error_reply(stanza, "cancel", "bad-request")); +function room_mt:handle_admin_query_get_command(origin, stanza) + local actor = stanza.attr.from; + local affiliation = self:get_affiliation(actor); + local item = stanza.tags[1].tags[1]; + local _aff = item.attr.affiliation; + local _aff_rank = valid_affiliations[_aff or "none"]; + local _rol = item.attr.role; + if _aff and _aff_rank and not _rol then + -- You need to be at least an admin, and be requesting info about your affifiliation or lower + -- e.g. an admin can't ask for a list of owners + local affiliation_rank = valid_affiliations[affiliation or "none"]; + if affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank + or self:get_members_only() and self:get_whois() == "anyone" and affiliation_rank >= valid_affiliations.member then + local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); + for jid in self:each_affiliation(_aff or "none") do + local nick = self:get_registered_nick(jid); + reply:tag("item", {affiliation = _aff, jid = jid, nick = nick }):up(); end - elseif xmlns == "http://jabber.org/protocol/muc#owner" and (type == "get" or type == "set") and stanza.tags[1].name == "query" then - if self:get_affiliation(stanza.attr.from) ~= "owner" then - origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms")); - elseif stanza.attr.type == "get" then - self:send_form(origin, stanza); - elseif stanza.attr.type == "set" then - local child = stanza.tags[1].tags[1]; - if not child then - origin.send(st.error_reply(stanza, "modify", "bad-request")); - elseif child.name == "destroy" then - local newjid = child.attr.jid; - local reason, password; - for _,tag in ipairs(child.tags) do - if tag.name == "reason" then - reason = #tag.tags == 0 and tag[1]; - elseif tag.name == "password" then - password = #tag.tags == 0 and tag[1]; - end - end - self:destroy(newjid, reason, password); - origin.send(st.reply(stanza)); - else - self:process_form(origin, stanza); - end - end - elseif type == "set" or type == "get" then - origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); - end - elseif stanza.name == "message" and type == "groupchat" then - local from = stanza.attr.from; - local current_nick = self._jid_nick[from]; - local occupant = self._occupants[current_nick]; - if not occupant then -- not in room - origin.send(st.error_reply(stanza, "cancel", "not-acceptable")); - elseif occupant.role == "visitor" then - origin.send(st.error_reply(stanza, "auth", "forbidden")); + origin.send(reply:up()); + return true; else - local from = stanza.attr.from; - stanza.attr.from = current_nick; - local subject = stanza:get_child_text("subject"); - if subject then - if occupant.role == "moderator" or - ( self._data.changesubject and occupant.role == "participant" ) then -- and participant - self:set_subject(current_nick, subject); - else - stanza.attr.from = from; - origin.send(st.error_reply(stanza, "auth", "forbidden")); - end - else - self:broadcast_message(stanza, self:get_historylength() > 0 and stanza:get_child("body")); - end - stanza.attr.from = from; - end - elseif stanza.name == "message" and type == "error" and is_kickable_error(stanza) then - local current_nick = self._jid_nick[stanza.attr.from]; - log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid); - self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); -- send unavailable - elseif stanza.name == "presence" then -- hack - some buggy clients send presence updates to the room rather than their nick - local to = stanza.attr.to; - local current_nick = self._jid_nick[stanza.attr.from]; - if current_nick then - stanza.attr.to = current_nick; - self:handle_to_occupant(origin, stanza); - stanza.attr.to = to; - elseif type ~= "error" and type ~= "result" then - origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; end - elseif stanza.name == "message" and not(type == "chat" or type == "error" or type == "groupchat" or type == "headline") and #stanza.tags == 1 - and self._jid_nick[stanza.attr.from] and stanza.tags[1].name == "x" and stanza.tags[1].attr.xmlns == "http://jabber.org/protocol/muc#user" then - local x = stanza.tags[1]; - local payload = (#x.tags == 1 and x.tags[1]); - if payload and payload.name == "invite" and payload.attr.to then - local _from, _to = stanza.attr.from, stanza.attr.to; - local _invitee = jid_prep(payload.attr.to); - if _invitee then - local _reason = payload.tags[1] and payload.tags[1].name == 'reason' and #payload.tags[1].tags == 0 and payload.tags[1][1]; - local invite = st.message({from = _to, to = _invitee, id = stanza.attr.id}) - :tag('x', {xmlns='http://jabber.org/protocol/muc#user'}) - :tag('invite', {from=_from}) - :tag('reason'):text(_reason or ""):up() - :up(); - if self:get_password() then - invite:tag("password"):text(self:get_password()):up(); - end - invite:up() - :tag('x', {xmlns="jabber:x:conference", jid=_to}) -- COMPAT: Some older clients expect this - :text(_reason or "") - :up() - :tag('body') -- Add a plain message for clients which don't support invites - :text(_from..' invited you to the room '.._to..(_reason and (' ('.._reason..')') or "")) - :up(); - if self:get_members_only() and not self:get_affiliation(_invitee) then - log("debug", "%s invited %s into members only room %s, granting membership", _from, _invitee, _to); - self:set_affiliation(_from, _invitee, "member", nil, "Invited by " .. self._jid_nick[_from]) + elseif _rol and valid_roles[_rol or "none"] and not _aff then + local role = self:get_role(self:get_occupant_jid(actor)) or self:get_default_role(affiliation); + if valid_roles[role or "none"] >= valid_roles.moderator then + if _rol == "none" then _rol = nil; end + local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin"); + -- TODO: whois check here? (though fully anonymous rooms are not supported) + for occupant_jid, occupant in self:each_occupant() do + if occupant.role == _rol then + local nick = jid_resource(occupant_jid); + self:build_item_list(occupant, reply, false, nick); end - self:_route_stanza(invite); - else - origin.send(st.error_reply(stanza, "cancel", "jid-malformed")); end + origin.send(reply:up()); + return true; else - origin.send(st.error_reply(stanza, "cancel", "bad-request")); + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; end else - if type == "error" or type == "result" then return; end - origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + return true; + end +end + +function room_mt:handle_owner_query_get_to_room(origin, stanza) + if self:get_affiliation(stanza.attr.from) ~= "owner" then + origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms")); + return true; end + + self:send_form(origin, stanza); + return true; end +function room_mt:handle_owner_query_set_to_room(origin, stanza) + if self:get_affiliation(stanza.attr.from) ~= "owner" then + origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms")); + return true; + end -function room_mt:handle_stanza(origin, stanza) - local to_node, to_host, to_resource = jid_split(stanza.attr.to); - if to_resource then - self:handle_to_occupant(origin, stanza); + local child = stanza.tags[1].tags[1]; + if not child then + origin.send(st.error_reply(stanza, "modify", "bad-request")); + return true; + elseif child.name == "destroy" then + local newjid = child.attr.jid; + local reason = child:get_child_text("reason"); + local password = child:get_child_text("password"); + self:destroy(newjid, reason, password); + origin.send(st.reply(stanza)); + return true; + elseif child.name == "x" and child.attr.xmlns == "jabber:x:data" then + return self:process_form(origin, stanza); else - self:handle_to_room(origin, stanza); + origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); + return true; + end +end + +function room_mt:handle_groupchat_to_room(origin, stanza) + local from = stanza.attr.from; + local occupant = self:get_occupant_by_real_jid(from); + if module:fire_event("muc-occupant-groupchat", { + room = self; origin = origin; stanza = stanza; from = from; occupant = occupant; + }) then return true; end + stanza.attr.from = occupant.nick; + self:broadcast_message(stanza); + stanza.attr.from = from; + return true; +end + +-- Role check +module:hook("muc-occupant-groupchat", function(event) + local role_rank = valid_roles[event.occupant and event.occupant.role or "none"]; + if role_rank <= valid_roles.none then + event.origin.send(st.error_reply(event.stanza, "cancel", "not-acceptable")); + return true; + elseif role_rank <= valid_roles.visitor then + event.origin.send(st.error_reply(event.stanza, "auth", "forbidden")); + return true; + end +end, 50); + +-- hack - some buggy clients send presence updates to the room rather than their nick +function room_mt:handle_presence_to_room(origin, stanza) + local current_nick = self:get_occupant_jid(stanza.attr.from); + local handled + if current_nick then + local to = stanza.attr.to; + stanza.attr.to = current_nick; + handled = self:handle_presence_to_occupant(origin, stanza); + stanza.attr.to = to; + end + return handled; +end + +-- Need visitor role or higher to invite +module:hook("muc-pre-invite", function(event) + local room, stanza = event.room, event.stanza; + local _from = stanza.attr.from; + local inviter = room:get_occupant_by_real_jid(_from); + local role = inviter and inviter.role or room:get_default_role(room:get_affiliation(_from)); + if valid_roles[role or "none"] <= valid_roles.visitor then + event.origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end +end); + +function room_mt:handle_mediated_invite(origin, stanza) + local payload = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite"); + local invitee = jid_prep(payload.attr.to); + if not invitee then + origin.send(st.error_reply(stanza, "cancel", "jid-malformed")); + return true; + elseif module:fire_event("muc-pre-invite", {room = self, origin = origin, stanza = stanza}) then + return true; + end + local invite = muc_util.filter_muc_x(st.clone(stanza)); + invite.attr.from = self.jid; + invite.attr.to = invitee; + invite:tag('x', {xmlns='http://jabber.org/protocol/muc#user'}) + :tag('invite', {from = stanza.attr.from;}) + :tag('reason'):text(payload:get_child_text("reason")):up() + :up() + :up(); + if not module:fire_event("muc-invite", {room = self, stanza = invite, origin = origin, incoming = stanza}) then + self:route_stanza(invite); + end + return true; +end + +-- COMPAT: Some older clients expect this +module:hook("muc-invite", function(event) + local room, stanza = event.room, event.stanza; + local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite"); + local reason = invite:get_child_text("reason"); + stanza:tag('x', {xmlns = "jabber:x:conference"; jid = room.jid;}) + :text(reason or "") + :up(); +end); + +-- Add a plain message for clients which don't support invites +module:hook("muc-invite", function(event) + local room, stanza = event.room, event.stanza; + if not stanza:get_child("body") then + local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite"); + local reason = invite:get_child_text("reason") or ""; + stanza:tag("body") + :text(invite.attr.from.." invited you to the room "..room.jid..(reason ~= "" and (" ("..reason..")") or "")) + :up(); + end +end); + +function room_mt:handle_mediated_decline(origin, stanza) + local payload = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("decline"); + local declinee = jid_prep(payload.attr.to); + if not declinee then + origin.send(st.error_reply(stanza, "cancel", "jid-malformed")); + return true; + elseif module:fire_event("muc-pre-decline", {room = self, origin = origin, stanza = stanza}) then + return true; + end + local decline = muc_util.filter_muc_x(st.clone(stanza)); + decline.attr.from = self.jid; + decline.attr.to = declinee; + decline:tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("decline", {from = stanza.attr.from}) + :tag("reason"):text(payload:get_child_text("reason")):up() + :up() + :up(); + if not module:fire_event("muc-decline", {room = self, stanza = decline, origin = origin, incoming = stanza}) then + declinee = decline.attr.to; -- re-fetch, in case event modified it + local occupant + if jid_bare(declinee) == self.jid then -- declinee jid is already an in-room jid + occupant = self:get_occupant_by_nick(declinee); + end + if occupant then + self:route_to_occupant(occupant, decline); + else + self:route_stanza(decline); + end + end + return true; +end + +-- Add a plain message for clients which don't support declines +module:hook("muc-decline", function(event) + local room, stanza = event.room, event.stanza; + if not stanza:get_child("body") then + local decline = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("decline"); + local reason = decline:get_child_text("reason") or ""; + stanza:body(decline.attr.from.." declined your invite to the room " + ..room.jid..(reason ~= "" and (" ("..reason..")") or "")); + end +end); + +function room_mt:handle_message_to_room(origin, stanza) + local type = stanza.attr.type; + if type == "groupchat" then + return self:handle_groupchat_to_room(origin, stanza) + elseif type == "error" and is_kickable_error(stanza) then + return self:handle_kickable(origin, stanza) + elseif type == nil or type == "normal" then + local x = stanza:get_child("x", "http://jabber.org/protocol/muc#user"); + if x then + local payload = x.tags[1]; + if payload == nil then --luacheck: ignore 542 + -- fallthrough + elseif payload.name == "invite" and payload.attr.to then + return self:handle_mediated_invite(origin, stanza) + elseif payload.name == "decline" and payload.attr.to then + return self:handle_mediated_decline(origin, stanza) + end + origin.send(st.error_reply(stanza, "cancel", "bad-request")); + return true; + end + + local form = stanza:get_child("x", "jabber:x:data"); + local form_type = dataform.get_type(form); + if form_type == "http://jabber.org/protocol/muc#request" then + self:handle_role_request(origin, stanza, form); + return true; + end end end -function room_mt:route_stanza(stanza) end -- Replace with a routing function, e.g., function(room, stanza) core_route_stanza(origin, stanza); end +function room_mt:route_stanza(stanza) -- luacheck: ignore 212 + module:send(stanza); +end function room_mt:get_affiliation(jid) local node, host, resource = jid_split(jid); + -- Affiliations are granted, revoked, and maintained based on the user's bare JID. local bare = node and node.."@"..host or host; - local result = self._affiliations[bare]; -- Affiliations are granted, revoked, and maintained based on the user's bare JID. + local result = self._affiliations[bare]; if not result and self._affiliations[host] == "outcast" then result = "outcast"; end -- host banned return result; end -function room_mt:set_affiliation(actor, jid, affiliation, callback, reason) - jid = jid_bare(jid); - if affiliation == "none" then affiliation = nil; end - if affiliation and affiliation ~= "outcast" and affiliation ~= "owner" and affiliation ~= "admin" and affiliation ~= "member" then + +-- Iterates over jid, affiliation pairs +function room_mt:each_affiliation(with_affiliation) + if not with_affiliation then + return pairs(self._affiliations); + else + return function(_affiliations, jid) + local affiliation; + repeat -- Iterate until we get a match + jid, affiliation = next(_affiliations, jid); + until jid == nil or affiliation == with_affiliation + return jid, affiliation; + end, self._affiliations, nil + end +end + +function room_mt:set_affiliation(actor, jid, affiliation, reason, data) + module:log("debug", "data is %s", tostring(data)); + if not actor then return nil, "modify", "not-acceptable"; end; + + local node, host, resource = jid_split(jid); + if not host then return nil, "modify", "not-acceptable"; end + jid = jid_join(node, host); -- Bare + local is_host_only = node == nil; + + if valid_affiliations[affiliation or "none"] == nil then return nil, "modify", "not-acceptable"; end - if actor ~= true then + affiliation = affiliation ~= "none" and affiliation or nil; -- coerces `affiliation == false` to `nil` + + local target_affiliation = self._affiliations[jid]; -- Raw; don't want to check against host + local is_downgrade = valid_affiliations[target_affiliation or "none"] > valid_affiliations[affiliation or "none"]; + + if actor == true then + actor = nil -- So we can pass it safely to 'publicise_occupant_status' below + else local actor_affiliation = self:get_affiliation(actor); - local target_affiliation = self:get_affiliation(jid); - if target_affiliation == affiliation then -- no change, shortcut - if callback then callback(); end - return true; - end - if actor_affiliation ~= "owner" then - if affiliation == "owner" or affiliation == "admin" or actor_affiliation ~= "admin" or target_affiliation == "owner" or target_affiliation == "admin" then - return nil, "cancel", "not-allowed"; - end - elseif target_affiliation == "owner" and jid_bare(actor) == jid then -- self change - local is_last = true; - for j, aff in pairs(self._affiliations) do if j ~= jid and aff == "owner" then is_last = false; break; end end - if is_last then - return nil, "cancel", "conflict"; + if actor_affiliation == "owner" then + if jid_bare(actor) == jid then -- self change + -- need at least one owner + local is_last = true; + for j in self:each_affiliation("owner") do + if j ~= jid then is_last = false; break; end + end + if is_last then + return nil, "cancel", "conflict"; + end end + -- owners can do anything else + elseif affiliation == "owner" or affiliation == "admin" + or actor_affiliation ~= "admin" + or target_affiliation == "owner" or target_affiliation == "admin" then + -- Can't demote owners or other admins + return nil, "cancel", "not-allowed"; end end + + -- Set in 'database' self._affiliations[jid] = affiliation; + if not affiliation or data == false or (data ~= nil and next(data) == nil) then + module:log("debug", "Clearing affiliation data for %s", jid); + self._affiliation_data[jid] = nil; + elseif data then + module:log("debug", "Updating affiliation data for %s", jid); + self._affiliation_data[jid] = data; + end + + -- Update roles local role = self:get_default_role(affiliation); - local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}) - :tag("item", {affiliation=affiliation or "none", role=role or "none"}) - :tag("reason"):text(reason or ""):up() - :up(); - local presence_type = nil; + local role_rank = valid_roles[role or "none"]; + local occupants_updated = {}; -- Filled with old roles + for nick, occupant in self:each_occupant() do -- luacheck: ignore 213 + if occupant.bare_jid == jid or ( + -- Outcast can be by host. + is_host_only and affiliation == "outcast" and select(2, jid_split(occupant.bare_jid)) == host + ) then + -- need to publcize in all cases; as affiliation in <item/> has changed. + occupants_updated[occupant] = occupant.role; + if occupant.role ~= role and ( + is_downgrade or + valid_roles[occupant.role or "none"] < role_rank -- upgrade + ) then + occupant.role = role; + self:save_occupant(occupant); + end + end + end + + -- Tell the room of the new occupant affiliations+roles + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}); if not role then -- getting kicked - presence_type = "unavailable"; if affiliation == "outcast" then x:tag("status", {code="301"}):up(); -- banned else x:tag("status", {code="321"}):up(); -- affiliation change end end - -- Your own presence should have status 110 - local self_x = st.clone(x); - self_x:tag("status", {code="110"}); - local modified_nicks = {}; - for nick, occupant in pairs(self._occupants) do - if jid_bare(occupant.jid) == jid then - if not role then -- getting kicked - self._occupants[nick] = nil; - else - occupant.affiliation, occupant.role = affiliation, role; - end - for jid,pres in pairs(occupant.sessions) do -- remove for all sessions of the nick - if not role then self._jid_nick[jid] = nil; end - local p = st.clone(pres); - p.attr.from = nick; - p.attr.type = presence_type; - p.attr.to = jid; - if occupant.jid == jid then - -- Broadcast this presence to everyone else later, with the public <x> variant - local bp = st.clone(p); - bp:add_child(x); - modified_nicks[nick] = bp; - end - p:add_child(self_x); - self:_route_stanza(p); + local is_semi_anonymous = self:get_whois() == "moderators"; + for occupant, old_role in pairs(occupants_updated) do + self:publicise_occupant_status(occupant, x, nil, actor, reason); + if occupant.role == nil then + module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); + elseif is_semi_anonymous and + (old_role == "moderator" and occupant.role ~= "moderator") or + (old_role ~= "moderator" and occupant.role == "moderator") then -- Has gained or lost moderator status + -- Send everyone else's presences (as jid visibility has changed) + for real_jid in occupant:each_session() do + self:send_occupant_list(real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433 + return occupant.bare_jid ~= jid; + end); end end end - if self.save then self:save(); end - if callback then callback(); end - for nick,p in pairs(modified_nicks) do - p.attr.from = nick; - self:broadcast_except_nick(p, nick); - end + + self:save(true); + + module:fire_event("muc-set-affiliation", { + room = self; + actor = actor; + jid = jid; + affiliation = affiliation or "none"; + reason = reason; + previous_affiliation = target_affiliation; + data = data and data or nil; -- coerce false to nil + in_room = next(occupants_updated) ~= nil; + }); + return true; end function room_mt:get_role(nick) - local session = self._occupants[nick]; - return session and session.role or nil; + local occupant = self:get_occupant_by_nick(nick); + return occupant and occupant.role or nil; end -function room_mt:can_set_role(actor_jid, occupant_jid, role) - local occupant = self._occupants[occupant_jid]; - if not occupant or not actor_jid then return nil, "modify", "not-acceptable"; end - if actor_jid == true then return true; end +function room_mt:set_role(actor, occupant_jid, role, reason) + if not actor then return nil, "modify", "not-acceptable"; end - local actor = self._occupants[self._jid_nick[actor_jid]]; - if actor and actor.role == "moderator" then - if occupant.affiliation ~= "owner" and occupant.affiliation ~= "admin" then - if actor.affiliation == "owner" or actor.affiliation == "admin" then - return true; - elseif occupant.role ~= "moderator" and role ~= "moderator" then - return true; - end - end + local occupant = self:get_occupant_by_nick(occupant_jid); + if not occupant then return nil, "modify", "item-not-found"; end + + if valid_roles[role or "none"] == nil then + return nil, "modify", "not-acceptable"; end - return nil, "cancel", "not-allowed"; -end -function room_mt:set_role(actor, occupant_jid, role, callback, reason) - if role == "none" then role = nil; end - if role and role ~= "moderator" and role ~= "participant" and role ~= "visitor" then return nil, "modify", "not-acceptable"; end - local allowed, err_type, err_condition = self:can_set_role(actor, occupant_jid, role); - if not allowed then return allowed, err_type, err_condition; end - local occupant = self._occupants[occupant_jid]; - local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}) - :tag("item", {affiliation=occupant.affiliation or "none", nick=select(3, jid_split(occupant_jid)), role=role or "none"}) - :tag("reason"):text(reason or ""):up() - :up(); - local presence_type = nil; - if not role then -- kick - presence_type = "unavailable"; - self._occupants[occupant_jid] = nil; - for jid in pairs(occupant.sessions) do -- remove for all sessions of the nick - self._jid_nick[jid] = nil; - end - x:tag("status", {code = "307"}):up(); + role = role ~= "none" and role or nil; -- coerces `role == false` to `nil` + + if actor == true then + actor = nil -- So we can pass it safely to 'publicise_occupant_status' below else - occupant.role = role; - end - local self_x = st.clone(x); - self_x:tag("status", {code = "110"}):up(); - local bp; - for jid,pres in pairs(occupant.sessions) do -- send to all sessions of the nick - local p = st.clone(pres); - p.attr.from = occupant_jid; - p.attr.type = presence_type; - p.attr.to = jid; - if occupant.jid == jid then - bp = st.clone(p); - bp:add_child(x); + -- Can't do anything to other owners or admins + local occupant_affiliation = self:get_affiliation(occupant.bare_jid); + if occupant_affiliation == "owner" or occupant_affiliation == "admin" then + return nil, "cancel", "not-allowed"; end - p:add_child(self_x); - self:_route_stanza(p); - end - if callback then callback(); end - if bp then - self:broadcast_except_nick(bp, occupant_jid); - end - return true; -end -function room_mt:_route_stanza(stanza) - local muc_child; - local to_occupant = self._occupants[self._jid_nick[stanza.attr.to]]; - local from_occupant = self._occupants[stanza.attr.from]; - if stanza.name == "presence" then - if to_occupant and from_occupant then - if self._data.whois == 'anyone' then - muc_child = stanza:get_child("x", "http://jabber.org/protocol/muc#user"); - else - if to_occupant.role == "moderator" or jid_bare(to_occupant.jid) == jid_bare(from_occupant.jid) then - muc_child = stanza:get_child("x", "http://jabber.org/protocol/muc#user"); - end + -- If you are trying to give or take moderator role you need to be an owner or admin + if occupant.role == "moderator" or role == "moderator" then + local actor_affiliation = self:get_affiliation(actor); + if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then + return nil, "cancel", "not-allowed"; end end - end - if muc_child then - for _, item in pairs(muc_child.tags) do - if item.name == "item" then - if from_occupant == to_occupant then - item.attr.jid = stanza.attr.to; - else - item.attr.jid = from_occupant.jid; - end - end + + -- Need to be in the room and a moderator + local actor_occupant = self:get_occupant_by_real_jid(actor); + if not actor_occupant or actor_occupant.role ~= "moderator" then + return nil, "cancel", "not-allowed"; end end - self:route_stanza(stanza); - if muc_child then - for _, item in pairs(muc_child.tags) do - if item.name == "item" then - item.attr.jid = nil; - end - end + + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"}); + if not role then + x:tag("status", {code = "307"}):up(); + end + occupant.role = role; + self:save_occupant(occupant); + self:publicise_occupant_status(occupant, x, nil, actor, reason); + if role == nil then + module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); end + return true; end +local whois = module:require "muc/whois"; +room_mt.get_whois = whois.get; +room_mt.set_whois = whois.set; + local _M = {}; -- module "muc" function _M.new_room(jid, config) @@ -1179,17 +1413,109 @@ function _M.new_room(jid, config) jid = jid; _jid_nick = {}; _occupants = {}; - _data = { - whois = 'moderators'; - history_length = math.min((config and config.history_length) - or default_history_length, max_history_length); - }; + _data = config or {}; _affiliations = {}; + _affiliation_data = {}; }, room_mt); end -function _M.set_max_history_length(_max_history_length) - max_history_length = _max_history_length or math.huge; +local new_format = module:get_option_boolean("new_muc_storage_format", false); + +function room_mt:freeze(live) + local frozen, state; + if new_format then + frozen = { + _jid = self.jid; + _data = self._data; + }; + for user, affiliation in pairs(self._affiliations) do + frozen[user] = affiliation; + end + else + frozen = { + jid = self.jid; + _data = self._data; + _affiliations = self._affiliations; + _affiliation_data = self._affiliation_data; + }; + end + if live then + state = {}; + for nick, occupant in self:each_occupant() do + state[nick] = { + bare_jid = occupant.bare_jid; + role = occupant.role; + jid = occupant.jid; + } + for jid, presence in occupant:each_session() do + state[jid] = st.preserialize(presence); + end + end + local history = self._history; + if history and history[1] ~= nil then + state._last_message = st.preserialize(history[#history].stanza); + state._last_message_at = history[#history].timestamp; + end + end + return frozen, state; +end + +function _M.restore_room(frozen, state) + local room_jid = frozen._jid or frozen.jid; + local room = _M.new_room(room_jid, frozen._data); + + if state and state._last_message and state._last_message_at then + room._history = { + { stanza = st.deserialize(state._last_message), + timestamp = state._last_message_at, }, + }; + end + + local occupants = {}; + local room_name, room_host = jid_split(room_jid); + + room._affiliation_data = frozen._affiliation_data or {}; + + if frozen.jid and frozen._affiliations then + -- Old storage format + room._affiliations = frozen._affiliations; + else + -- New storage format + for jid, data in pairs(frozen) do + local node, host, resource = jid_split(jid); + if host:sub(1,1) ~= "_" and not resource and type(data) == "string" then + -- bare jid: affiliation + room._affiliations[jid] = data; + end + end + end + for jid, data in pairs(state or frozen) do + local node, host, resource = jid_split(jid); + if node or host:sub(1,1) ~= "_" then + if host == room_host and node == room_name and resource and type(data) == "table" then + -- full room jid: bare real jid and role + local nick = jid; + local occupant = occupants[nick] or occupant_lib.new(data.bare_jid, nick); + occupant.bare_jid = data.bare_jid; + occupant.role = data.role; + occupant.jid = data.jid; -- Primary session JID + occupants[nick] = occupant; + elseif type(data) == "table" and data.name == "presence" then + -- full user jid: presence + local nick = data.attr.from; + local occupant = occupants[nick] or occupant_lib.new(nil, nick); + local presence = st.deserialize(data); + occupant:set_session(jid, presence); + occupants[nick] = occupant; + end + end + end + + for _, occupant in pairs(occupants) do + room:save_occupant(occupant); + end + + return room; end _M.room_mt = room_mt; diff --git a/plugins/muc/name.lib.lua b/plugins/muc/name.lib.lua new file mode 100644 index 00000000..37fe1259 --- /dev/null +++ b/plugins/muc/name.lib.lua @@ -0,0 +1,48 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local jid_split = require "util.jid".split; + +local function get_name(room) + return room._data.name or jid_split(room.jid); +end + +local function set_name(room, name) + if name == "" then name = nil; end + if room._data.name == name then return false; end + room._data.name = name; + return true; +end + +local function insert_name_into_form(event) + table.insert(event.form, { + name = "muc#roomconfig_roomname"; + type = "text-single"; + label = "Title"; + value = event.room._data.name; + }); +end + +module:hook("muc-disco#info", function(event) + event.reply:tag("identity", {category="conference", type="text", name=get_name(event.room)}):up(); + insert_name_into_form(event); +end); + +module:hook("muc-config-form", insert_name_into_form, 100-1); + +module:hook("muc-config-submitted/muc#roomconfig_roomname", function(event) + if set_name(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +return { + get = get_name; + set = set_name; +}; diff --git a/plugins/muc/occupant.lib.lua b/plugins/muc/occupant.lib.lua new file mode 100644 index 00000000..8fe4bbdf --- /dev/null +++ b/plugins/muc/occupant.lib.lua @@ -0,0 +1,85 @@ +local pairs = pairs; +local setmetatable = setmetatable; +local st = require "util.stanza"; +local util = module:require "muc/util"; + +local function get_filtered_presence(stanza) + return util.filter_muc_x(st.clone(stanza)); +end + +local occupant_mt = {}; +occupant_mt.__index = occupant_mt; + +local function new_occupant(bare_real_jid, nick) + return setmetatable({ + bare_jid = bare_real_jid; + nick = nick; -- in-room jid + sessions = {}; -- hash from real_jid to presence stanzas. stanzas should not be modified + role = nil; + jid = nil; -- Primary session + }, occupant_mt); +end + +-- Deep copy an occupant +local function copy_occupant(occupant) + local sessions = {}; + for full_jid, presence_stanza in pairs(occupant.sessions) do + -- Don't keep unavailable presences, as they'll accumulate; unless they're the primary session + if presence_stanza.attr.type ~= "unavailable" or full_jid == occupant.jid then + sessions[full_jid] = presence_stanza; + end + end + return setmetatable({ + bare_jid = occupant.bare_jid; + nick = occupant.nick; + sessions = sessions; + role = occupant.role; + jid = occupant.jid; + }, occupant_mt); +end + +-- finds another session to be the primary (there might not be one) +function occupant_mt:choose_new_primary() + for jid, pr in self:each_session() do + if pr.attr.type == nil then + return jid; + end + end + return nil; +end + +function occupant_mt:set_session(real_jid, presence_stanza, replace_primary) + local pr = get_filtered_presence(presence_stanza); + pr.attr.from = self.nick; + pr.attr.to = real_jid; + + self.sessions[real_jid] = pr; + if replace_primary then + self.jid = real_jid; + elseif self.jid == nil or (pr.attr.type == "unavailable" and self.jid == real_jid) then + -- Only leave an unavailable presence as primary when there are no other options + self.jid = self:choose_new_primary() or real_jid; + end +end + +function occupant_mt:remove_session(real_jid) + -- Delete original session + self.sessions[real_jid] = nil; + if self.jid == real_jid then + self.jid = self:choose_new_primary(); + end +end + +function occupant_mt:each_session() + return pairs(self.sessions) +end + +function occupant_mt:get_presence(real_jid) + return self.sessions[real_jid or self.jid] +end + +return { + new = new_occupant; + copy = copy_occupant; + mt = occupant_mt; +} diff --git a/plugins/muc/password.lib.lua b/plugins/muc/password.lib.lua new file mode 100644 index 00000000..1f4b2add --- /dev/null +++ b/plugins/muc/password.lib.lua @@ -0,0 +1,80 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; + +local function get_password(room) + return room._data.password; +end + +local function set_password(room, password) + if password == "" then password = nil; end + if room._data.password == password then return false; end + room._data.password = password; + return true; +end + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = get_password(event.room) and "muc_passwordprotected" or "muc_unsecured"}):up(); +end); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_roomsecret"; + type = "text-private"; + label = "Password"; + value = get_password(event.room) or ""; + }); +end, 90-2); + +module:hook("muc-config-submitted/muc#roomconfig_roomsecret", function(event) + if set_password(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +-- Don't allow anyone to join room unless they provide the password +module:hook("muc-occupant-pre-join", function(event) + local room, stanza = event.room, event.stanza; + if not get_password(room) then return end + local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc"); + if not muc_x then return end + local password = muc_x:get_child_text("password", "http://jabber.org/protocol/muc"); + if not password or password == "" then password = nil; end + if get_password(room) ~= password then + local from, to = stanza.attr.from, stanza.attr.to; + module:log("debug", "%s couldn't join due to invalid password: %s", from, to); + local reply = st.error_reply(stanza, "auth", "not-authorized"):up(); + reply.tags[1].attr.code = "401"; + event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end +end, -20); + +-- Add password to outgoing invite +module:hook("muc-invite", function(event) + local password = get_password(event.room); + if password then + local x = event.stanza:get_child("x", "http://jabber.org/protocol/muc#user"); + x:tag("password"):text(password):up(); + end +end); + +module:hook("muc-room-pre-create", function (event) + local stanza, room = event.stanza, event.room; + local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc"); + if not muc_x then return end + local password = muc_x:get_child_text("password", "http://jabber.org/protocol/muc"); + set_password(room, password); +end); + +return { + get = get_password; + set = set_password; +}; diff --git a/plugins/muc/persistent.lib.lua b/plugins/muc/persistent.lib.lua new file mode 100644 index 00000000..c3b16ea4 --- /dev/null +++ b/plugins/muc/persistent.lib.lua @@ -0,0 +1,58 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local restrict_persistent = not module:get_option_boolean("muc_room_allow_persistent", true); +local um_is_admin = require "core.usermanager".is_admin; + +local function get_persistent(room) + return room._data.persistent; +end + +local function set_persistent(room, persistent) + persistent = persistent and true or nil; + if get_persistent(room) == persistent then return false; end + room._data.persistent = persistent; + return true; +end + +module:hook("muc-config-form", function(event) + if restrict_persistent and not um_is_admin(event.actor, module.host) then + -- Don't show option if hidden rooms are restricted and user is not admin of this host + return; + end + table.insert(event.form, { + name = "muc#roomconfig_persistentroom"; + type = "boolean"; + label = "Persistent (room should remain even when it is empty)"; + desc = "Rooms are automatically deleted when they are empty, unless this option is enabled"; + value = get_persistent(event.room); + }); +end, 100-5); + +module:hook("muc-config-submitted/muc#roomconfig_persistentroom", function(event) + if restrict_persistent and not um_is_admin(event.actor, module.host) then + return; -- Not allowed + end + if set_persistent(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = get_persistent(event.room) and "muc_persistent" or "muc_temporary"}):up(); +end); + +module:hook("muc-room-destroyed", function(event) + set_persistent(event.room, false); +end, -100); + +return { + get = get_persistent; + set = set_persistent; +}; diff --git a/plugins/muc/register.lib.lua b/plugins/muc/register.lib.lua new file mode 100644 index 00000000..21cb3f2f --- /dev/null +++ b/plugins/muc/register.lib.lua @@ -0,0 +1,195 @@ +local jid_bare = require "util.jid".bare; +local jid_resource = require "util.jid".resource; +local resourceprep = require "util.encodings".stringprep.resourceprep; +local st = require "util.stanza"; +local dataforms = require "util.dataforms"; + +local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", false); + +local enforce_nick = module:get_option_boolean("enforce_registered_nickname", false); + +-- reserved_nicks[nick] = jid +local function get_reserved_nicks(room) + if room._reserved_nicks then + return room._reserved_nicks; + end + module:log("debug", "Refreshing reserved nicks..."); + local reserved_nicks = {}; + for jid in room:each_affiliation() do + local data = room._affiliation_data[jid]; + local nick = data and data.reserved_nickname; + module:log("debug", "Refreshed for %s: %s", jid, nick); + if nick then + reserved_nicks[nick] = jid; + end + end + room._reserved_nicks = reserved_nicks; + return reserved_nicks; +end + +-- Returns the registered nick, if any, for a JID +-- Note: this is just the *nick* part, i.e. the resource of the in-room JID +local function get_registered_nick(room, jid) + local registered_data = room._affiliation_data[jid]; + if not registered_data then + return; + end + return registered_data.reserved_nickname; +end + +-- Returns the JID, if any, that registered a nick (not in-room JID) +local function get_registered_jid(room, nick) + local reserved_nicks = get_reserved_nicks(room); + return reserved_nicks[nick]; +end + +module:hook("muc-set-affiliation", function (event) + -- Clear reserved nick cache + event.room._reserved_nicks = nil; +end); + +module:add_feature("jabber:iq:register"); + +module:hook("muc-disco#info", function (event) + event.reply:tag("feature", { var = "jabber:iq:register" }):up(); +end); + +local registration_form = dataforms.new { + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#register" }, + { name = "muc#register_roomnick", type = "text-single", label = "Nickname"}, +}; + +local function enforce_nick_policy(event) + local origin, stanza = event.origin, event.stanza; + local room = assert(event.room); -- FIXME + if not room then return; end + + -- Check if the chosen nickname is reserved + local requested_nick = jid_resource(stanza.attr.to); + local reserved_by = get_registered_jid(room, requested_nick); + if reserved_by and reserved_by ~= jid_bare(stanza.attr.from) then + module:log("debug", "%s attempted to use nick %s reserved by %s", stanza.attr.from, requested_nick, reserved_by); + local reply = st.error_reply(stanza, "cancel", "conflict"):up(); + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end + + -- Check if the occupant has a reservation they must use + if enforce_nick then + local nick = get_registered_nick(room, jid_bare(stanza.attr.from)); + if nick then + if event.occupant then + event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick; + elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then + module:log("debug", "Attempt by %s to join as %s, but their reserved nick is %s", stanza.attr.from, requested_nick, nick); + local reply = st.error_reply(stanza, "cancel", "not-acceptable"):up(); + origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"})); + return true; + end + end + end +end + +module:hook("muc-occupant-pre-join", enforce_nick_policy); +module:hook("muc-occupant-pre-change", enforce_nick_policy); + +-- Discovering Reserved Room Nickname +-- http://xmpp.org/extensions/xep-0045.html#reservednick +module:hook("muc-disco#info/x-roomuser-item", function (event) + local nick = get_registered_nick(event.room, jid_bare(event.stanza.attr.from)); + if nick then + event.reply:tag("identity", { category = "conference", type = "text", name = nick }) + end +end); + +local function handle_register_iq(room, origin, stanza) + local user_jid = jid_bare(stanza.attr.from) + local affiliation = room:get_affiliation(user_jid); + if affiliation == "outcast" then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + elseif not (affiliation or allow_unaffiliated) then + origin.send(st.error_reply(stanza, "auth", "registration-required")); + return true; + end + local reply = st.reply(stanza); + local registered_nick = get_registered_nick(room, user_jid); + if stanza.attr.type == "get" then + reply:query("jabber:iq:register"); + if registered_nick then + reply:tag("registered"):up(); + reply:tag("username"):text(registered_nick); + origin.send(reply); + return true; + end + reply:add_child(registration_form:form()); + else -- type == set -- handle registration form + local query = stanza.tags[1]; + if query:get_child("remove") then + -- Remove "member" affiliation, but preserve if any other + local new_affiliation = affiliation ~= "member" and affiliation; + local ok, err_type, err_condition = room:set_affiliation(true, user_jid, new_affiliation, nil, false); + if not ok then + origin.send(st.error_reply(stanza, err_type, err_condition)); + return true; + end + origin.send(reply); + return true; + end + local form_tag = query:get_child("x", "jabber:x:data"); + local reg_data = form_tag and registration_form:data(form_tag); + if not reg_data then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form")); + return true; + end + -- Is the nickname valid? + local desired_nick = resourceprep(reg_data["muc#register_roomnick"]); + if not desired_nick then + origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid Nickname")); + return true; + end + -- Is the nickname currently in use by another user? + local current_occupant = room:get_occupant_by_nick(room.jid.."/"..desired_nick); + if current_occupant and current_occupant.bare_jid ~= user_jid then + origin.send(st.error_reply(stanza, "cancel", "conflict")); + return true; + end + -- Is the nickname currently reserved by another user? + local reserved_by = get_registered_jid(room, desired_nick); + if reserved_by and reserved_by ~= user_jid then + origin.send(st.error_reply(stanza, "cancel", "conflict")); + return true; + end + + if enforce_nick then + -- Kick any sessions that are not using this nick before we register it + local required_room_nick = room.jid.."/"..desired_nick; + for room_nick, occupant in room:each_occupant() do + if occupant.bare_jid == user_jid and room_nick ~= required_room_nick then + room:set_role(true, room_nick, nil); -- Kick (TODO: would be nice to use 333 code) + end + end + end + + -- Checks passed, save the registration + if registered_nick ~= desired_nick then + local registration_data = { reserved_nickname = desired_nick }; + local ok, err_type, err_condition = room:set_affiliation(true, user_jid, affiliation or "member", nil, registration_data); + if not ok then + origin.send(st.error_reply(stanza, err_type, err_condition)); + return true; + end + module:log("debug", "Saved nick registration for %s: %s", user_jid, desired_nick); + origin.send(reply); + return true; + end + end + origin.send(reply); + return true; +end + +return { + get_registered_nick = get_registered_nick; + get_registered_jid = get_registered_jid; + handle_register_iq = handle_register_iq; +} diff --git a/plugins/muc/request.lib.lua b/plugins/muc/request.lib.lua new file mode 100644 index 00000000..1d9c39f9 --- /dev/null +++ b/plugins/muc/request.lib.lua @@ -0,0 +1,132 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local jid_resource = require "util.jid".resource; + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#request"}):up(); +end); + +local voice_request_form = require "util.dataforms".new({ + title = "Voice Request"; + { + name = "FORM_TYPE"; + type = "hidden"; + value = "http://jabber.org/protocol/muc#request"; + }, + { + name = "muc#jid"; + type = "jid-single"; + label = "User ID"; + desc = "The user's JID (address)"; + }, + { + name = "muc#roomnick"; + type = "text-single"; + label = "Room nickname"; + desc = "The user's nickname within the room"; + }, + { + name = "muc#role"; + type = "list-single"; + label = "Requested role"; + value = "participant"; + options = { + "none", + "visitor", + "participant", + "moderator", + }; + }, + { + name = "muc#request_allow"; + type = "boolean"; + label = "Grant voice to this person?"; + desc = "Specify whether this person is able to speak in a moderated room"; + value = false; + } +}); + +local function handle_request(room, origin, stanza, form) + local occupant = room:get_occupant_by_real_jid(stanza.attr.from); + local fields = voice_request_form:data(form); + local event = { + room = room; + origin = origin; + stanza = stanza; + fields = fields; + occupant = occupant; + }; + if occupant.role == "moderator" then + module:log("debug", "%s responded to a voice request in %s", jid_resource(occupant.nick), room.jid); + module:fire_event("muc-voice-response", event); + else + module:log("debug", "%s requested voice in %s", jid_resource(occupant.nick), room.jid); + module:fire_event("muc-voice-request", event); + end +end + +module:hook("muc-voice-request", function(event) + if event.occupant.role == "visitor" then + local nick = jid_resource(event.occupant.nick); + local formdata = { + ["muc#jid"] = event.stanza.attr.from; + ["muc#roomnick"] = nick; + }; + + local message = st.message({ type = "normal"; from = event.room.jid }) + :add_direct_child(voice_request_form:form(formdata)); + + event.room:broadcast(message, function (_, occupant) + return occupant.role == "moderator"; + end); + end +end); + +module:hook("muc-voice-response", function(event) + local actor = event.stanza.attr.from; + local affected_occupant = event.room:get_occupant_by_real_jid(event.fields["muc#jid"]); + local occupant = event.occupant; + + if occupant.role ~= "moderator" then + module:log("debug", "%s tried to grant voice but wasn't a moderator", jid_resource(occupant.nick)); + return; + end + + if not event.fields["muc#request_allow"] then + module:log("debug", "%s did not grant voice", jid_resource(occupant.nick)); + return; + end + + if not affected_occupant then + module:log("debug", "%s tried to grant voice to unknown occupant %s", + jid_resource(occupant.nick), event.fields["muc#jid"]); + return; + end + + if affected_occupant.role ~= "visitor" then + module:log("debug", "%s tried to grant voice to %s but they already have it", + jid_resource(occupant.nick), jid_resource(occupant.jid)); + return; + end + + module:log("debug", "%s granted voice to %s", jid_resource(event.occupant.nick), jid_resource(occupant.jid)); + local ok, errtype, err = event.room:set_role(actor, affected_occupant.nick, "participant", "Voice granted"); + + if not ok then + module:log("debug", "Error granting voice: %s", err or errtype); + event.origin.send(st.error_reply(event.stanza, errtype, err)); + end +end); + + +return { + handle_request = handle_request; +}; diff --git a/plugins/muc/subject.lib.lua b/plugins/muc/subject.lib.lua new file mode 100644 index 00000000..938abf61 --- /dev/null +++ b/plugins/muc/subject.lib.lua @@ -0,0 +1,119 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local st = require "util.stanza"; +local dt = require "util.datetime"; + +local muc_util = module:require "muc/util"; +local valid_roles = muc_util.valid_roles; + +local function create_subject_message(from, subject) + return st.message({from = from; type = "groupchat"}) + :tag("subject"):text(subject or ""):up(); +end + +local function get_changesubject(room) + return room._data.changesubject; +end + +local function set_changesubject(room, changesubject) + changesubject = changesubject and true or nil; + if get_changesubject(room) == changesubject then return false; end + room._data.changesubject = changesubject; + return true; +end + +module:hook("muc-disco#info", function (event) + table.insert(event.form, { + name = "muc#roominfo_changesubject"; + type = "boolean"; + }); + event.formdata["muc#roominfo_changesubject"] = get_changesubject(event.room); +end); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_changesubject"; + type = "boolean"; + label = "Allow anyone to set the room's subject"; + desc = "Choose whether anyone, or only moderators, may set the room's subject"; + value = get_changesubject(event.room); + }); +end, 80-1); + +module:hook("muc-config-submitted/muc#roomconfig_changesubject", function(event) + if set_changesubject(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +local function get_subject(room) + -- a <message/> stanza from the room JID (or from the occupant JID of the entity that set the subject) + return room._data.subject_from or room.jid, room._data.subject; +end + +local function send_subject(room, to, time) + local msg = create_subject_message(get_subject(room)); + msg.attr.to = to; + if time then + msg:tag("delay", { + xmlns = "urn:xmpp:delay", + from = room.jid, + stamp = dt.datetime(time); + }):up(); + end + room:route_stanza(msg); +end + +local function set_subject(room, from, subject) + if subject == "" then subject = nil; end + local old_from, old_subject = get_subject(room); + if old_subject == subject and old_from == from then return false; end + room._data.subject_from = from; + room._data.subject = subject; + room._data.subject_time = os.time(); + local msg = create_subject_message(from, subject); + room:broadcast_message(msg); + return true; +end + +-- Send subject to joining user +module:hook("muc-occupant-session-new", function(event) + send_subject(event.room, event.stanza.attr.from, event.room._data.subject_time); +end, 20); + +-- Prosody has made the decision that messages with <subject/> are exclusively subject changes +-- e.g. body will be ignored; even if the subject change was not allowed +module:hook("muc-occupant-groupchat", function(event) + local stanza = event.stanza; + local subject = stanza:get_child("subject"); + if subject then + local room = event.room; + local occupant = event.occupant; + -- Role check for subject changes + local role_rank = valid_roles[occupant and occupant.role or "none"]; + if role_rank >= valid_roles.moderator or + ( role_rank >= valid_roles.participant and get_changesubject(room) ) then -- and participant + set_subject(room, occupant.nick, subject:get_text()); + room:save(); + return true; + else + event.origin.send(st.error_reply(stanza, "auth", "forbidden", "You are not allowed to change the subject")); + return true; + end + end +end, 20); + +return { + get_changesubject = get_changesubject; + set_changesubject = set_changesubject; + get = get_subject; + set = set_subject; + send = send_subject; +}; diff --git a/plugins/muc/util.lib.lua b/plugins/muc/util.lib.lua new file mode 100644 index 00000000..16deb543 --- /dev/null +++ b/plugins/muc/util.lib.lua @@ -0,0 +1,58 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local _M = {}; + +_M.valid_affiliations = { + outcast = -1; + none = 0; + member = 1; + admin = 2; + owner = 3; +}; + +_M.valid_roles = { + none = 0; + visitor = 1; + participant = 2; + moderator = 3; +}; + +local kickable_error_conditions = { + ["gone"] = true; + ["internal-server-error"] = true; + ["item-not-found"] = true; + ["jid-malformed"] = true; + ["recipient-unavailable"] = true; + ["redirect"] = true; + ["remote-server-not-found"] = true; + ["remote-server-timeout"] = true; + ["service-unavailable"] = true; + ["malformed error"] = true; +}; +function _M.is_kickable_error(stanza) + local cond = select(2, stanza:get_error()) or "malformed error"; + return kickable_error_conditions[cond]; +end + +local muc_x_filters = { + ["http://jabber.org/protocol/muc"] = true; + ["http://jabber.org/protocol/muc#user"] = true; +} +local function muc_x_filter(tag) + if muc_x_filters[tag.attr.xmlns] then + return nil; + end + return tag; +end +function _M.filter_muc_x(stanza) + return stanza:maptags(muc_x_filter); +end + +return _M; diff --git a/plugins/muc/whois.lib.lua b/plugins/muc/whois.lib.lua new file mode 100644 index 00000000..636d7c59 --- /dev/null +++ b/plugins/muc/whois.lib.lua @@ -0,0 +1,66 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +local valid_whois = { + moderators = true; + anyone = true; +}; + +local function get_whois(room) + return room._data.whois or "moderators"; +end + +local function set_whois(room, whois) + assert(valid_whois[whois], "Invalid whois value") + if get_whois(room) == whois then return false; end + room._data.whois = whois; + return true; +end + +module:hook("muc-disco#info", function(event) + local whois = get_whois(event.room) ~= "anyone" and "muc_semianonymous" or "muc_nonanonymous"; + event.reply:tag("feature", { var = whois }):up(); +end); + +module:hook("muc-config-form", function(event) + local whois = get_whois(event.room); + table.insert(event.form, { + name = 'muc#roomconfig_whois', + type = 'list-single', + label = 'Addresses (JIDs) of room occupants may be viewed by:', + options = { + { value = 'moderators', label = 'Moderators only', default = whois == 'moderators' }, + { value = 'anyone', label = 'Anyone', default = whois == 'anyone' } + } + }); +end, 80-4); + +module:hook("muc-config-submitted/muc#roomconfig_whois", function(event) + if set_whois(event.room, event.value) then + local code = (event.value == 'moderators') and "173" or "172"; + event.status_codes[code] = true; + end +end); + +-- Mask 'from' jid as occupant jid if room is anonymous +module:hook("muc-invite", function(event) + local room, stanza = event.room, event.stanza; + if get_whois(room) == "moderators" and room:get_default_role(room:get_affiliation(stanza.attr.to)) ~= "moderator" then + local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite"); + local occupant_jid = room:get_occupant_jid(invite.attr.from); + if occupant_jid ~= nil then -- FIXME: This will expose real jid if inviter is not in room + invite.attr.from = occupant_jid; + end + end +end, 50); + +return { + get = get_whois; + set = set_whois; +}; |