diff options
61 files changed, 5271 insertions, 1461 deletions
@@ -1,3 +1,17 @@ +trunk +===== + +**YYYY-MM-DD** + +New features +------------ + +- Rewritten more extensible MUC module + - Store inactive rooms to disk + - Store rooms to disk on shutdown +- mod\_pep\_plus +- Asynchronous operations + 0.10.not-released-yet ===================== @@ -2,11 +2,11 @@ Welcome hackers! This project accepts and *encourages* contributions. If you would like to get involved you can join us on our mailing list and discussion rooms. More -information on these at http://prosody.im/discuss +information on these at https://prosody.im/discuss Patches are welcome, though before sending we would appreciate if you read docs/coding_style.txt for guidelines on how to format your code, and other tips. -Documentation for developers can be found at http://prosody.im/doc/developers +Documentation for developers can be found at https://prosody.im/doc/developers Have fun :) @@ -1,5 +1,5 @@ (This file was created from -http://prosody.im/doc/installing_from_source on 2013-03-31) +https://prosody.im/doc/installing_from_source on 2013-03-31) ====== Installing from source ====== ==== Dependencies ==== @@ -9,29 +9,29 @@ rapidly prototype new protocols. ## Useful links -Homepage: http://prosody.im/ -Download: http://prosody.im/download -Documentation: http://prosody.im/doc/ +Homepage: https://prosody.im/ +Download: https://prosody.im/download +Documentation: https://prosody.im/doc/ Jabber/XMPP Chat: Address: prosody@conference.prosody.im Web interface: - http://prosody.im/webchat + https://prosody.im/webchat Mailing lists: User support and discussion: - http://groups.google.com/group/prosody-users + https://groups.google.com/group/prosody-users Development discussion: - http://groups.google.com/group/prosody-dev + https://groups.google.com/group/prosody-dev Issue tracker changes: - http://groups.google.com/group/prosody-issues + https://groups.google.com/group/prosody-issues ## Installation See the accompanying INSTALL file for help on building Prosody from source. Alternatively -see our guide at http://prosody.im/doc/install +see our guide at https://prosody.im/doc/install diff --git a/certs/localhost.cnf b/certs/localhost.cnf index a7dc6cfe..61d59e72 100644 --- a/certs/localhost.cnf +++ b/certs/localhost.cnf @@ -11,7 +11,7 @@ otherName.2 = 1.3.6.1.5.5.7.8.5;FORMAT:UTF8,UTF8:localhost [distinguished_name] countryName = GB organizationName = Prosody IM -organizationalUnitName = http://prosody.im/doc/certificates +organizationalUnitName = https://prosody.im/doc/certificates commonName = Example certificate [req] diff --git a/core/hostmanager.lua b/core/hostmanager.lua index 53c1cd4e..d7a585f9 100644 --- a/core/hostmanager.lua +++ b/core/hostmanager.lua @@ -12,8 +12,6 @@ local events_new = require "util.events".new; local disco_items = require "util.multitable".new(); local NULL = {}; -local jid_split = require "util.jid".split; - local log = require "util.logger".init("hostmanager"); local hosts = prosody.hosts; @@ -24,7 +22,7 @@ end local incoming_s2s = _G.prosody.incoming_s2s; local core_route_stanza = _G.prosody.core_route_stanza; -local pairs, select, rawget = pairs, select, rawget; +local pairs, rawget = pairs, rawget; local tostring, type = tostring, type; local setmetatable = setmetatable; @@ -71,13 +69,6 @@ end prosody_events.add_handler("server-starting", load_enabled_hosts); local function host_send(stanza) - local name, stanza_type = stanza.name, stanza.attr.type; - if stanza_type == "error" or (name == "iq" and stanza_type == "result") then - local dest_host_name = select(2, jid_split(stanza.attr.to)); - local dest_host = hosts[dest_host_name] or { type = "unknown" }; - log("warn", "Unhandled response sent to %s host %s: %s", dest_host.type, dest_host_name, tostring(stanza)); - return; - end core_route_stanza(nil, stanza); end diff --git a/core/moduleapi.lua b/core/moduleapi.lua index ee1f3e60..d89d46fa 100644 --- a/core/moduleapi.lua +++ b/core/moduleapi.lua @@ -20,9 +20,10 @@ local st = require "util.stanza"; local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat; local error, setmetatable, type = error, setmetatable, type; local ipairs, pairs, select = ipairs, pairs, select; -local unpack = table.unpack or unpack; --luacheck: ignore 113 local tonumber, tostring = tonumber, tostring; local require = require; +local pack = table.pack or function(...) return {n=select("#",...), ...}; end -- table.pack is only in 5.2 +local unpack = table.unpack or unpack; --luacheck: ignore 113 -- renamed in 5.2 local prosody = prosody; local hosts = prosody.hosts; @@ -386,11 +387,29 @@ function api:broadcast(jids, stanza, iter) end end -function api:add_timer(delay, callback) - return timer.add_task(delay, function (t) - if self.loaded == false then return; end - return callback(t); - end); +local timer_methods = { } +local timer_mt = { + __index = timer_methods; +} +function timer_methods:stop( ) + timer.stop(self.id); +end +timer_methods.disarm = timer_methods.stop +function timer_methods:reschedule(delay) + timer.reschedule(self.id, delay) +end + +local function timer_callback(now, id, t) --luacheck: ignore 212/id + if t.module_env.loaded == false then return; end + return t.callback(now, unpack(t, 1, t.n)); +end + +function api:add_timer(delay, callback, ...) + local t = pack(...) + t.module_env = self; + t.callback = callback; + t.id = timer.add_task(delay, timer_callback, t); + return setmetatable(t, timer_mt); end local path_sep = package.config:sub(1,1); diff --git a/core/s2smanager.lua b/core/s2smanager.lua index a8d399d2..abeaa646 100644 --- a/core/s2smanager.lua +++ b/core/s2smanager.lua @@ -64,6 +64,7 @@ local function retire_session(session, reason) function session.send(data) log("debug", "Discarding data sent to resting session: %s", tostring(data)); end function session.data(data) log("debug", "Discarding data received from resting session: %s", tostring(data)); end + session.thread = { run = function (_, data) return session.data(data) end }; session.sends2s = session.send; return setmetatable(session, resting_session); end diff --git a/core/sessionmanager.lua b/core/sessionmanager.lua index 75e54b4f..3224a84d 100644 --- a/core/sessionmanager.lua +++ b/core/sessionmanager.lua @@ -72,6 +72,7 @@ local function retire_session(session) function session.send(data) log("debug", "Discarding data sent to resting session: %s", tostring(data)); return false; end function session.data(data) log("debug", "Discarding data received from resting session: %s", tostring(data)); end + session.thread = { run = function (_, data) return session.data(data) end }; return setmetatable(session, resting_session); end diff --git a/core/storagemanager.lua b/core/storagemanager.lua index 319046b9..f0450dfb 100644 --- a/core/storagemanager.lua +++ b/core/storagemanager.lua @@ -136,7 +136,7 @@ local map_shim_mt = { }; } -local open; +local open; -- forward declaration local function create_map_shim(host, store) local keyval_store, err = open(host, store, "keyval"); diff --git a/doc/session.txt b/doc/session.txt index fc6eec17..c5dd361e 100644 --- a/doc/session.txt +++ b/doc/session.txt @@ -20,7 +20,7 @@ session { -- methods --
send(x) -- converts x to a string, and writes it to the connection
- disconnect(x) -- Disconnect the user and clean up the session, best call sessionmanager.destroy_session() instead of this in most cases
+ close(x) -- Disconnect the user and clean up the session, best call sessionmanager.destroy_session() instead of this in most cases
}
if session.full_jid (also session.roster and session.resource) then this is a "connected resource"
diff --git a/net/connlisteners.lua b/net/connlisteners.lua index 000bfa63..38cf8f08 100644 --- a/net/connlisteners.lua +++ b/net/connlisteners.lua @@ -5,7 +5,7 @@ local traceback = debug.traceback; local _ENV = nil; local function fail() - log("error", "Attempt to use legacy connlisteners API. For more info see http://prosody.im/doc/developers/network"); + log("error", "Attempt to use legacy connlisteners API. For more info see https://prosody.im/doc/developers/network"); log("error", "Legacy connlisteners API usage, %s", traceback("", 2)); end diff --git a/net/cqueues.lua b/net/cqueues.lua new file mode 100644 index 00000000..8c4c756f --- /dev/null +++ b/net/cqueues.lua @@ -0,0 +1,74 @@ +-- Prosody IM +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- +-- This module allows you to use cqueues with a net.server mainloop +-- + +local server = require "net.server"; +local cqueues = require "cqueues"; +assert(cqueues.VERSION >= 20150113, "cqueues newer than 20150113 required") + +-- Create a single top level cqueue +local cq; + +if server.cq then -- server provides cqueues object + cq = server.cq; +elseif server.get_backend() == "select" and server._addtimer then -- server_select + cq = cqueues.new(); + local function step() + assert(cq:loop(0)); + end + + -- Use wrapclient (as wrapconnection isn't exported) to get server_select to watch cq fd + local handler = server.wrapclient({ + getfd = function() return cq:pollfd(); end; + settimeout = function() end; -- Method just needs to exist + close = function() end; -- Need close method for 'closeall' + }, nil, nil, {}); + + -- Only need to listen for readable; cqueues handles everything under the hood + -- readbuffer is called when `select` notes an fd as readable + handler.readbuffer = step; + + -- Use server_select low lever timer facility, + -- this callback gets called *every* time there is a timeout in the main loop + server._addtimer(function(current_time) + -- This may end up in extra step()'s, but cqueues handles it for us. + step(); + return cq:timeout(); + end); +elseif server.event and server.base then -- server_event + cq = cqueues.new(); + -- Only need to listen for readable; cqueues handles everything under the hood + local EV_READ = server.event.EV_READ; + -- Convert a cqueues timeout to an acceptable timeout for luaevent + local function luaevent_safe_timeout(cq) + local t = cq:timeout(); + -- if you give luaevent 0 or nil, it re-uses the previous timeout. + if t == 0 then + t = 0.000001; -- 1 microsecond is the smallest that works (goes into a `struct timeval`) + elseif t == nil then -- pick something big if we don't have one + t = 0x7FFFFFFF; -- largest 32bit int + end + return t + end + local event_handle; + event_handle = server.base:addevent(cq:pollfd(), EV_READ, function(e) + -- Need to reference event_handle or this callback will get collected + -- This creates a circular reference that can only be broken if event_handle is manually :close()'d + local _ = event_handle; + -- Run as many cqueues things as possible (with a timeout of 0) + -- If an error is thrown, it will break the libevent loop; but prosody resumes after logging a top level error + assert(cq:loop(0)); + return EV_READ, luaevent_safe_timeout(cq); + end, luaevent_safe_timeout(cq)); +else + error "NYI" +end + +return { + cq = cq; +} diff --git a/net/httpserver.lua b/net/httpserver.lua index 6e2e31b9..56561306 100644 --- a/net/httpserver.lua +++ b/net/httpserver.lua @@ -5,7 +5,7 @@ local traceback = debug.traceback; local _ENV = nil; function fail() - log("error", "Attempt to use legacy HTTP API. For more info see http://prosody.im/doc/developers/legacy_http"); + log("error", "Attempt to use legacy HTTP API. For more info see https://prosody.im/doc/developers/legacy_http"); log("error", "Legacy HTTP API usage, %s", traceback("", 2)); end diff --git a/net/server.lua b/net/server.lua index 41e180fa..8b6fbc0b 100644 --- a/net/server.lua +++ b/net/server.lua @@ -6,25 +6,75 @@ -- COPYING file in the source package for more information. -- -local use_luaevent = prosody and require "core.configmanager".get("*", "use_libevent"); +local server_type = prosody and require "core.configmanager".get("*", "network_backend") or "select"; +if prosody and require "core.configmanager".get("*", "use_libevent") then + server_type = "event"; +end -if use_luaevent then - use_luaevent = pcall(require, "luaevent.core"); - if not use_luaevent then +if server_type == "event" then + if not pcall(require, "luaevent.core") then log("error", "libevent not found, falling back to select()"); + server_type = "select" end end local server; - -if use_luaevent then +local set_config; +if server_type == "event" then server = require "net.server_event"; - -- Overwrite signal.signal() because we need to ask libevent to - -- handle them instead - local ok, signal = pcall(require, "util.signal"); - if ok and signal then - local _signal_signal = signal.signal; + local defaults = {}; + for k,v in pairs(server.cfg) do + defaults[k] = v; + end + function set_config(settings) + local event_settings = { + ACCEPT_DELAY = settings.accept_retry_interval; + ACCEPT_QUEUE = settings.tcp_backlog; + CLEAR_DELAY = settings.event_clear_interval; + CONNECT_TIMEOUT = settings.connect_timeout; + DEBUG = settings.debug; + HANDSHAKE_TIMEOUT = settings.ssl_handshake_timeout; + MAX_CONNECTIONS = settings.max_connections; + MAX_HANDSHAKE_ATTEMPTS = settings.max_ssl_handshake_roundtrips; + MAX_READ_LENGTH = settings.max_receive_buffer_size; + MAX_SEND_LENGTH = settings.max_send_buffer_size; + READ_TIMEOUT = settings.read_timeout; + WRITE_TIMEOUT = settings.send_timeout; + }; + + for k,default in pairs(defaults) do + server.cfg[k] = event_settings[k] or default; + end + end +elseif server_type == "select" then + server = require "net.server_select"; + + local defaults = {}; + for k,v in pairs(server.getsettings()) do + defaults[k] = v; + end + function set_config(settings) + local select_settings = {}; + for k,default in pairs(defaults) do + select_settings[k] = settings[k] or default; + end + server.changesettings(select_settings); + end +else + server = require("net.server_"..server_type); + set_config = server.set_config; + if not server.get_backend then + function server.get_backend() + return server_type; + end + end +end + +-- If server.hook_signal exists, replace signal.signal() +local has_signal, signal = pcall(require, "util.signal"); +if has_signal then + if server.hook_signal then function signal.signal(signal_id, handler) if type(signal_id) == "string" then signal_id = signal[signal_id:upper()]; @@ -34,46 +84,22 @@ if use_luaevent then end return server.hook_signal(signal_id, handler); end + else + server.hook_signal = signal.signal; end else - use_luaevent = false; - server = require "net.server_select"; + if not server.hook_signal then + server.hook_signal = function() + return false, "signal hooking not supported" + end + end end -if prosody then +if prosody and set_config then local config_get = require "core.configmanager".get; - local defaults = {}; - for k,v in pairs(server.cfg or server.getsettings()) do - defaults[k] = v; - end local function load_config() local settings = config_get("*", "network_settings") or {}; - if use_luaevent then - local event_settings = { - ACCEPT_DELAY = settings.accept_retry_interval; - ACCEPT_QUEUE = settings.tcp_backlog; - CLEAR_DELAY = settings.event_clear_interval; - CONNECT_TIMEOUT = settings.connect_timeout; - DEBUG = settings.debug; - HANDSHAKE_TIMEOUT = settings.ssl_handshake_timeout; - MAX_CONNECTIONS = settings.max_connections; - MAX_HANDSHAKE_ATTEMPTS = settings.max_ssl_handshake_roundtrips; - MAX_READ_LENGTH = settings.max_receive_buffer_size; - MAX_SEND_LENGTH = settings.max_send_buffer_size; - READ_TIMEOUT = settings.read_timeout; - WRITE_TIMEOUT = settings.send_timeout; - }; - - for k,default in pairs(defaults) do - server.cfg[k] = event_settings[k] or default; - end - else - local select_settings = {}; - for k,default in pairs(defaults) do - select_settings[k] = settings[k] or default; - end - server.changesettings(select_settings); - end + return set_config(settings); end load_config(); prosody.events.add_handler("config-reloaded", load_config); diff --git a/net/server_epoll.lua b/net/server_epoll.lua new file mode 100644 index 00000000..7a62b03c --- /dev/null +++ b/net/server_epoll.lua @@ -0,0 +1,694 @@ +-- Prosody IM +-- Copyright (C) 2016 Kim Alvefur +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +-- server_epoll +-- Server backend based on https://luarocks.org/modules/zash/lua-epoll + +local t_sort = table.sort; +local t_insert = table.insert; +local t_remove = table.remove; +local t_concat = table.concat; +local setmetatable = setmetatable; +local tostring = tostring; +local pcall = pcall; +local next = next; +local pairs = pairs; +local log = require "util.logger".init("server_epoll"); +local epoll = require "epoll"; +local socket = require "socket"; +local luasec = require "ssl"; +local gettime = require "util.time".now; +local createtable = require "util.table".create; +local _SOCKETINVALID = socket._SOCKETINVALID or -1; + +assert(socket.tcp6 and socket.tcp4, "Incompatible LuaSocket version"); + +local _ENV = nil; + +local default_config = { __index = { + read_timeout = 900; + write_timeout = 7; + tcp_backlog = 128; + accept_retry_interval = 10; + read_retry_delay = 1e-06; + connect_timeout = 20; + handshake_timeout = 60; + max_wait = 86400; + min_wait = 1e-06; +}}; +local cfg = default_config.__index; + +local fds = createtable(10, 0); -- FD -> conn + +-- Timer and scheduling -- + +local timers = {}; + +local function noop() end +local function closetimer(t) + t[1] = 0; + t[2] = noop; +end + +-- Set to true when timers have changed +local resort_timers = false; + +-- Add absolute timer +local function at(time, f) + local timer = { time, f, close = closetimer }; + t_insert(timers, timer); + resort_timers = true; + return timer; +end + +-- Add relative timer +local function addtimer(timeout, f) + return at(gettime() + timeout, f); +end + +-- Run callbacks of expired timers +-- Return time until next timeout +local function runtimers(next_delay, min_wait) + -- Any timers at all? + if not timers[1] then + return next_delay; + end + + if resort_timers then + -- Sort earliest timers to the end + t_sort(timers, function (a, b) return a[1] > b[1]; end); + resort_timers = false; + end + + -- Iterate from the end and remove completed timers + for i = #timers, 1, -1 do + local timer = timers[i]; + local t, f = timer[1], timer[2]; + -- Get time for every iteration to increase accuracy + local now = gettime(); + if t > now then + -- This timer should not fire yet + local diff = t - now; + if diff < next_delay then + next_delay = diff; + end + break; + end + local new_timeout = f(now); + if new_timeout then + -- Schedule for 'delay' from the time actually scheduled, + -- not from now, in order to prevent timer drift. + timer[1] = t + new_timeout; + resort_timers = true; + else + t_remove(timers, i); + end + end + + if resort_timers or next_delay < min_wait then + -- Timers may be added from within a timer callback. + -- Those would not be considered for next_delay, + -- and we might sleep for too long, so instead + -- we return a shorter timeout so we can + -- properly sort all new timers. + next_delay = min_wait; + end + + return next_delay; +end + +-- Socket handler interface + +local interface = {}; +local interface_mt = { __index = interface }; + +function interface_mt:__tostring() + if self.sockname and self.peername then + return ("FD %d (%s, %d, %s, %d)"):format(self:getfd(), self.peername, self.peerport, self.sockname, self.sockport); + elseif self.sockname or self.peername then + return ("FD %d (%s, %d)"):format(self:getfd(), self.sockname or self.peername, self.sockport or self.peerport); + end + return ("%s FD %d"):format(tostring(self.conn), self:getfd()); +end + +-- Replace the listener and tell the old one +function interface:setlistener(listeners) + self:on("detach"); + self.listeners = listeners; +end + +-- Call a listener callback +function interface:on(what, ...) + local listener = self.listeners["on"..what]; + if not listener then + -- log("debug", "Missing listener 'on%s'", what); -- uncomment for development and debugging + return; + end + local ok, err = pcall(listener, self, ...); + if not ok then + log("error", "Error calling on%s: %s", what, err); + end + return err; +end + +-- Return the file descriptor number +function interface:getfd() + if self.conn then + return self.conn:getfd(); + end + return _SOCKETINVALID; +end + +-- Get IP address +function interface:ip() + return self.peername or self.sockname; +end + +-- Get a port number, doesn't matter which +function interface:port() + return self.sockport or self.peerport; +end + +-- Get local port number +function interface:clientport() + return self.sockport; +end + +-- Get remote port +function interface:serverport() + if self.sockport then + return self.sockport; + elseif self.server then + self.server:port(); + end +end + +-- Return underlying socket +function interface:socket() + return self.conn; +end + +function interface:setoption(k, v) + -- LuaSec doesn't expose setoption :( + if self.conn.setoption then + self.conn:setoption(k, v); + end +end + +-- Timeout for detecting dead or idle sockets +function interface:setreadtimeout(t) + if t == false then + if self._readtimeout then + self._readtimeout:close(); + self._readtimeout = nil; + end + return + end + t = t or cfg.read_timeout; + if self._readtimeout then + self._readtimeout[1] = gettime() + t; + resort_timers = true; + else + self._readtimeout = addtimer(t, function () + if self:on("readtimeout") then + return cfg.read_timeout; + else + self:on("disconnect", "read timeout"); + self:destroy(); + end + end); + end +end + +-- Timeout for detecting dead sockets +function interface:setwritetimeout(t) + if t == false then + if self._writetimeout then + self._writetimeout:close(); + self._writetimeout = nil; + end + return + end + t = t or cfg.write_timeout; + if self._writetimeout then + self._writetimeout[1] = gettime() + t; + resort_timers = true; + else + self._writetimeout = addtimer(t, function () + self:on("disconnect", "write timeout"); + self:destroy(); + end); + end +end + +-- lua-epoll flag for currently requested poll state +function interface:flags() + if self._wantread then + if self._wantwrite then + return "rw"; + end + return "r"; + elseif self._wantwrite then + return "w"; + end +end + +-- Add or remove sockets or modify epoll flags +function interface:setflags(r, w) + if r ~= nil then self._wantread = r; end + if w ~= nil then self._wantwrite = w; end + local flags = self:flags(); + local currentflags = self._flags; + if flags == currentflags then + return true; + end + local fd = self:getfd(); + if fd < 0 then + self._wantread, self._wantwrite = nil, nil; + return nil, "invalid fd"; + end + local op = "mod"; + if not flags then + op = "del"; + elseif not currentflags then + op = "add"; + end + local ok, err = epoll.ctl(op, fd, flags); +-- log("debug", "epoll_ctl(%q, %d, %q) -> %s" .. (err and ", %q" or ""), +-- op, fd, flags or "", tostring(ok), err); + if not ok then return ok, err end + if op == "add" then + fds[fd] = self; + elseif op == "del" then + fds[fd] = nil; + end + self._flags = flags; + return true; +end + +-- Called when socket is readable +function interface:onreadable() + local data, err, partial = self.conn:receive(self._pattern); + if data then + self:on("incoming", data); + else + if partial then + self:on("incoming", partial, err); + end + if err == "wantread" then + self:setflags(true, nil); + elseif err == "wantwrite" then + self:setflags(nil, true); + elseif err ~= "timeout" then + self:on("disconnect", err); + self:destroy() + return; + end + end + if not self.conn then return; end + if self.conn:dirty() then + self:setreadtimeout(false); + self:pausefor(cfg.read_retry_delay); + else + self:setreadtimeout(); + end +end + +-- Called when socket is writable +function interface:onwriteable() + local buffer = self.writebuffer; + local data = t_concat(buffer); + local ok, err, partial = self.conn:send(data); + if ok then + for i = #buffer, 1, -1 do + buffer[i] = nil; + end + self:setflags(nil, false); + self:setwritetimeout(false); + self:ondrain(); -- Be aware of writes in ondrain + return; + end + if partial then + buffer[1] = data:sub(partial+1); + for i = #buffer, 2, -1 do + buffer[i] = nil; + end + self:setwritetimeout(); + end + if err == "wantwrite" or err == "timeout" then + self:setflags(nil, true); + elseif err == "wantread" then + self:setflags(true, nil); + elseif err ~= "timeout" then + self:on("disconnect", err); + self:destroy(); + end +end + +-- The write buffer has been successfully emptied +function interface:ondrain() + if self._toclose then + return self:close(); + elseif self._starttls then + return self:starttls(); + else + return self:on("drain"); + end +end + +-- Add data to write buffer and set flag for wanting to write +function interface:write(data) + local buffer = self.writebuffer; + if buffer then + t_insert(buffer, data); + else + self.writebuffer = { data }; + end + self:setwritetimeout(); + self:setflags(nil, true); + return #data; +end +interface.send = interface.write; + +-- Close, possibly after writing is done +function interface:close() + if self._wantwrite then + self:setflags(false, true); -- Flush final buffer contents + self.write, self.send = noop, noop; -- No more writing + log("debug", "Close %s after writing", tostring(self)); + self._toclose = true; + else + log("debug", "Close %s now", tostring(self)); + self.write, self.send = noop, noop; + self.close = noop; + self:on("disconnect"); + self:destroy(); + end +end + +function interface:destroy() + self:setflags(false, false); + self:setwritetimeout(false); + self:setreadtimeout(false); + self.onreadable = noop; + self.onwriteable = noop; + self.destroy = noop; + self.close = noop; + self.on = noop; + self.conn:close(); + self.conn = nil; +end + +function interface:ssl() + return self._tls; +end + +function interface:starttls(ctx) + if ctx then self.tls = ctx; end + if self.writebuffer and self.writebuffer[1] then + log("debug", "Start TLS on %s after write", tostring(self)); + self._starttls = true; + self:setflags(nil, true); -- make sure wantwrite is set + else + log("debug", "Start TLS on %s now", tostring(self)); + self:setflags(false, false); + local conn, err = luasec.wrap(self.conn, ctx or self.tls); + if not conn then + self:on("disconnect", err); + self:destroy(); + return conn, err; + end + conn:settimeout(0); + self.conn = conn; + self._starttls = nil; + self.onwriteable = interface.tlshandskake; + self.onreadable = interface.tlshandskake; + self:setflags(true, true); + self:setwritetimeout(cfg.handshake_timeout); + end +end + +function interface:tlshandskake() + self:setwritetimeout(false); + self:setreadtimeout(false); + local ok, err = self.conn:dohandshake(); + if ok then + log("debug", "TLS handshake on %s complete", tostring(self)); + self.onwriteable = nil; + self.onreadable = nil; + self:setflags(true, true); + local old = self._tls; + self._tls = true; + self.starttls = false; + if old == false then + self:init(); + else + self:setflags(true, true); + self:on("status", "ssl-handshake-complete"); + end + elseif err == "wantread" then + log("debug", "TLS handshake on %s to wait until readable", tostring(self)); + self:setflags(true, false); + self:setreadtimeout(cfg.handshake_timeout); + elseif err == "wantwrite" then + log("debug", "TLS handshake on %s to wait until writable", tostring(self)); + self:setflags(false, true); + self:setwritetimeout(cfg.handshake_timeout); + else + log("debug", "TLS handshake error on %s: %s", tostring(self), err); + self:on("disconnect", err); + self:destroy(); + end +end + +local function wrapsocket(client, server, pattern, listeners, tls) -- luasocket object -> interface object + client:settimeout(0); + local conn = setmetatable({ + conn = client; + server = server; + created = gettime(); + listeners = listeners; + _pattern = pattern or server._pattern; + writebuffer = {}; + tls = tls; + }, interface_mt); + + if client.getpeername then + conn.peername, conn.peerport = client:getpeername(); + end + if client.getsockname then + conn.sockname, conn.sockport = client:getsockname(); + end + return conn; +end + +-- A server interface has new incoming connections waiting +-- This replaces the onreadable callback +function interface:onacceptable() + local conn, err = self.conn:accept(); + if not conn then + log("debug", "Error accepting new client: %s, server will be paused for %ds", err, cfg.accept_retry_interval); + self:pausefor(cfg.accept_retry_interval); + return; + end + local client = wrapsocket(conn, self, nil, self.listeners, self.tls); + log("debug", "New connection %s", tostring(client)); + client:init(); +end + +-- Initialization +function interface:init() + if self.tls and not self._tls then + self._tls = false; -- This means we should call onconnect when TLS is up + return self:starttls(); + else + self.onwriteable = interface.onconnect; + self:setwritetimeout(); + return self:setflags(false, true); + end +end + +function interface:pause() + return self:setflags(false); +end + +function interface:resume() + return self:setflags(true); +end + +-- Pause connection for some time +function interface:pausefor(t) + if self._pausefor then + self._pausefor:close(); + end + if t == false then return; end + self:setflags(false); + self._pausefor = addtimer(t, function () + self._pausefor = nil; + if self.conn and self.conn:dirty() then + self:onreadable(); + end + self:setflags(true); + end); +end + +-- Connected! +function interface:onconnect() + self.onwriteable = nil; + self:on("connect"); + self:setflags(true); + return self:onwriteable(); +end + +local function addserver(addr, port, listeners, pattern, tls) + local conn, err = socket.bind(addr, port, cfg.tcp_backlog); + if not conn then return conn, err; end + conn:settimeout(0); + local server = setmetatable({ + conn = conn; + created = gettime(); + listeners = listeners; + _pattern = pattern; + onreadable = interface.onacceptable; + tls = tls; + sockname = addr; + sockport = port; + }, interface_mt); + server:setflags(true, false); + return server; +end + +-- COMPAT +local function wrapclient(conn, addr, port, listeners, pattern, tls) + local client = wrapsocket(conn, nil, pattern, listeners, tls); + if not client.peername then + client.peername, client.peerport = addr, port; + end + client:init(); + return client; +end + +-- New outgoing TCP connection +local function addclient(addr, port, listeners, pattern, tls) + local conn, err = socket.tcp(); + if not conn then return conn, err; end + conn:settimeout(0); + conn:connect(addr, port); + local client = wrapsocket(conn, nil, pattern, listeners, tls) + client:init(); + return client, conn; +end + +-- Dump all data from one connection into another +local function link(from, to) + from.listeners = setmetatable({ + onincoming = function (_, data) + from:pause(); + to:write(data); + end, + }, {__index=from.listeners}); + to.listeners = setmetatable({ + ondrain = function () + from:resume(); + end, + }, {__index=to.listeners}); + from:setflags(true, nil); + to:setflags(nil, true); +end + +-- XXX What uses this? +-- net.adns +function interface:set_send(new_send) + self.send = new_send; +end + +-- Close all connections and servers +local function closeall() + for fd, conn in pairs(fds) do -- luacheck: ignore 213/fd + conn:close(); + end +end + +local quitting = nil; + +-- Signal main loop about shutdown via above upvalue +local function setquitting(quit) + if quit then + quitting = "quitting"; + closeall(); + else + quitting = nil; + end +end + +-- Main loop +local function loop(once) + repeat + local t = runtimers(cfg.max_wait, cfg.min_wait); + local fd, r, w = epoll.wait(t); + if fd then + local conn = fds[fd]; + if conn then + if r then + conn:onreadable(); + end + if w then + conn:onwriteable(); + end + else + log("debug", "Removing unknown fd %d", fd); + epoll.ctl("del", fd); + end + elseif r ~= "timeout" then + log("debug", "epoll_wait error: %s", tostring(r)); + end + until once or (quitting and next(fds) == nil); + return quitting; +end + +return { + get_backend = function () return "epoll"; end; + addserver = addserver; + addclient = addclient; + add_task = addtimer; + at = at; + loop = loop; + closeall = closeall; + setquitting = setquitting; + wrapclient = wrapclient; + link = link; + set_config = function (newconfig) + cfg = setmetatable(newconfig, default_config); + end; + + -- libevent emulation + event = { EV_READ = "r", EV_WRITE = "w", EV_READWRITE = "rw", EV_LEAVE = -1 }; + addevent = function (fd, mode, callback) + local function onevent(self) + local ret = self:callback(); + if ret == -1 then + epoll.ctl("del", fd); + elseif ret then + epoll.ctl("mod", fd, mode); + end + end + + local conn = { + callback = callback; + onreadable = onevent; + onwriteable = onevent; + close = function () + fds[fd] = nil; + return epoll.ctl("del", fd); + end; + }; + fds[fd] = conn; + local ok, err = epoll.ctl("add", fd, mode or "r"); + if not ok then return ok, err; end + return conn; + end; +}; diff --git a/net/server_event.lua b/net/server_event.lua index 43a19fe9..fc3bde9f 100644 --- a/net/server_event.lua +++ b/net/server_event.lua @@ -810,6 +810,20 @@ local function link(sender, receiver, buffersize) sender:set_mode("*a"); end +local function add_task(delay, callback) + local event_handle; + event_handle = base:addevent(nil, 0, function () + local ret = callback(socket_gettime()); + if ret then + return 0, ret; + elseif event_handle then + return -1; + end + end + , delay); + return event_handle; +end + return { cfg = cfg, base = base, @@ -825,6 +839,7 @@ return { closeall = closeallservers, get_backend = get_backend, hook_signal = hook_signal, + add_task = add_task, __NAME = SCRIPT_NAME, __DATE = LAST_MODIFIED, diff --git a/net/server_select.lua b/net/server_select.lua index 12aef9d8..f051da24 100644 --- a/net/server_select.lua +++ b/net/server_select.lua @@ -40,6 +40,7 @@ local coroutine = use "coroutine" local math_min = math.min local math_huge = math.huge local table_concat = table.concat +local table_insert = table.insert local string_sub = string.sub local coroutine_wrap = coroutine.wrap local coroutine_yield = coroutine.yield @@ -55,7 +56,6 @@ local getaddrinfo = luasocket.dns.getaddrinfo local ssl_wrap = ( has_luasec and luasec.wrap ) local socket_bind = luasocket.bind -local socket_sleep = luasocket.sleep local socket_select = luasocket.select --// functions //-- @@ -100,7 +100,6 @@ local _sendtraffic local _readtraffic local _selecttimeout -local _sleeptime local _tcpbacklog local _accepretry @@ -114,8 +113,6 @@ local _checkinterval local _sendtimeout local _readtimeout -local _timer - local _maxselectlen local _maxfd @@ -141,7 +138,6 @@ _sendtraffic = 0 -- some stats _readtraffic = 0 _selecttimeout = 1 -- timeout of socket.select -_sleeptime = 0 -- time to wait at the end of every loop _tcpbacklog = 128 -- some kind of hint to the OS _accepretry = 10 -- seconds to wait until the next attempt of a full server to accept @@ -301,7 +297,6 @@ wrapconnection = function( server, listeners, socket, ip, serverport, clientport local bufferqueuelen = 0 -- end of buffer array local toclose - local fatalerror local needtls local bufferlen = 0 @@ -517,7 +512,6 @@ wrapconnection = function( server, listeners, socket, ip, serverport, clientport return dispatch( handler, buffer, err ) else -- connections was closed or fatal error out_put( "server.lua: client ", tostring(ip), ":", tostring(clientport), " read error: ", tostring(err) ) - fatalerror = true _ = handler and handler:force_close( err ) return false end @@ -557,7 +551,6 @@ wrapconnection = function( server, listeners, socket, ip, serverport, clientport return true else -- connection was closed during sending or fatal error out_put( "server.lua: client ", tostring(ip), ":", tostring(clientport), " write error: ", tostring(err) ) - fatalerror = true _ = handler and handler:force_close( err ) return false end @@ -806,7 +799,6 @@ end getsettings = function( ) return { select_timeout = _selecttimeout; - select_sleep_time = _sleeptime; tcp_backlog = _tcpbacklog; max_send_buffer_size = _maxsendlen; max_receive_buffer_size = _maxreadlen; @@ -825,7 +817,6 @@ changesettings = function( new ) return nil, "invalid settings table" end _selecttimeout = tonumber( new.select_timeout ) or _selecttimeout - _sleeptime = tonumber( new.select_sleep_time ) or _sleeptime _maxsendlen = tonumber( new.max_send_buffer_size ) or _maxsendlen _maxreadlen = tonumber( new.max_receive_buffer_size ) or _maxreadlen _checkinterval = tonumber( new.select_idle_check_interval ) or _checkinterval @@ -848,6 +839,49 @@ addtimer = function( listener ) return true end +local add_task do + local data = {}; + local new_data = {}; + + function add_task(delay, callback) + local current_time = luasocket_gettime(); + delay = delay + current_time; + if delay >= current_time then + table_insert(new_data, {delay, callback}); + else + local r = callback(current_time); + if r and type(r) == "number" then + return add_task(r, callback); + end + end + end + + addtimer(function(current_time) + if #new_data > 0 then + for _, d in pairs(new_data) do + table_insert(data, d); + end + new_data = {}; + end + + local next_time = math_huge; + for i, d in pairs(data) do + local t, callback = d[1], d[2]; + if t <= current_time then + data[i] = nil; + local r = callback(current_time); + if type(r) == "number" then + add_task(r, callback); + next_time = math_min(next_time, r); + end + else + next_time = math_min(next_time, t - current_time); + end + end + return next_time; + end); +end + stats = function( ) return _readtraffic, _sendtraffic, _readlistlen, _sendlistlen, _timerlistlen end @@ -861,8 +895,15 @@ end loop = function(once) -- this is the main loop of the program if quitting then return "quitting"; end if once then quitting = "once"; end - local next_timer_time = math_huge; + _currenttime = luasocket_gettime( ) repeat + -- Fire timers + local next_timer_time = math_huge; + for i = 1, _timerlistlen do + local t = _timerlist[ i ]( _currenttime ) -- fire timers + if t then next_timer_time = math_min(next_timer_time, t); end + end + local read, write, err = socket_select( _readlist, _sendlist, math_min(_selecttimeout, next_timer_time) ) for _, socket in ipairs( write ) do -- send data waiting in writequeues local handler = _socketlist[ socket ] @@ -910,27 +951,12 @@ loop = function(once) -- this is the main loop of the program end end - -- Fire timers - if _currenttime - _timer >= math_min(next_timer_time, 1) then - next_timer_time = math_huge; - for i = 1, _timerlistlen do - local t = _timerlist[ i ]( _currenttime ) -- fire timers - if t then next_timer_time = math_min(next_timer_time, t); end - end - _timer = _currenttime - else - next_timer_time = next_timer_time - (_currenttime - _timer); - end - for server, paused_time in pairs( _fullservers ) do if _currenttime - paused_time > _accepretry then _fullservers[ server ] = nil; server.resume(); end end - - -- wait some time (0 by default) - socket_sleep( _sleeptime ) until quitting; if once and quitting == "once" then quitting = nil; return; end closeall(); @@ -977,16 +1003,14 @@ local addclient = function( address, port, listeners, pattern, sslctx, typ ) elseif sslctx and not has_luasec then err = "luasec not found" end - if not typ then + if getaddrinfo and not typ then local addrinfo, err = getaddrinfo(address) if not addrinfo then return nil, err end if addrinfo[1] and addrinfo[1].family == "inet6" then typ = "tcp6" - else - typ = "tcp" end end - local create = luasocket[typ] + local create = luasocket[typ or "tcp"] if type( create ) ~= "function" then err = "invalid socket type" end @@ -1002,22 +1026,19 @@ local addclient = function( address, port, listeners, pattern, sslctx, typ ) end client:settimeout( 0 ) local ok, err = client:connect( address, port ) - if ok or err == "timeout" then + if ok or err == "timeout" or err == "Operation already in progress" then return wrapclient( client, address, port, listeners, pattern, sslctx ) else return nil, err end end ---// EXPERIMENTAL //-- - ----------------------------------// BEGIN //-- use "setmetatable" ( _socketlist, { __mode = "k" } ) use "setmetatable" ( _readtimes, { __mode = "k" } ) use "setmetatable" ( _writetimes, { __mode = "k" } ) -_timer = luasocket_gettime( ) _starttime = luasocket_gettime( ) local function setlogger(new_logger) @@ -1032,6 +1053,7 @@ end return { _addtimer = addtimer, + add_task = add_task; addclient = addclient, wrapclient = wrapclient, diff --git a/plugins/mod_admin_telnet.lua b/plugins/mod_admin_telnet.lua index c73870f3..3250e2ed 100644 --- a/plugins/mod_admin_telnet.lua +++ b/plugins/mod_admin_telnet.lua @@ -335,6 +335,43 @@ function def_env.server:memory() return true, "OK"; end +def_env.timer = {}; + +function def_env.timer:info() + 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 k, 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 + def_env.module = {}; local function get_hosts_set(hosts, module) @@ -960,7 +997,7 @@ function def_env.muc:create(room_jid) 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 + if hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end return hosts[host].modules.muc.create_room(room_jid); end @@ -969,7 +1006,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 = hosts[host].modules.muc.get_room_from_jid(room_jid); if not room_obj then return nil, "No such room: "..room_jid; end @@ -983,8 +1020,8 @@ function def_env.muc:list(host) 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"; @@ -1148,7 +1185,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_bosh.lua b/plugins/mod_bosh.lua index 494d11b0..8d83ecd6 100644 --- a/plugins/mod_bosh.lua +++ b/plugins/mod_bosh.lua @@ -19,8 +19,9 @@ local logger = require "util.logger"; local log = logger.init("mod_bosh"); 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 xmlns_streams = "http://etherx.jabber.org/streams"; @@ -68,13 +69,11 @@ 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 +87,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) + if not session.destroyed then + sessions[context.sid] = nil; + sm_destroy_session(session, reason); end end @@ -124,7 +138,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 +162,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 +197,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,6 +204,10 @@ 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 @@ -198,6 +219,11 @@ 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 @@ -237,10 +263,11 @@ 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; @@ -291,6 +318,10 @@ function stream_callbacks.streamopened(context, attr) }; 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); @@ -402,6 +433,11 @@ function stream_callbacks.streamopened(context, attr) 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()); @@ -415,9 +451,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!"); @@ -467,51 +501,13 @@ 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>]]; }; diff --git a/plugins/mod_c2s.lua b/plugins/mod_c2s.lua index 041eb1f2..7eebaf2d 100644 --- a/plugins/mod_c2s.lua +++ b/plugins/mod_c2s.lua @@ -15,9 +15,10 @@ 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 t_insert, t_remove = table.insert, table.remove; local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; @@ -35,6 +36,7 @@ local hosts = prosody.hosts; local stream_callbacks = { default_ns = "jabber:client" }; local listener = {}; +local runner_callbacks = {}; module:hook("stats-update", function () -- Connection counter resets to 0 on load and reload @@ -131,12 +133,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 @@ -205,6 +204,18 @@ module:hook_global("user-deleted", 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); @@ -241,6 +252,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 0de4d23f..2ef2533b 100644 --- a/plugins/mod_component.lua +++ b/plugins/mod_component.lua @@ -31,7 +31,7 @@ local sessions = module:shared("sessions"); 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_http.lua b/plugins/mod_http.lua index 2cde9341..975663a5 100644 --- a/plugins/mod_http.lua +++ b/plugins/mod_http.lua @@ -50,6 +50,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 @@ -120,7 +123,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(); diff --git a/plugins/mod_muc_unique.lua b/plugins/mod_muc_unique.lua new file mode 100644 index 00000000..0ecd0c7a --- /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 uuid_gen = require "util.uuid".generate; +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(uuid_gen()) -- FIXME Random UUIDs can theoretically have collisions + ); + return true; +end,-1); diff --git a/plugins/mod_pep_plus.lua b/plugins/mod_pep_plus.lua new file mode 100644 index 00000000..f45d5ef3 --- /dev/null +++ b/plugins/mod_pep_plus.lua @@ -0,0 +1,393 @@ +local pubsub = require "util.pubsub"; +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; +local set_new = require "util.set".new; +local st = require "util.stanza"; +local calculate_hash = require "util.caps".calculate_hash; +local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed; + +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 handlers = lib_pubsub.handlers; +local pubsub_error_reply = lib_pubsub.pubsub_error_reply; + +local empty_set = set_new(); + +local services = {}; +local recipients = {}; +local hash_map = {}; + +function module.save() + return { services = services }; +end + +function module.restore(data) + services = data.services; +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 + +local function get_broadcaster(name) + local function simple_broadcast(kind, node, jids, item) + if item then + item = st.clone(item); + item.attr.xmlns = nil; -- Clear the pubsub namespace + end + local message = st.message({ from = name, type = "headline" }) + :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 from %s: %s", jid, name, tostring(item)); + message.attr.to = jid; + module:send(message); + end + end + return simple_broadcast; +end + +function get_pep_service(name) + local service = services[name]; + if service then + return service; + end + service = pubsub.new({ + capabilities = { + none = { + create = false; + publish = false; + retract = false; + get_nodes = false; + + subscribe = false; + unsubscribe = false; + get_subscription = false; + get_subscriptions = false; + get_items = false; + + subscribe_other = false; + unsubscribe_other = false; + get_subscription_other = false; + get_subscriptions_other = false; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = false; + }; + subscriber = { + 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; + }; + }; + + node_defaults = { + ["pubsub#max_items"] = "1"; + }; + + autocreate_on_publish = true; + autocreate_on_subscribe = true; + + broadcaster = get_broadcaster(name); + get_affiliation = function (jid) + if jid_bare(jid) == name then + return "owner"; + elseif subscription_presence(name, jid) then + return "subscriber"; + end + end; + + normalize_jid = jid_bare; + }); + services[name] = service; + module:add_item("pep-service", { service = service, jid = name }); + return service; +end + +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 + return origin.send(st.error_reply(stanza, "cancel", "bad-request")); + end + local service_name = stanza.attr.to or origin.username.."@"..origin.host + local service = get_pep_service(service_name); + local handler = handlers[stanza.attr.type.."_"..action.name]; + if handler then + handler(origin, stanza, action, service); + return true; + end +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 + 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 + 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, items = service:get_items(node, jid); + if not ok then return; end + for i, id in ipairs(items) do + service.config.broadcaster("items", node, { [jid] = true }, items[id]); + end +end + +local function update_subscriptions(recipient, service_name, nodes) + local service = get_pep_service(service_name); + 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 + + for node in current - nodes do + service:remove_subscription(node, recipient, recipient); + end + + for node in nodes - current do + service:add_subscription(node, recipient, recipient); + resend_last_item(recipient, node, service); + 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 + 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; + local service = get_pep_service(user); + + 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, 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 + update_subscriptions(recipient, user); + else + recipients[user] = recipients[user] or {}; + if hash_map[hash] then + update_subscriptions(recipient, user, hash_map[hash]); + 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 can't deal with missing from attribute + origin.send( + st.stanza("iq", {from=user, 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 + update_subscriptions(stanza.attr.from, user); + 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 + update_subscriptions(subscriber, user); + end + end + end + end +end, 10); + +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 self = not stanza.attr.to; + local user = stanza.attr.to or (origin.username..'@'..origin.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 = 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 + end + hash_map[ver] = notify; -- update hash map + if self then + for jid, item in pairs(origin.roster) do -- for all interested contacts + if item.subscription == "both" or item.subscription == "from" then + if not recipients[jid] then recipients[jid] = {}; end + update_subscriptions(contact, jid, notify); + end + end + end + update_subscriptions(contact, user, notify); +end); + +module:hook("account-disco-info-node", function(event) + local reply, stanza, origin = event.reply, event.stanza, event.origin; + local service_name = stanza.attr.to or origin.username.."@"..origin.host + local service = get_pep_service(service_name); + local node = event.node; + local ok = service:get_items(node, jid_bare(stanza.attr.from) or true); + if not ok then return; end + event.exists = true; + reply:tag('identity', {category='pubsub', type='leaf'}):up(); +end); + +module:hook("account-disco-info", function(event) + local reply = 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-node", function(event) + local reply, stanza, origin = event.reply, event.stanza, event.origin; + local node = event.node; + local service_name = stanza.attr.to or origin.username.."@"..origin.host + local service = get_pep_service(service_name); + local ok, ret = service:get_items(node, jid_bare(stanza.attr.from) or true); + if not ok then return; end + event.exists = true; + for _, id in ipairs(ret) do + reply:tag("item", { jid = service_name, name = id }):up(); + end +end); + +module:hook("account-disco-items", function(event) + local reply, stanza, origin = event.reply, event.stanza, event.origin; + + local service_name = reply.attr.from or origin.username.."@"..origin.host + local service = get_pep_service(service_name); + 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 = service_name, node = node, name = node_obj.config.name }):up(); + end +end); diff --git a/plugins/mod_posix.lua b/plugins/mod_posix.lua index 0a6845ac..122f550a 100644 --- a/plugins/mod_posix.lua +++ b/plugins/mod_posix.lua @@ -59,7 +59,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("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 diff --git a/plugins/mod_private.lua b/plugins/mod_private.lua index c01053d5..9375cf80 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 + return true; else -- type == set - if not data then data = {}; end; - if #tag == 0 then - data[key] = nil; - else - data[key] = st.preserialize(tag); + 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_pubsub/pubsub.lib.lua b/plugins/mod_pubsub/pubsub.lib.lua index 1497c21c..805efc1f 100644 --- a/plugins/mod_pubsub/pubsub.lib.lua +++ b/plugins/mod_pubsub/pubsub.lib.lua @@ -31,7 +31,7 @@ local function pubsub_error_reply(stanza, error) end _M.pubsub_error_reply = pubsub_error_reply; -local node_config_form = require"util.dataforms".new { +local node_config_form = dataform { { type = "hidden"; name = "FORM_TYPE"; diff --git a/plugins/mod_s2s/mod_s2s.lua b/plugins/mod_s2s/mod_s2s.lua index 67af6a14..00e816f7 100644 --- a/plugins/mod_s2s/mod_s2s.lua +++ b/plugins/mod_s2s/mod_s2s.lua @@ -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"); @@ -41,6 +42,8 @@ local measure_connections = module:measure("connections", "amount"); local sessions = module:shared("sessions"); +local runner_callbacks = {}; + local log = module._log; module:hook("stats-update", function () @@ -66,6 +69,9 @@ local function bounce_sendq(session, reason) (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]; @@ -153,7 +159,7 @@ 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); @@ -264,11 +270,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 @@ -442,14 +458,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 @@ -524,6 +532,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; @@ -587,6 +604,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]; diff --git a/plugins/mod_storage_sql.lua b/plugins/mod_storage_sql.lua index 819846bb..50d9cd0f 100644 --- a/plugins/mod_storage_sql.lua +++ b/plugins/mod_storage_sql.lua @@ -397,7 +397,7 @@ local function upgrade_table(params, apply_changes) 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/muc/affiliation_notify.lib.lua b/plugins/muc/affiliation_notify.lib.lua new file mode 100644 index 00000000..7403a9ac --- /dev/null +++ b/plugins/muc/affiliation_notify.lib.lua @@ -0,0 +1,64 @@ +-- Prosody IM +-- Copyright (C) 2014 Daurnimator +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +--[[ +Out of courtesy, a MUC service MAY send an out-of-room <message/> +if a user's affiliation changes while the user is not in the room; +the message SHOULD be sent from the room to the user's bare JID, +MAY contain a <body/> element describing the affiliation change, +and MUST contain a status code of 101. +]] + + +local st = require "util.stanza"; + +local function get_affiliation_notify(room) + return room._data.affiliation_notify; +end + +local function set_affiliation_notify(room, affiliation_notify) + affiliation_notify = affiliation_notify and true or nil; + if room._data.affiliation_notify == affiliation_notify then return false; end + room._data.affiliation_notify = affiliation_notify; + return true; +end + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_affiliationnotify"; + type = "boolean"; + label = "Notify users when their affiliation changes when they are not in the room?"; + value = get_affiliation_notify(event.room); + }); +end, 100-11); + +module:hook("muc-config-submitted/muc#roomconfig_affiliationnotify", function(event) + if set_affiliation_notify(event.room, event.value) then + event.status_codes["104"] = true; + end +end); + +module:hook("muc-set-affiliation", function(event) + local room = event.room; + if not event.in_room and get_affiliation_notify(room) then + local body = string.format("Your affiliation in room %s is now %s.", room.jid, event.affiliation); + local stanza = st.message({ + type = "headline"; + from = room.jid; + to = event.jid; + }, body) + :tag("x", {xmlns = "http://jabber.org/protocol/muc#user"}) + :tag("status", {code="101"}):up() + :up(); + room:route_stanza(stanza); + end +end); + +return { + get = get_affiliation_notify; + set = set_affiliation_notify; +}; diff --git a/plugins/muc/description.lib.lua b/plugins/muc/description.lib.lua new file mode 100644 index 00000000..d4bc1a71 --- /dev/null +++ b/plugins/muc/description.lib.lua @@ -0,0 +1,41 @@ +-- 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_form_option(event) + table.insert(event.form, { + name = "muc#roomconfig_roomdesc"; + type = "text-single"; + label = "Description"; + value = get_description(event.room) or ""; + }); +end +module:hook("muc-disco#info", add_form_option); +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..b2fe6216 --- /dev/null +++ b/plugins/muc/hidden.lib.lua @@ -0,0 +1,43 @@ +-- 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_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) + table.insert(event.form, { + name = "muc#roomconfig_publicroom"; + type = "boolean"; + label = "Make Room Publicly Searchable?"; + value = not get_hidden(event.room); + }); +end, 100-5); + +module:hook("muc-config-submitted/muc#roomconfig_publicroom", function(event) + 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..437056ef --- /dev/null +++ b/plugins/muc/history.lib.lua @@ -0,0 +1,170 @@ +-- 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 + +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"; + value = tostring(get_historylength(event.room)); + }); +end, 100-10); + +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); + +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, default_history_length, 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); + +local function send_history(room, stanza) + local maxchars, maxstanzas, since = parse_history(stanza); + local event = { + room = room; + 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 historic = event.stanza:get_child("body"); + if historic then + 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", {xmlns = "urn:xmpp:delay", from = module.host, stamp = stamp}):up(); -- XEP-0203 + stanza:tag("x", {xmlns = "jabber:x:delay", from = module.host, stamp = datetime.legacy()}):up(); -- XEP-0091 (deprecated) + local entry = { stanza = stanza, timestamp = ts }; + table.insert(history, entry); + while #history > get_historylength(room) do table.remove(history, 1) end + 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) + module:fire_event("muc-add-history", event); +end); + +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/lock.lib.lua b/plugins/muc/lock.lib.lua new file mode 100644 index 00000000..15dd624b --- /dev/null +++ b/plugins/muc/lock.lib.lua @@ -0,0 +1,61 @@ +-- 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", false); +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 or false; + if ts then + if ts < os.time() 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 + 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..1e5e6a56 --- /dev/null +++ b/plugins/muc/members_only.lib.lua @@ -0,0 +1,128 @@ +-- 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 + +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = get_members_only(event.room) and "muc_membersonly" or "muc_open"}):up(); +end); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_membersonly"; + type = "boolean"; + label = "Make Room Members-Only?"; + value = get_members_only(event.room); + }); +end, 100-6); + +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); + +-- 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 affiliation = room:get_affiliation(stanza.attr.from); + if valid_affiliations[affiliation or "none"] < valid_affiliations.admin 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); + if valid_affiliations[affiliation or "none"] <= valid_affiliations.none 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(from, invitee, "member", "Invited by " .. from); + room:save(); + end + end +end); + +return { + get = get_members_only; + set = set_members_only; +}; diff --git a/plugins/muc/mod_muc.lua b/plugins/muc/mod_muc.lua index 90b01cc7..12b95b2c 100644 --- a/plugins/muc/mod_muc.lua +++ b/plugins/muc/mod_muc.lua @@ -6,272 +6,340 @@ -- COPYING file in the source package for more information. -- -local array = require "util.array"; - 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. + +local affiliation_notify = module:require "muc/affiliation_notify"; -- luacheck: ignore 211 + +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 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; + +local moderated = module:require "muc/moderated"; +room_mt.get_moderated = moderated.get; +room_mt.set_moderated = moderated.set; + +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 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 = persistent_rooms_storage:get() or {}; -local room_configs = module:open_store("config"); - --- Configurable options -muclib.set_max_history_length(module:get_option_number("max_history_messages")); 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) + if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end + return _set_affiliation(self, actor, jid, affiliation, reason); + 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) - local room = muc_new_room(jid); - room.route_stanza = room_route_stanza; - room.save = room_save; - rooms[jid] = room; - if lock_rooms 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 rooms = cache.new(module:get_option_number("muc_room_cache_size", 100), function (jid, room) + module:log("debug", "%s evicted", jid); + room_save(room, nil, true); -- Force to disk +end); + +-- Automatically destroy empty non-persistent rooms +module:hook("muc-occupant-left",function(event) + local room = event.room + if not room:has_occupant() and not persistent.get(room) then -- empty, non-persistent room + module:fire_event("muc-room-destroyed", { room = room }); end - module:fire_event("muc-room-created", { room = room }); - return room; +end, -1); + +function track_room(room) + rooms:set(room.jid, room); + -- When room is created, over-ride 'save' method + room.save = room_save; end -local persistent_errors = false; -for jid in pairs(persistent_rooms) do +local function restore_room(jid) local node = jid_split(jid); local data = room_configs:get(node); + local state = room_state:get(node); if data then - local room = create_room(jid); - room._data = data._data; - room._affiliations = data._affiliations; - else -- missing room data - persistent_rooms[jid] = nil; - module:log("error", "Missing data for room '%s', removing from persistent room list", jid); - persistent_errors = true; + local room = muclib.restore_room(data, state); + track_room(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; +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 +function delete_room(room) + module:log("debug", "Deleting %s", room.jid); + room_configs: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 rooms:values() 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 + rooms:set(room_jid, room); -- bump to top; + return room; + end + return restore_room(room_jid); +end + +function create_room(room_jid) + local exists = get_room_from_jid(room_jid); + if exists then + return nil, "room-exists"; end - return true; + local room = muclib.new_room(room_jid); + module:fire_event("muc-room-created", { + room = room; + }); + return room; 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 - origin.send(st.error_reply(stanza, "cancel", "item-not-found")); - return true; +function each_room(local_only) + if local_only then + return rooms:values(); + end + return coroutine.wrap(function () + local seen = {}; -- Don't iterate over persistent rooms twice + for room in rooms:values() do + coroutine.yield(room); + seen[room.jid] = true; 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); + for room_jid in pairs(persistent_rooms_storage:get(nil) or {}) do + if not seen[room_jid] then + local room = restore_room(room_jid); + if room == nil then + module:log("error", "Missing data for room '%s', omitting from iteration", room_jid); + else + coroutine.yield(room); + end + end end - 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 + end); +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 each_room() 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-created", function(event) + track_room(event.room); +end, -1000); -local saved = false; -module.save = function() - saved = true; - return {rooms = rooms}; -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; +module:hook("muc-room-destroyed",function(event) + local room = event.room; + forget_room(room); + delete_room(room); +end); + +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")); + 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" ; + -- 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 == 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", "not-allowed")); + return true; + else + return; + end end - room._occupants[nick] = nil; - 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 each_room(true) 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(each_room()):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..8354c585 --- /dev/null +++ b/plugins/muc/moderated.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_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 = "Make Room Moderated?"; + value = get_moderated(event.room); + }); +end, 100-4); + +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 + 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 cc147d19..032817be 100644 --- a/plugins/muc/muc.lib.lua +++ b/plugins/muc/muc.lib.lua @@ -1,66 +1,33 @@ -- 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 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,526 +36,703 @@ 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"]; + }); + 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 + +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 -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); +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:broadcast_message(stanza, historic) - local to = stanza.attr.to; - 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); + +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: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 - stanza.attr.to = to; - if historic then -- add to history - return self:save_to_history(stanza) + + 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 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 = muc_domain, stamp = stamp}):up(); -- XEP-0203 - stanza:tag("x", {xmlns = "jabber:x:delay", from = muc_domain, 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 + +function room_mt:route_to_occupant(occupant, stanza) + local to = stanza.attr.to; + for jid in occupant:each_session() do + stanza.attr.to = jid; + self:route_stanza(stanza); + end + stanza.attr.to = to; 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); - 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: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); +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 + +-- 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 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 + +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 + +-- 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 (pr.attr.type ~= "unavailable" and occupant.role ~= nil) 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 = select(3, jid_split(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; + if can_see_real_jids(whois, occupant) then + self_p, self_x = get_full_p(); + else + -- 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 - if since and since > entry.stamp then break; end - if n + 1 > maxstanzas then break; end - n = n + 1; + self:route_to_occupant(n_occupant, pr); end - for i=#history-n+1,#history do - local msg = history[i].stanza; - msg.attr.to = to; - self:_route_stanza(msg); + end + + -- 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_subject(to) - if self._data['subject'] then - self:_route_stanza(st.message({type='groupchat', from=self._data['subject_from'] or self.jid, to=to}):tag("subject"):text(self._data['subject'])); + +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 + end + 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: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=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), + local reply = st.reply(stanza):query("http://jabber.org/protocol/disco#info"); + local form = dataform.new { + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#roominfo" }; }; - module:fire_event("muc-disco#info", { room = self, reply = reply, form = dataform, formdata = formdata }); - reply:add_child(dataform:form(formdata, 'result')) + local formdata = {}; + module:fire_event("muc-disco#info", {room = self; reply = reply; form = form, formdata = formdata ;}); + reply:add_child(form:form(formdata, "result")); return reply; end +module:hook("muc-disco#info", function(event) + event.reply:tag("feature", {var = "http://jabber.org/protocol/muc"}):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) local reply = st.reply(stanza):query("http://jabber.org/protocol/disco#items"); - for room_jid in pairs(self._occupants) do + for room_jid in self:each_occupant() do reply:tag("item", {jid = room_jid, name = room_jid:match("/(.*)")}):up(); end return reply; 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 error_message = error_message..": "..text; end - return st.presence({type='unavailable', from=stanza.attr.from, to=stanza.attr.to}) - :tag('status'):text(error_message); -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 + occupant:set_session(real_jid, st.presence({type="unavailable"}) + :tag('status'):text(error_message)); + self:save_occupant(occupant); + local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}) + :tag("status", {code = "307"}) + self:publicise_occupant_status(occupant, x); + if occupant.jid == real_jid then -- Was last session + module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;}); end + return true; end -function room_mt:get_name() - return self._data.name or jid_split(self.jid); + +if not module:get_option_boolean("muc_compat_create", true) then + module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + if not stanza:get_child("x", "http://jabber.org/protocol/muc") then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + end + end, -1); 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 + +-- 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_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, -10); + +function room_mt:handle_first_presence(origin, stanza) + 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); + + -- TODO Handle this case sensibly + if not stanza:get_child("x", "http://jabber.org/protocol/muc") then + module:log("debug", "Room creation without <x>, possibly desynced"); 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 + + 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_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 self_x; + if nick_changed then + self_x = st.clone(dest_x); + self_x:tag("status", {code = "210"}):up(); end + self:save_occupant(dest_occupant); + + self:publicise_occupant_status(dest_occupant, {base = dest_x, self = self_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_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 + +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); + if type == "unavailable" and orig_occupant == nil then return true; end -- Unavailable from someone not in the room + local is_first_dest_session; + local dest_occupant; + if type == "unavailable" then -- luacheck: ignore 542 + -- FIXME Why the empty if branch? + -- 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_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 + 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 -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 + + -- TODO Handle these cases sensibly + local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc"); + if orig_occupant == nil and not muc_x then + module:log("debug", "Join without <x>, possibly desynced"); + elseif orig_occupant ~= nil and muc_x then + module:log("debug", "Presence update with <x>, possibly desynced"); 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 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 - self._data.history_length = length; -end + if module:fire_event(event_name, event) then return true; end + local nick_changed = dest_occupant and orig_nick ~= dest_occupant.nick; -local valid_whois = { moderators = true, anyone = true }; + -- 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 -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 + -- 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 = select(3, jid_split(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; publically 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";}); + -- self:build_item_list(orig_occupant, x, false, dest_nick); -- COMPAT: clients get confused if they see other items besides their own + 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 + + self:save_occupant(orig_occupant); + self:publicise_occupant_status(orig_occupant, orig_x, dest_nick); + + 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 -end -function room_mt:get_whois() - return self._data.whois; -end + if dest_occupant ~= nil then + dest_occupant:set_session(real_jid, stanza); + local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}); + if orig_occupant == nil and self:get_whois() == "anyone" then + dest_x:tag("status", {code = "100"}):up(); + end + self:save_occupant(dest_occupant); -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; + if orig_occupant == nil or muc_x then + -- Send occupant list to newly joined 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 self_x; + if nick_changed then + self_x = st.clone(dest_x); + self_x:tag("status", {code="210"}):up(); + end + self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x}); - 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 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 - 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 - 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 - 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 + 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); + if stanza.tags[1].attr.xmlns == 'vcard-temp' 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) @@ -605,361 +749,382 @@ 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 = {};}; + 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("item", { affiliation='none', role='none' }):up() + :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:clear(x); + module:fire_event("muc-room-destroyed", { room = self }); + 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 - 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 not item.attr.nick and item.attr.jid then + local nick = self:get_occupant_jid(item.attr.jid); + if nick then item.attr.nick = select(3, jid_split(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 + success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason); + 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")); - 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 +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 + reply:tag("item", {affiliation = _aff, jid = jid}):up(); 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 = select(3,jid_split(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_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); +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 + + 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 -function room_mt:route_stanza(stanza) end -- Replace with a routing function, e.g., function(room, stanza) core_route_stanza(origin, stanza); 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:tag("body") + :text(decline.attr.from.." declined your invite to the room "..room.jid..(reason == "" and (" ("..reason..")") or "")) + :up(); + 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 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 + end +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); @@ -968,175 +1133,184 @@ function room_mt:get_affiliation(jid) 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) + 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; + + -- 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 - 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; - p:add_child(x); - self:_route_stanza(p); - if occupant.jid == jid then - modified_nicks[nick] = p; - end + 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; + 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 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; - p:add_child(x); - self:_route_stanza(p); - if occupant.jid == jid then - bp = p; + -- 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 - 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) @@ -1144,17 +1318,103 @@ 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 = {}; }, 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; + }; + 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 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); + + if frozen.jid and frozen._affiliations then + room._affiliations = frozen._affiliations; + else + 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..2dcb979a --- /dev/null +++ b/plugins/muc/name.lib.lua @@ -0,0 +1,45 @@ +-- 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 == "" or name == (jid_split(room.jid)) then name = nil; end + if room._data.name == name then return false; end + room._data.name = name; + return true; +end + +module:hook("muc-disco#info", function(event) + event.reply:tag("identity", {category="conference", type="text", name=get_name(event.room)}):up(); +end); + +module:hook("muc-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_roomname"; + type = "text-single"; + label = "Name"; + value = get_name(event.room) or ""; + }); +end, 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..02ecdc1a --- /dev/null +++ b/plugins/muc/password.lib.lua @@ -0,0 +1,70 @@ +-- 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, 100-7); + +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; + local password = stanza:get_child("x", "http://jabber.org/protocol/muc"); + password = password and password: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); + +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..abceafe1 --- /dev/null +++ b/plugins/muc/persistent.lib.lua @@ -0,0 +1,47 @@ +-- 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_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) + table.insert(event.form, { + name = "muc#roomconfig_persistentroom"; + type = "boolean"; + label = "Make Room Persistent?"; + value = get_persistent(event.room); + }); +end, 100-3); + +module:hook("muc-config-submitted/muc#roomconfig_persistentroom", function(event) + 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); + +return { + get = get_persistent; + set = set_persistent; +}; diff --git a/plugins/muc/subject.lib.lua b/plugins/muc/subject.lib.lua new file mode 100644 index 00000000..6f118b43 --- /dev/null +++ b/plugins/muc/subject.lib.lua @@ -0,0 +1,101 @@ +-- 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_roles = muc_util.valid_roles; + +local function create_subject_message(from, subject) + return st.message({from = from; type = "groupchat"}) + :tag("subject"):text(subject):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-config-form", function(event) + table.insert(event.form, { + name = "muc#roomconfig_changesubject"; + type = "boolean"; + label = "Allow Occupants to Change Subject?"; + value = get_changesubject(event.room); + }); +end, 100-8); + +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) + local msg = create_subject_message(get_subject(room)); + msg.attr.to = to; + 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; + 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); +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")); + 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..4acf288c --- /dev/null +++ b/plugins/muc/whois.lib.lua @@ -0,0 +1,65 @@ +-- 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) + event.reply:tag("feature", {var = get_whois(event.room) ~= "anyone" and "muc_semianonymous" or "muc_nonanonymous"}):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 = 'Who May Discover Real JIDs?', + value = { + { value = 'moderators', label = 'Moderators Only', default = whois == 'moderators' }, + { value = 'anyone', label = 'Anyone', default = whois == 'anyone' } + } + }); +end, 100-9); + +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; +}; @@ -111,7 +111,7 @@ function read_config() print("A sample config file is included in the Prosody download called prosody.cfg.lua.dist"); print("Copy or rename it to prosody.cfg.lua and edit as necessary."); end - print("More help on configuring Prosody can be found at http://prosody.im/doc/configure"); + print("More help on configuring Prosody can be found at https://prosody.im/doc/configure"); print("Good luck!"); print("**************************"); print(""); diff --git a/prosody.cfg.lua.dist b/prosody.cfg.lua.dist index d2af75a0..c42e56d0 100644 --- a/prosody.cfg.lua.dist +++ b/prosody.cfg.lua.dist @@ -1,7 +1,7 @@ -- Prosody Example Configuration File -- -- Information on configuring Prosody can be found on our --- website at http://prosody.im/doc/configure +-- website at https://prosody.im/doc/configure -- -- Tip: You can check that the syntax of this file is correct -- when you have finished by running: prosodyctl check config @@ -18,17 +18,17 @@ -- This is a (by default, empty) list of accounts that are admins -- for the server. Note that you must create the accounts separately --- (see http://prosody.im/doc/creating_accounts for info) +-- (see https://prosody.im/doc/creating_accounts for info) -- Example: admins = { "user1@example.com", "user2@example.net" } admins = { } -- Enable use of libevent for better performance under high load --- For more information see: http://prosody.im/doc/libevent +-- For more information see: https://prosody.im/doc/libevent --use_libevent = true -- This is the list of modules Prosody will load on startup. -- It looks for mod_modulename.lua in the plugins folder, so make sure that exists too. --- Documentation on modules can be found at: http://prosody.im/doc/modules +-- Documentation on modules can be found at: https://prosody.im/doc/modules modules_enabled = { -- Generally required @@ -41,7 +41,7 @@ modules_enabled = { -- Not essential, but recommended "private"; -- Private XML storage (for room bookmarks, etc.) "vcard"; -- Allow users to set vCards - + -- These are commented by default as they have a performance impact --"blocklist"; -- Allow users to block communications with other users --"compression"; -- Stream compression @@ -57,7 +57,7 @@ modules_enabled = { -- Admin interfaces "admin_adhoc"; -- Allows administration via an XMPP client that supports ad-hoc commands --"admin_telnet"; -- Opens telnet console interface on localhost port 5582 - + -- HTTP modules --"bosh"; -- Enable BOSH clients, aka "Jabber over HTTP" --"http_files"; -- Serve static files from a directory over HTTP @@ -81,7 +81,7 @@ modules_disabled = { } -- Disable account creation by default, for security --- For more information see http://prosody.im/doc/creating_accounts +-- For more information see https://prosody.im/doc/creating_accounts allow_registration = false -- These are the SSL/TLS-related settings. If you don't want @@ -100,7 +100,7 @@ c2s_require_encryption = true -- This provides ideal security, but requires servers you communicate -- with to support encryption AND present valid, trusted certificates. -- NOTE: Your version of LuaSec must support certificate verification! --- For more information see http://prosody.im/doc/s2s#security +-- For more information see https://prosody.im/doc/s2s#security s2s_secure_auth = false @@ -119,7 +119,7 @@ s2s_secure_auth = false -- use Prosody's configured data storage to store the authentication data. -- To allow Prosody to offer secure authentication mechanisms to clients, the -- default provider stores passwords in plaintext. If you do not trust your --- server please see http://prosody.im/doc/modules/mod_auth_internal_hashed +-- server please see https://prosody.im/doc/modules/mod_auth_internal_hashed -- for information about using the hashed backend. authentication = "internal_plain" @@ -127,7 +127,7 @@ authentication = "internal_plain" -- Select the storage backend to use. By default Prosody uses flat files -- in its configured data directory, but it also supports more backends -- through modules. An "sql" backend is included by default, but requires --- additional dependencies. See http://prosody.im/doc/storage for more info. +-- additional dependencies. See https://prosody.im/doc/storage for more info. --storage = "sql" -- Default is "internal" @@ -137,7 +137,7 @@ authentication = "internal_plain" --sql = { driver = "PostgreSQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" } -- Logging configuration --- For advanced logging see http://prosody.im/doc/logging +-- For advanced logging see https://prosody.im/doc/logging log = { info = "prosody.log"; -- Change 'info' to 'debug' for verbose logging error = "prosody.err"; @@ -166,7 +166,7 @@ VirtualHost "example.com" ------ Components ------ -- You can specify components to add hosts that provide special services, -- like multi-user conferences, and transports. --- For more information on components, see http://prosody.im/doc/components +-- For more information on components, see https://prosody.im/doc/components ---Set up a MUC (multi-user chat) room server on conference.example.com: --Component "conference.example.com" "muc" @@ -178,7 +178,7 @@ VirtualHost "example.com" -- -- External components allow adding various services, such as gateways/ -- transports to other networks like ICQ, MSN and Yahoo. For more info --- see: http://prosody.im/doc/components#adding_an_external_component +-- see: https://prosody.im/doc/components#adding_an_external_component -- --Component "gateway.example.com" -- component_secret = "password" @@ -103,7 +103,7 @@ do print("A sample config file is included in the Prosody download called prosody.cfg.lua.dist"); print("Copy or rename it to prosody.cfg.lua and edit as necessary."); end - print("More help on configuring Prosody can be found at http://prosody.im/doc/configure"); + print("More help on configuring Prosody can be found at https://prosody.im/doc/configure"); print("Good luck!"); print("**************************"); print(""); @@ -168,7 +168,7 @@ if ok and pposix then pposix.setenv("PROSODY_CONFIG", ENV_CONFIG); else print("Error: Unable to load pposix module. Check that Prosody is installed correctly.") - print("For more help send the below error to us through http://prosody.im/discuss"); + print("For more help send the below error to us through https://prosody.im/discuss"); print(tostring(pposix)) os.exit(1); end @@ -219,9 +219,9 @@ local error_messages = setmetatable({ ["no-such-user"] = "The given user does not exist on the server"; ["no-such-host"] = "The given hostname does not exist in the config"; ["unable-to-save-data"] = "Unable to store, perhaps you don't have permission?"; - ["no-pidfile"] = "There is no 'pidfile' option in the configuration file, see http://prosody.im/doc/prosodyctl#pidfile for help"; - ["invalid-pidfile"] = "The 'pidfile' option in the configuration file is not a string, see http://prosody.im/doc/prosodyctl#pidfile for help"; - ["no-posix"] = "The mod_posix module is not enabled in the Prosody config file, see http://prosody.im/doc/prosodyctl for more info"; + ["no-pidfile"] = "There is no 'pidfile' option in the configuration file, see https://prosody.im/doc/prosodyctl#pidfile for help"; + ["invalid-pidfile"] = "The 'pidfile' option in the configuration file is not a string, see https://prosody.im/doc/prosodyctl#pidfile for help"; + ["no-posix"] = "The mod_posix module is not enabled in the Prosody config file, see https://prosody.im/doc/prosodyctl for more info"; ["no-such-method"] = "This module has no commands"; ["not-running"] = "Prosody is not running"; }, { __index = function (t,k) return "Error: "..(tostring(k):gsub("%-", " "):gsub("^.", string.upper)); end }); @@ -879,7 +879,7 @@ function commands.check(arg) ok = false; print(""); print(" No global options defined. Perhaps you have put a host definition at the top") - print(" of the config file? They should be at the bottom, see http://prosody.im/doc/configure#overview"); + print(" of the config file? They should be at the bottom, see https://prosody.im/doc/configure#overview"); end if it.count(enabled_hosts()) == 0 then ok = false; @@ -931,7 +931,7 @@ function commands.check(arg) local n = it.count(misplaced_options); print(" You have "..n.." option"..(n>1 and "s " or " ").."set under "..host.." that should be"); print(" in the global section of the config file, above any VirtualHost or Component definitions,") - print(" see http://prosody.im/doc/configure#overview for more information.") + print(" see https://prosody.im/doc/configure#overview for more information.") print(""); print(" You need to move the following option"..(n>1 and "s" or "")..": "..table.concat(it.to_array(misplaced_options), ", ")); end @@ -941,7 +941,7 @@ function commands.check(arg) print(""); print(" Suggestion: If "..host.. " is a new host with no real users yet, consider renaming it now to"); print(" "..host:gsub("^[^.]+%.", "")..". You can use SRV records to redirect XMPP clients and servers to "..host.."."); - print(" For more information see: http://prosody.im/doc/dns"); + print(" For more information see: https://prosody.im/doc/dns"); end end local all_modules = set.new(config["*"].modules_enabled); @@ -1185,7 +1185,7 @@ function commands.check(arg) end if host_ok_v6 and not v6_supported then print(" Host "..host.." has AAAA records, but your version of LuaSocket does not support IPv6."); - print(" Please see http://prosody.im/doc/ipv6 for more information."); + print(" Please see https://prosody.im/doc/ipv6 for more information."); end end if not all_targets_ok then @@ -1199,7 +1199,7 @@ function commands.check(arg) end if not problem_hosts:empty() then print(""); - print("For more information about DNS configuration please see http://prosody.im/doc/dns"); + print("For more information about DNS configuration please see https://prosody.im/doc/dns"); print(""); ok = false; end @@ -1278,7 +1278,7 @@ function commands.check(arg) end if cert_ok == false then print("") - print("For more information about certificates please see http://prosody.im/doc/certificates"); + print("For more information about certificates please see https://prosody.im/doc/certificates"); ok = false end end diff --git a/tests/test.lua b/tests/test.lua index bc33bb76..ab029cc4 100644 --- a/tests/test.lua +++ b/tests/test.lua @@ -21,6 +21,7 @@ function run_all_tests() dotest "util.json" dotest "util.stanza" dotest "util.sasl.scram" + dotest "util.async" dotest "util.cache" dotest "util.throttle" dotest "util.uuid" diff --git a/tests/test_util_async.lua b/tests/test_util_async.lua new file mode 100644 index 00000000..a0c16b88 --- /dev/null +++ b/tests/test_util_async.lua @@ -0,0 +1,148 @@ + +-- Test passing nil to runner +-- Test runners work correctly after errors (coroutine gets recreated) +-- What happens if an error is thrown, but more items are in the queue? (I think runner might stall) +-- Test errors thrown halfway through a queue +-- Multiple runners + +function runner(new_runner, async) + local function new(func) + local log = {}; + return new_runner(func, setmetatable({}, { + __index = function (_, event) + return function (runner, err) + print("event", event, err) + table.insert(log, { event = event, err = err }); + end; + end; + })), log; + end + + -------------------- + local r, l = new(function (item) assert(type(item) == "number") end); + r:run(1); + r:run(2); + for k, v in ipairs(l) do print(k,v) end + + -------------------- + local wait, done; + + local r, l = new(function (item) + assert(type(item) == "number") + if item == 3 then + wait, done = async.waiter(); + wait(); + end + end); + + r:run(1); + assert(r.state == "ready"); + r:run(2); + assert(r.state == "ready"); + r:run(3); + assert(r.state == "waiting"); + done(); + assert(r.state == "ready"); + for k, v in ipairs(l) do print(k,v) end + + -------------------- + local wait, done; + local last_item = 0; + local r, l = new(function (item) + assert(type(item) == "number") + assert(item == last_item + 1); + last_item = item; + if item == 3 then + wait, done = async.waiter(); + wait(); + end + end); + + r:run(1); + assert(r.state == "ready"); + r:run(2); + assert(r.state == "ready"); + r:run(3); + assert(r.state == "waiting"); + r:run(4); + assert(r.state == "waiting"); + done(); + assert(r.state == "ready"); + for k, v in ipairs(l) do print(k,v) end + + -------------------- + local wait, done; + local last_item = 0; + local r, l = new(function (item) + assert(type(item) == "number") + assert((item == last_item + 1) or item == 3); + last_item = item; + if item == 3 then + wait, done = async.waiter(); + wait(); + end + end); + + r:run(1); + assert(r.state == "ready"); + r:run(2); + assert(r.state == "ready"); + + local dones = {}; + r:run(3); + assert(r.state == "waiting"); + r:run(3); + assert(r.state == "waiting"); + r:run(3); + assert(r.state == "waiting"); + r:run(4); + assert(r.state == "waiting"); + + for i = 1, 3 do + done(); + if i < 3 then + assert(r.state == "waiting"); + end + end + + assert(r.state == "ready"); + for k, v in ipairs(l) do print(k,v) end + + -------------------- + local wait, done; + local last_item = 0; + local r, l = new(function (item) + assert(type(item) == "number") + assert((item == last_item + 1) or item == 3); + last_item = item; + if item == 3 then + wait, done = async.waiter(); + wait(); + end + end); + + r:run(1); + assert(r.state == "ready"); + r:run(2); + assert(r.state == "ready"); + + local dones = {}; + r:run(3); + assert(r.state == "waiting"); + r:run(3); + assert(r.state == "waiting"); + + for i = 1, 2 do + done(); + if i < 2 then + assert(r.state == "waiting"); + end + end + + assert(r.state == "ready"); + r:run(4); + assert(r.state == "ready"); + + assert(r.state == "ready"); + for k, v in ipairs(l) do print(k,v) end +end diff --git a/tests/test_util_xml.lua b/tests/test_util_xml.lua index ba44da19..56d3f7b9 100644 --- a/tests/test_util_xml.lua +++ b/tests/test_util_xml.lua @@ -9,4 +9,5 @@ function parse(parse) ]] local stanza = parse(x); assert_equal(stanza.tags[2].attr.xmlns, "b"); + assert_equal(stanza.tags[2].namespaces["a"], "b"); end diff --git a/tools/migration/migrator/prosody_sql.lua b/tools/migration/migrator/prosody_sql.lua index 180ae910..1872a85d 100644 --- a/tools/migration/migrator/prosody_sql.lua +++ b/tools/migration/migrator/prosody_sql.lua @@ -12,7 +12,7 @@ local tostring = tostring; local tonumber = tonumber; if not have_DBI then - error("LuaDBI (required for SQL support) was not found, please see http://prosody.im/doc/depends#luadbi", 0); + error("LuaDBI (required for SQL support) was not found, please see https://prosody.im/doc/depends#luadbi", 0); end module "prosody_sql" diff --git a/util/async.lua b/util/async.lua new file mode 100644 index 00000000..e8f898d9 --- /dev/null +++ b/util/async.lua @@ -0,0 +1,173 @@ +local log = require "util.logger".init("util.async"); + +local function runner_continue(thread) + -- ASSUMPTION: runner is in 'waiting' state (but we don't have the runner to know for sure) + if coroutine.status(thread) ~= "suspended" then -- This should suffice + return false; + end + local ok, state, runner = coroutine.resume(thread); + if not ok then + -- Running the coroutine failed, which means we have to find the runner manually, + -- in order to inform the error handler + local level = 0; + while debug.getinfo(thread, level, "") do level = level + 1; end + ok, runner = debug.getlocal(thread, level-1, 1); + local error_handler = runner.watchers.error; + if error_handler then error_handler(runner, debug.traceback(thread, state)); end + elseif state == "ready" then + -- If state is 'ready', it is our responsibility to update runner.state from 'waiting'. + -- We also have to :run(), because the queue might have further items that will not be + -- processed otherwise. FIXME: It's probably best to do this in a nexttick (0 timer). + runner.state = "ready"; + runner:run(); + end + return true; +end + +local function waiter(num) + local thread = coroutine.running(); + if not thread then + error("Not running in an async context, see https://prosody.im/doc/developers/async"); + end + num = num or 1; + local waiting; + return function () + if num == 0 then return; end -- already done + waiting = true; + coroutine.yield("wait"); + end, function () + num = num - 1; + if num == 0 and waiting then + runner_continue(thread); + elseif num < 0 then + error("done() called too many times"); + end + end; +end + +local function guarder() + local guards = {}; + return function (id, func) + local thread = coroutine.running(); + if not thread then + error("Not running in an async context, see https://prosody.im/doc/developers/async"); + end + local guard = guards[id]; + if not guard then + guard = {}; + guards[id] = guard; + log("debug", "New guard!"); + else + table.insert(guard, thread); + log("debug", "Guarded. %d threads waiting.", #guard) + coroutine.yield("wait"); + end + local function exit() + local next_waiting = table.remove(guard, 1); + if next_waiting then + log("debug", "guard: Executing next waiting thread (%d left)", #guard) + runner_continue(next_waiting); + else + log("debug", "Guard off duty.") + guards[id] = nil; + end + end + if func then + func(); + exit(); + return; + end + return exit; + end; +end + +local runner_mt = {}; +runner_mt.__index = runner_mt; + +local function runner_create_thread(func, self) + local thread = coroutine.create(function (self) -- luacheck: ignore 432/self + while true do + func(coroutine.yield("ready", self)); + end + end); + assert(coroutine.resume(thread, self)); -- Start it up, it will return instantly to wait for the first input + return thread; +end + +local empty_watchers = {}; +local function runner(func, watchers, data) + return setmetatable({ func = func, thread = false, state = "ready", notified_state = "ready", + queue = {}, watchers = watchers or empty_watchers, data = data } + , runner_mt); +end + +-- Add a task item for the runner to process +function runner_mt:run(input) + if input ~= nil then + table.insert(self.queue, input); + end + if self.state ~= "ready" then + -- The runner is busy. Indicate that the task item has been + -- queued, and return information about the current runner state + return true, self.state, #self.queue; + end + + local q, thread = self.queue, self.thread; + if not thread or coroutine.status(thread) == "dead" then + -- Create a new coroutine for this runner + thread = runner_create_thread(self.func, self); + self.thread = thread; + end + + -- Process task item(s) while the queue is not empty, and we're not blocked + local n, state, err = #q, self.state, nil; + self.state = "running"; + while n > 0 and state == "ready" do + local consumed; + -- Loop through queue items, and attempt to run them + for i = 1,n do + local queued_input = q[i]; + local ok, new_state = coroutine.resume(thread, queued_input); + if not ok then + -- There was an error running the coroutine, save the error, mark runner as ready to begin again + consumed, state, err = i, "ready", debug.traceback(thread, new_state); + self.thread = nil; + break; + elseif new_state == "wait" then + -- Runner is blocked on waiting for a task item to complete + consumed, state = i, "waiting"; + break; + end + end + -- Loop ended - either queue empty because all tasks passed without blocking (consumed == nil) + -- or runner is blocked/errored, and consumed will contain the number of tasks processed so far + if not consumed then consumed = n; end + -- Remove consumed items from the queue array + if q[n+1] ~= nil then + n = #q; + end + for i = 1, n do + q[i] = q[consumed+i]; + end + n = #q; + end + -- Runner processed all items it can, so save current runner state + self.state = state; + if err or state ~= self.notified_state then + if err then + state = "error" + else + self.notified_state = state; + end + local handler = self.watchers[state]; + if handler then handler(self, err); end + end + return true, state, n; +end + +-- Add a task item to the queue without invoking the runner, even if it is idle +function runner_mt:enqueue(input) + table.insert(self.queue, input); +end + +return { waiter = waiter, guarder = guarder, runner = runner }; diff --git a/util/dependencies.lua b/util/dependencies.lua index d2024c4f..11cd9486 100644 --- a/util/dependencies.lua +++ b/util/dependencies.lua @@ -28,7 +28,7 @@ local function missingdep(name, sources, msg) end print(""); print(msg or (name.." is required for Prosody to run, so we will now exit.")); - print("More help can be found on our website, at http://prosody.im/doc/depends"); + print("More help can be found on our website, at https://prosody.im/doc/depends"); print("**************************"); print(""); end @@ -40,7 +40,7 @@ end package.preload["util.ztact"] = function () if not package.loaded["core.loggingmanager"] then error("util.ztact has been removed from Prosody and you need to fix your config " - .."file. More information can be found at http://prosody.im/doc/packagers#ztact", 0); + .."file. More information can be found at https://prosody.im/doc/packagers#ztact", 0); else error("module 'util.ztact' has been deprecated in Prosody 0.8."); end @@ -93,7 +93,7 @@ local function check_dependencies() if not ssl then missingdep("LuaSec", { - ["Debian/Ubuntu"] = "http://prosody.im/download/start#debian_and_ubuntu"; + ["Debian/Ubuntu"] = "https://prosody.im/download/start#debian_and_ubuntu"; ["luarocks"] = "luarocks install luasec"; ["Source"] = "https://github.com/brunoos/luasec"; }, "SSL/TLS support will not be available"); @@ -154,7 +154,7 @@ local function log_warnings() if ssl then local major, minor, veryminor, patched = ssl._VERSION:match("(%d+)%.(%d+)%.?(%d*)(M?)"); if not major or ((tonumber(major) == 0 and (tonumber(minor) or 0) <= 3 and (tonumber(veryminor) or 0) <= 2) and patched ~= "M") then - prosody.log("error", "This version of LuaSec contains a known bug that causes disconnects, see http://prosody.im/doc/depends"); + prosody.log("error", "This version of LuaSec contains a known bug that causes disconnects, see https://prosody.im/doc/depends"); end end local lxp = softreq"lxp"; @@ -163,7 +163,7 @@ local function log_warnings() prosody.log("error", "The version of LuaExpat on your system leaves Prosody " .."vulnerable to denial-of-service attacks. You should upgrade to " .."LuaExpat 1.3.0 or higher as soon as possible. See " - .."http://prosody.im/doc/depends#luaexpat for more information."); + .."https://prosody.im/doc/depends#luaexpat for more information."); end if not lxp.new({}).getcurrentbytecount then prosody.log("error", "The version of LuaExpat on your system does not support " @@ -171,7 +171,7 @@ local function log_warnings() .."networks (e.g. the internet) vulnerable to denial-of-service " .."attacks. You should upgrade to LuaExpat 1.3.0 or higher as " .."soon as possible. See " - .."http://prosody.im/doc/depends#luaexpat for more information."); + .."https://prosody.im/doc/depends#luaexpat for more information."); end end end diff --git a/util/indexedbheap.lua b/util/indexedbheap.lua new file mode 100644 index 00000000..c60861e8 --- /dev/null +++ b/util/indexedbheap.lua @@ -0,0 +1,157 @@ + +local setmetatable = setmetatable; +local math_floor = math.floor; +local t_remove = table.remove; + +local function _heap_insert(self, item, sync, item2, index) + local pos = #self + 1; + while true do + local half_pos = math_floor(pos / 2); + if half_pos == 0 or item > self[half_pos] then break; end + self[pos] = self[half_pos]; + sync[pos] = sync[half_pos]; + index[sync[pos]] = pos; + pos = half_pos; + end + self[pos] = item; + sync[pos] = item2; + index[item2] = pos; +end + +local function _percolate_up(self, k, sync, index) + local tmp = self[k]; + local tmp_sync = sync[k]; + while k ~= 1 do + local parent = math_floor(k/2); + if tmp < self[parent] then break; end + self[k] = self[parent]; + sync[k] = sync[parent]; + index[sync[k]] = k; + k = parent; + end + self[k] = tmp; + sync[k] = tmp_sync; + index[tmp_sync] = k; + return k; +end + +local function _percolate_down(self, k, sync, index) + local tmp = self[k]; + local tmp_sync = sync[k]; + local size = #self; + local child = 2*k; + while 2*k <= size do + if child ~= size and self[child] > self[child + 1] then + child = child + 1; + end + if tmp > self[child] then + self[k] = self[child]; + sync[k] = sync[child]; + index[sync[k]] = k; + else + break; + end + + k = child; + child = 2*k; + end + self[k] = tmp; + sync[k] = tmp_sync; + index[tmp_sync] = k; + return k; +end + +local function _heap_pop(self, sync, index) + local size = #self; + if size == 0 then return nil; end + + local result = self[1]; + local result_sync = sync[1]; + index[result_sync] = nil; + if size == 1 then + self[1] = nil; + sync[1] = nil; + return result, result_sync; + end + self[1] = t_remove(self); + sync[1] = t_remove(sync); + index[sync[1]] = 1; + + _percolate_down(self, 1, sync, index); + + return result, result_sync; +end + +local indexed_heap = {}; + +function indexed_heap:insert(item, priority, id) + if id == nil then + id = self.current_id; + self.current_id = id + 1; + end + self.items[id] = item; + _heap_insert(self.priorities, priority, self.ids, id, self.index); + return id; +end +function indexed_heap:pop() + local priority, id = _heap_pop(self.priorities, self.ids, self.index); + if id then + local item = self.items[id]; + self.items[id] = nil; + return priority, item, id; + end +end +function indexed_heap:peek() + return self.priorities[1]; +end +function indexed_heap:reprioritize(id, priority) + local k = self.index[id]; + if k == nil then return; end + self.priorities[k] = priority; + + k = _percolate_up(self.priorities, k, self.ids, self.index); + k = _percolate_down(self.priorities, k, self.ids, self.index); +end +function indexed_heap:remove_index(k) + local result = self.priorities[k]; + if result == nil then return; end + + local result_sync = self.ids[k]; + local item = self.items[result_sync]; + local size = #self.priorities; + + self.priorities[k] = self.priorities[size]; + self.ids[k] = self.ids[size]; + self.index[self.ids[k]] = k; + + t_remove(self.priorities); + t_remove(self.ids); + + self.index[result_sync] = nil; + self.items[result_sync] = nil; + + if size > k then + k = _percolate_up(self.priorities, k, self.ids, self.index); + k = _percolate_down(self.priorities, k, self.ids, self.index); + end + + return result, item, result_sync; +end +function indexed_heap:remove(id) + return self:remove_index(self.index[id]); +end + +local mt = { __index = indexed_heap }; + +local _M = { + create = function() + return setmetatable({ + ids = {}; -- heap of ids, sync'd with priorities + items = {}; -- map id->items + priorities = {}; -- heap of priorities + index = {}; -- map of id->index of id in ids + current_id = 1.5 + }, mt); + end +}; +return _M; diff --git a/util/stanza.lua b/util/stanza.lua index 2191fa8e..651c1973 100644 --- a/util/stanza.lua +++ b/util/stanza.lua @@ -41,8 +41,8 @@ local _ENV = nil; local stanza_mt = { __type = "stanza" }; stanza_mt.__index = stanza_mt; -local function new_stanza(name, attr) - local stanza = { name = name, attr = attr or {}, tags = {} }; +local function new_stanza(name, attr, namespaces) + local stanza = { name = name, attr = attr or {}, namespaces = namespaces, tags = {} }; return setmetatable(stanza, stanza_mt); end @@ -58,8 +58,8 @@ function stanza_mt:body(text, attr) return self:tag("body", attr):text(text); end -function stanza_mt:tag(name, attrs) - local s = new_stanza(name, attrs); +function stanza_mt:tag(name, attr, namespaces) + local s = new_stanza(name, attr, namespaces); local last_add = self.last_add; if not last_add then last_add = {}; self.last_add = last_add; end (last_add[#last_add] or self):add_direct_child(s); @@ -337,7 +337,12 @@ end local function clone(stanza) local attr, tags = {}, {}; for k,v in pairs(stanza.attr) do attr[k] = v; end - local new = { name = stanza.name, attr = attr, tags = tags }; + local old_namespaces, namespaces = stanza.namespaces; + if old_namespaces then + namespaces = {}; + for k,v in pairs(old_namespaces) do namespaces[k] = v; end + end + local new = { name = stanza.name, attr = attr, namespaces = namespaces, tags = tags }; for i=1,#stanza do local child = stanza[i]; if child.name then diff --git a/util/timer.lua b/util/timer.lua index 3713625d..2277ee77 100644 --- a/util/timer.lua +++ b/util/timer.lua @@ -6,78 +6,104 @@ -- COPYING file in the source package for more information. -- +local indexedbheap = require "util.indexedbheap"; +local log = require "util.logger".init("timer"); local server = require "net.server"; -local math_min = math.min -local math_huge = math.huge local get_time = require "socket".gettime; -local t_insert = table.insert; -local pairs = pairs; local type = type; - -local data = {}; -local new_data = {}; +local debug_traceback = debug.traceback; +local tostring = tostring; +local xpcall = xpcall; local _ENV = nil; -local _add_task; -if not server.event then - function _add_task(delay, callback) - local current_time = get_time(); - delay = delay + current_time; - if delay >= current_time then - t_insert(new_data, {delay, callback}); - else - local r = callback(current_time); - if r and type(r) == "number" then - return _add_task(r, callback); - end +local _add_task = server.add_task; + +local _server_timer; +local _active_timers = 0; +local h = indexedbheap.create(); +local params = {}; +local next_time = nil; +local _id, _callback, _now, _param; +local function _call() return _callback(_now, _id, _param); end +local function _traceback_handler(err) log("error", "Traceback[timer]: %s", debug_traceback(tostring(err), 2)); end +local function _on_timer(now) + local peek; + while true do + peek = h:peek(); + if peek == nil or peek > now then break; end + local _; + _, _callback, _id = h:pop(); + _now = now; + _param = params[_id]; + params[_id] = nil; + --item(now, id, _param); -- FIXME pcall + local success, err = xpcall(_call, _traceback_handler); + if success and type(err) == "number" then + h:insert(_callback, err + now, _id); -- re-add + params[_id] = _param; end end - server._addtimer(function() - local current_time = get_time(); - if #new_data > 0 then - for _, d in pairs(new_data) do - t_insert(data, d); - end - new_data = {}; - end + if peek ~= nil and _active_timers > 1 and peek == next_time then + -- Another instance of _on_timer already set next_time to the same value, + -- so it should be safe to not renew this timer event + peek = nil; + else + next_time = peek; + end - local next_time = math_huge; - for i, d in pairs(data) do - local t, callback = d[1], d[2]; - if t <= current_time then - data[i] = nil; - local r = callback(current_time); - if type(r) == "number" then - _add_task(r, callback); - next_time = math_min(next_time, r); - end - else - next_time = math_min(next_time, t - current_time); - end - end - return next_time; - end); -else - local event = server.event; - local event_base = server.event_base; - local EVENT_LEAVE = (event.core and event.core.LEAVE) or -1; + if peek then + -- peek is the time of the next event + return peek - now; + end + _active_timers = _active_timers - 1; +end +local function add_task(delay, callback, param) + local current_time = get_time(); + local event_time = current_time + delay; - function _add_task(delay, callback) - local event_handle; - event_handle = event_base:addevent(nil, 0, function () - local ret = callback(get_time()); - if ret then - return 0, ret; - elseif event_handle then - return EVENT_LEAVE; - end + local id = h:insert(callback, event_time); + params[id] = param; + if next_time == nil or event_time < next_time then + next_time = event_time; + if _server_timer then + _server_timer:close(); + _server_timer = nil; + else + _active_timers = _active_timers + 1; + end + _server_timer = _add_task(next_time - current_time, _on_timer); + end + return id; +end +local function stop(id) + params[id] = nil; + local result, item, result_sync = h:remove(id); + local peek = h:peek(); + if peek ~= next_time and _server_timer then + next_time = peek; + _server_timer:close(); + if next_time ~= nil then + _server_timer = _add_task(next_time - get_time(), _on_timer); end - , delay); end + return result, item, result_sync; +end +local function reschedule(id, delay) + local current_time = get_time(); + local event_time = current_time + delay; + h:reprioritize(id, delay); + if next_time == nil or event_time < next_time then + next_time = event_time; + _add_task(next_time - current_time, _on_timer); + end + return id; end return { - add_task = _add_task; + add_task = add_task; + stop = stop; + reschedule = reschedule; }; + diff --git a/util/vcard.lua b/util/vcard.lua new file mode 100644 index 00000000..8aafa24d --- /dev/null +++ b/util/vcard.lua @@ -0,0 +1,582 @@ +-- Copyright (C) 2011-2014 Kim Alvefur +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +-- TODO +-- Fix folding. + +local st = require "util.stanza"; +local t_insert, t_concat = table.insert, table.concat; +local type = type; +local next, pairs, ipairs = next, pairs, ipairs; + +local from_text, to_text, from_xep54, to_xep54; + +local line_sep = "\n"; + +local vCard_dtd; -- See end of file +local vCard4_dtd; + +local function fold_line() + error "Not implemented" --TODO +end +local function unfold_line() + error "Not implemented" + -- gsub("\r?\n[ \t]([^\r\n])", "%1"); +end + +local function vCard_esc(s) + return s:gsub("[,:;\\]", "\\%1"):gsub("\n","\\n"); +end + +local function vCard_unesc(s) + return s:gsub("\\?[\\nt:;,]", { + ["\\\\"] = "\\", + ["\\n"] = "\n", + ["\\r"] = "\r", + ["\\t"] = "\t", + ["\\:"] = ":", -- FIXME Shouldn't need to espace : in values, just params + ["\\;"] = ";", + ["\\,"] = ",", + [":"] = "\29", + [";"] = "\30", + [","] = "\31", + }); +end + +local function item_to_xep54(item) + local t = st.stanza(item.name, { xmlns = "vcard-temp" }); + + local prop_def = vCard_dtd[item.name]; + if prop_def == "text" then + t:text(item[1]); + elseif type(prop_def) == "table" then + if prop_def.types and item.TYPE then + if type(item.TYPE) == "table" then + for _,v in pairs(prop_def.types) do + for _,typ in pairs(item.TYPE) do + if typ:upper() == v then + t:tag(v):up(); + break; + end + end + end + else + t:tag(item.TYPE:upper()):up(); + end + end + + if prop_def.props then + for _,v in pairs(prop_def.props) do + if item[v] then + t:tag(v):up(); + end + end + end + + if prop_def.value then + t:tag(prop_def.value):text(item[1]):up(); + elseif prop_def.values then + local prop_def_values = prop_def.values; + local repeat_last = prop_def_values.behaviour == "repeat-last" and prop_def_values[#prop_def_values]; + for i=1,#item do + t:tag(prop_def.values[i] or repeat_last):text(item[i]):up(); + end + end + end + + return t; +end + +local function vcard_to_xep54(vCard) + local t = st.stanza("vCard", { xmlns = "vcard-temp" }); + for i=1,#vCard do + t:add_child(item_to_xep54(vCard[i])); + end + return t; +end + +function to_xep54(vCards) + if not vCards[1] or vCards[1].name then + return vcard_to_xep54(vCards) + else + local t = st.stanza("xCard", { xmlns = "vcard-temp" }); + for i=1,#vCards do + t:add_child(vcard_to_xep54(vCards[i])); + end + return t; + end +end + +function from_text(data) + data = data -- unfold and remove empty lines + :gsub("\r\n","\n") + :gsub("\n ", "") + :gsub("\n\n+","\n"); + local vCards = {}; + local c; -- current item + for line in data:gmatch("[^\n]+") do + local line = vCard_unesc(line); + local name, params, value = line:match("^([-%a]+)(\30?[^\29]*)\29(.*)$"); + value = value:gsub("\29",":"); + if #params > 0 then + local _params = {}; + for k,isval,v in params:gmatch("\30([^=]+)(=?)([^\30]*)") do + k = k:upper(); + local _vt = {}; + for _p in v:gmatch("[^\31]+") do + _vt[#_vt+1]=_p + _vt[_p]=true; + end + if isval == "=" then + _params[k]=_vt; + else + _params[k]=true; + end + end + params = _params; + end + if name == "BEGIN" and value == "VCARD" then + c = {}; + vCards[#vCards+1] = c; + elseif name == "END" and value == "VCARD" then + c = nil; + elseif c and vCard_dtd[name] then + local dtd = vCard_dtd[name]; + local p = { name = name }; + c[#c+1]=p; + --c[name]=p; + local up = c; + c = p; + if dtd.types then + for _, t in ipairs(dtd.types) do + local t = t:lower(); + if ( params.TYPE and params.TYPE[t] == true) + or params[t] == true then + c.TYPE=t; + end + end + end + if dtd.props then + for _, p in ipairs(dtd.props) do + if params[p] then + if params[p] == true then + c[p]=true; + else + for _, prop in ipairs(params[p]) do + c[p]=prop; + end + end + end + end + end + if dtd == "text" or dtd.value then + t_insert(c, value); + elseif dtd.values then + local value = "\30"..value; + for p in value:gmatch("\30([^\30]*)") do + t_insert(c, p); + end + end + c = up; + end + end + return vCards; +end + +local function item_to_text(item) + local value = {}; + for i=1,#item do + value[i] = vCard_esc(item[i]); + end + value = t_concat(value, ";"); + + local params = ""; + for k,v in pairs(item) do + if type(k) == "string" and k ~= "name" then + params = params .. (";%s=%s"):format(k, type(v) == "table" and t_concat(v,",") or v); + end + end + + return ("%s%s:%s"):format(item.name, params, value) +end + +local function vcard_to_text(vcard) + local t={}; + t_insert(t, "BEGIN:VCARD") + for i=1,#vcard do + t_insert(t, item_to_text(vcard[i])); + end + t_insert(t, "END:VCARD") + return t_concat(t, line_sep); +end + +function to_text(vCards) + if vCards[1] and vCards[1].name then + return vcard_to_text(vCards) + else + local t = {}; + for i=1,#vCards do + t[i]=vcard_to_text(vCards[i]); + end + return t_concat(t, line_sep); + end +end + +local function from_xep54_item(item) + local prop_name = item.name; + local prop_def = vCard_dtd[prop_name]; + + local prop = { name = prop_name }; + + if prop_def == "text" then + prop[1] = item:get_text(); + elseif type(prop_def) == "table" then + if prop_def.value then --single item + prop[1] = item:get_child_text(prop_def.value) or ""; + elseif prop_def.values then --array + local value_names = prop_def.values; + if value_names.behaviour == "repeat-last" then + for i=1,#item.tags do + t_insert(prop, item.tags[i]:get_text() or ""); + end + else + for i=1,#value_names do + t_insert(prop, item:get_child_text(value_names[i]) or ""); + end + end + elseif prop_def.names then + local names = prop_def.names; + for i=1,#names do + if item:get_child(names[i]) then + prop[1] = names[i]; + break; + end + end + end + + if prop_def.props_verbatim then + for k,v in pairs(prop_def.props_verbatim) do + prop[k] = v; + end + end + + if prop_def.types then + local types = prop_def.types; + prop.TYPE = {}; + for i=1,#types do + if item:get_child(types[i]) then + t_insert(prop.TYPE, types[i]:lower()); + end + end + if #prop.TYPE == 0 then + prop.TYPE = nil; + end + end + + -- A key-value pair, within a key-value pair? + if prop_def.props then + local params = prop_def.props; + for i=1,#params do + local name = params[i] + local data = item:get_child_text(name); + if data then + prop[name] = prop[name] or {}; + t_insert(prop[name], data); + end + end + end + else + return nil + end + + return prop; +end + +local function from_xep54_vCard(vCard) + local tags = vCard.tags; + local t = {}; + for i=1,#tags do + t_insert(t, from_xep54_item(tags[i])); + end + return t +end + +function from_xep54(vCard) + if vCard.attr.xmlns ~= "vcard-temp" then + return nil, "wrong-xmlns"; + end + if vCard.name == "xCard" then -- A collection of vCards + local t = {}; + local vCards = vCard.tags; + for i=1,#vCards do + t[i] = from_xep54_vCard(vCards[i]); + end + return t + elseif vCard.name == "vCard" then -- A single vCard + return from_xep54_vCard(vCard) + end +end + +local vcard4 = { } + +function vcard4:text(node, params, value) + self:tag(node:lower()) + -- FIXME params + if type(value) == "string" then + self:tag("text"):text(value):up() + elseif vcard4[node] then + vcard4[node](value); + end + self:up(); +end + +function vcard4.N(value) + for i, k in ipairs(vCard_dtd.N.values) do + value:tag(k):text(value[i]):up(); + end +end + +local xmlns_vcard4 = "urn:ietf:params:xml:ns:vcard-4.0" + +local function item_to_vcard4(item) + local typ = item.name:lower(); + local t = st.stanza(typ, { xmlns = xmlns_vcard4 }); + + local prop_def = vCard4_dtd[typ]; + if prop_def == "text" then + t:tag("text"):text(item[1]):up(); + elseif prop_def == "uri" then + if item.ENCODING and item.ENCODING[1] == 'b' then + t:tag("uri"):text("data:;base64,"):text(item[1]):up(); + else + t:tag("uri"):text(item[1]):up(); + end + elseif type(prop_def) == "table" then + if prop_def.values then + for i, v in ipairs(prop_def.values) do + t:tag(v:lower()):text(item[i] or ""):up(); + end + else + t:tag("unsupported",{xmlns="http://zash.se/protocol/vcardlib"}) + end + else + t:tag("unsupported",{xmlns="http://zash.se/protocol/vcardlib"}) + end + return t; +end + +local function vcard_to_vcard4xml(vCard) + local t = st.stanza("vcard", { xmlns = xmlns_vcard4 }); + for i=1,#vCard do + t:add_child(item_to_vcard4(vCard[i])); + end + return t; +end + +local function vcards_to_vcard4xml(vCards) + if not vCards[1] or vCards[1].name then + return vcard_to_vcard4xml(vCards) + else + local t = st.stanza("vcards", { xmlns = xmlns_vcard4 }); + for i=1,#vCards do + t:add_child(vcard_to_vcard4xml(vCards[i])); + end + return t; + end +end + +-- This was adapted from http://xmpp.org/extensions/xep-0054.html#dtd +vCard_dtd = { + VERSION = "text", --MUST be 3.0, so parsing is redundant + FN = "text", + N = { + values = { + "FAMILY", + "GIVEN", + "MIDDLE", + "PREFIX", + "SUFFIX", + }, + }, + NICKNAME = "text", + PHOTO = { + props_verbatim = { ENCODING = { "b" } }, + props = { "TYPE" }, + value = "BINVAL", --{ "EXTVAL", }, + }, + BDAY = "text", + ADR = { + types = { + "HOME", + "WORK", + "POSTAL", + "PARCEL", + "DOM", + "INTL", + "PREF", + }, + values = { + "POBOX", + "EXTADD", + "STREET", + "LOCALITY", + "REGION", + "PCODE", + "CTRY", + } + }, + LABEL = { + types = { + "HOME", + "WORK", + "POSTAL", + "PARCEL", + "DOM", + "INTL", + "PREF", + }, + value = "LINE", + }, + TEL = { + types = { + "HOME", + "WORK", + "VOICE", + "FAX", + "PAGER", + "MSG", + "CELL", + "VIDEO", + "BBS", + "MODEM", + "ISDN", + "PCS", + "PREF", + }, + value = "NUMBER", + }, + EMAIL = { + types = { + "HOME", + "WORK", + "INTERNET", + "PREF", + "X400", + }, + value = "USERID", + }, + JABBERID = "text", + MAILER = "text", + TZ = "text", + GEO = { + values = { + "LAT", + "LON", + }, + }, + TITLE = "text", + ROLE = "text", + LOGO = "copy of PHOTO", + AGENT = "text", + ORG = { + values = { + behaviour = "repeat-last", + "ORGNAME", + "ORGUNIT", + } + }, + CATEGORIES = { + values = "KEYWORD", + }, + NOTE = "text", + PRODID = "text", + REV = "text", + SORTSTRING = "text", + SOUND = "copy of PHOTO", + UID = "text", + URL = "text", + CLASS = { + names = { -- The item.name is the value if it's one of these. + "PUBLIC", + "PRIVATE", + "CONFIDENTIAL", + }, + }, + KEY = { + props = { "TYPE" }, + value = "CRED", + }, + DESC = "text", +}; +vCard_dtd.LOGO = vCard_dtd.PHOTO; +vCard_dtd.SOUND = vCard_dtd.PHOTO; + +vCard4_dtd = { + source = "uri", + kind = "text", + xml = "text", + fn = "text", + n = { + values = { + "family", + "given", + "middle", + "prefix", + "suffix", + }, + }, + nickname = "text", + photo = "uri", + bday = "date-and-or-time", + anniversary = "date-and-or-time", + gender = "text", + adr = { + values = { + "pobox", + "ext", + "street", + "locality", + "region", + "code", + "country", + } + }, + tel = "text", + email = "text", + impp = "uri", + lang = "language-tag", + tz = "text", + geo = "uri", + title = "text", + role = "text", + logo = "uri", + org = "text", + member = "uri", + related = "uri", + categories = "text", + note = "text", + prodid = "text", + rev = "timestamp", + sound = "uri", + uid = "uri", + clientpidmap = "number, uuid", + url = "uri", + version = "text", + key = "uri", + fburl = "uri", + caladruri = "uri", + caluri = "uri", +}; + +return { + from_text = from_text; + to_text = to_text; + + from_xep54 = from_xep54; + to_xep54 = to_xep54; + + to_vcard4 = vcards_to_vcard4xml; +}; diff --git a/util/xml.lua b/util/xml.lua index 733d821a..ec06fb01 100644 --- a/util/xml.lua +++ b/util/xml.lua @@ -1,6 +1,8 @@ local st = require "util.stanza"; local lxp = require "lxp"; +local t_insert = table.insert; +local t_remove = table.remove; local _ENV = nil; @@ -14,6 +16,21 @@ local parse_xml = (function() --luacheck: ignore 212/self local handler = {}; local stanza = st.stanza("root"); + local namespaces = {}; + local prefixes = {}; + function handler:StartNamespaceDecl(prefix, url) + if prefix ~= nil then + t_insert(namespaces, url); + t_insert(prefixes, prefix); + end + end + function handler:EndNamespaceDecl(prefix) + if prefix ~= nil then + -- we depend on each StartNamespaceDecl having a paired EndNamespaceDecl + t_remove(namespaces); + t_remove(prefixes); + end + end function handler:StartElement(tagname, attr) local curr_ns,name = tagname:match(ns_pattern); if name == "" then @@ -34,7 +51,11 @@ local parse_xml = (function() end end end - stanza:tag(name, attr); + local n = {} + for i=1,#namespaces do + n[prefixes[i]] = namespaces[i]; + end + stanza:tag(name, attr, n); end function handler:CharacterData(data) stanza:text(data); |