Mercurial > prosody-hg
changeset 14210:41fa97090264
Merge 13.0->trunk
| author | Kim Alvefur <zash@zash.se> |
|---|---|
| date | Sat, 30 May 2026 19:22:51 +0200 |
| parents | 2b13c24ba2d6 (diff) c4aa9b2b4787 (current diff) |
| children | a8ce6f45ba35 |
| files | net/unbound.lua |
| diffstat | 94 files changed, 2154 insertions(+), 611 deletions(-) [+] |
line wrap: on
line diff
--- a/CHANGES Sat May 30 19:21:08 2026 +0200 +++ b/CHANGES Sat May 30 19:22:51 2026 +0200 @@ -1,6 +1,32 @@ TRUNK ===== +## New + +- Support for Lua 5.5 +- Signals outgoing s2s connection failure by closing existing incoming s2s +- Server-to-server connection attempt retry blocking +- DNS resolver connection filters +- pubsub: support for retrieve-affiliitations +- HTTP errors may be returned as JSON if Accept-ed +- mod_http_file_share warns about missing or unexpected files in storage +- mod_offline can now optionally expire old messages (like mod_mam) +- mod_muc_mam honours `archive_expires_after` if `muc_log_expires_after` not set + +### Shell + +- Tables printed as tab-separated values by `prosodyctl shell` when piped to commands +- `prosodyctl shell --quiet` suppresses status message +- `config:dump()` command showing the currently applied config +- `debug:ping()` command shows a slice of debug logs while pinging a JID + +## API + +- `api:context()` with an unknown host no longer works +- `request` is now included in `http-error` events +- mod_mam has a new `archive-query-result` event +- stanza meta api + 13.0.0 ======
--- a/GNUmakefile Sat May 30 19:21:08 2026 +0200 +++ b/GNUmakefile Sat May 30 19:22:51 2026 +0200 @@ -141,9 +141,10 @@ vpath %.tl teal-src/prosody %.lua: %.tl tl -I teal-src/ --gen-compat off --gen-target 5.1 gen $^ -o $@ - -lua-format -i $@ + -lua-format -i --no-keep-simple-control-block-one-line --no-keep-simple-function-one-line $@ + sed -i "1i-- This file is generated from $<" $@ -teal: util/jsonschema.lua util/datamapper.lua util/jsonpointer.lua +teal: plugins/mod_cron.lua util/datamapper.lua util/jsonpointer.lua util/jsonschema.lua util/mathcompat.lua util/smqueue.lua util/xtemplate.lua util/%.so: $(MAKE) install -C util-src
--- a/configure Sat May 30 19:21:08 2026 +0200 +++ b/configure Sat May 30 19:22:51 2026 +0200 @@ -45,7 +45,7 @@ Default is \$PREFIX/lib --datadir=DIR Location where the server data should be stored. Default is \$PREFIX/var/lib/$APP_DIRNAME ---lua-version=VERSION Use specific Lua version: 5.2, 5.3, or 5.4 +--lua-version=VERSION Use specific Lua version: 5.2, 5.3, 5.4, or 5.5 Default is auto-detected. --lua-suffix=SUFFIX Versioning suffix to use in Lua filenames. Default is "$LUA_SUFFIX" (lua$LUA_SUFFIX...) @@ -174,7 +174,7 @@ [ -n "$value" ] || die "Missing value in flag $key." LUA_VERSION="$value" [ "$LUA_VERSION" != "5.1" ] || die "Lua 5.1 is no longer supported" - [ "$LUA_VERSION" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || [ "$LUA_VERSION" = "5.4" ] || die "Invalid Lua version in flag $key." + [ "$LUA_VERSION" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || [ "$LUA_VERSION" = "5.4" ] || [ "$LUA_VERSION" = "5.5" ] || die "Invalid Lua version in flag $key." LUA_VERSION_SET=yes ;; --with-lua) @@ -336,7 +336,7 @@ fi detect_lua_version() { - detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.[234])$"))' 2> /dev/null) + detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.%d+)$"))' 2> /dev/null) if [ "$detected_lua" != "nil" ] then if [ "$LUA_VERSION_SET" != "yes" ] @@ -399,10 +399,14 @@ elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.4" ] then suffixes="5.4 54 -5.4 -54" + elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.5" ] + then + suffixes="5.5 55 -5.5 -55" else suffixes="5.2 52 -5.2 -52" suffixes="$suffixes 5.3 53 -5.3 -53" suffixes="$suffixes 5.4 54 -5.4 -54" + suffixes="$suffixes 5.5 55 -5.5 -55" fi for suffix in "" $suffixes do @@ -508,6 +512,11 @@ then echo_n "Checking if Lua header version matches that of the interpreter... " header_version=$(sed -n 's/.*LUA_VERSION_NUM.*5.\(.\).*/5.\1/p' "$lua_h") + if [ -z "$header_version" ]; then + header_major_version=$(sed -n 's/.*LUA_VERSION_MAJOR_N\s*.\([0-9]\).*/\1/p' "$lua_h") + header_minor_version=$(sed -n 's/.*LUA_VERSION_MINOR_N\s*.\([0-9]\).*/\1/p' "$lua_h") + header_version="$header_major_version.$header_minor_version" + fi if [ "$header_version" = "$LUA_VERSION" ] then echo "yes"
--- a/core/features.lua Sat May 30 19:21:08 2026 +0200 +++ b/core/features.lua Sat May 30 19:22:51 2026 +0200 @@ -20,6 +20,8 @@ "loader"; -- "keyval+" store "keyval+"; + -- Support for target filter in net.resolvers.basic + "net-connect-filter"; "s2sout-pre-connect-event";
--- a/core/moduleapi.lua Sat May 30 19:21:08 2026 +0200 +++ b/core/moduleapi.lua Sat May 30 19:22:51 2026 +0200 @@ -93,7 +93,14 @@ end function api:hook(event, handler, priority) - return self:hook_object_event((hosts[self.host] or prosody).events, event, handler, priority); + local events; + local host = self.host; + if host == "*" then + events = prosody.events; + else + events = hosts[host].events; + end + return self:hook_object_event(events, event, handler, priority); end function api:hook_global(event, handler, priority) @@ -406,7 +413,14 @@ end function api:context(host) - return setmetatable({ host = host or "*", global = "*" == host }, { __index = self, __newindex = self }); + local is_global = false; + if not host or host == "*" then + host, is_global = "*", true; + end + if not is_global and not hosts[host] then + error("Cannot create context for unknown host: "..host); + end + return setmetatable({ host = host, global = is_global }, { __index = self, __newindex = self }); end function api:add_item(key, value)
--- a/core/s2smanager.lua Sat May 30 19:21:08 2026 +0200 +++ b/core/s2smanager.lua Sat May 30 19:22:51 2026 +0200 @@ -91,8 +91,17 @@ log("debug", "Destroying %s session %s->%s%s%s", session.direction, session.from_host, session.to_host, reason and ": " or "", reason or ""); if session.direction == "outgoing" then - hosts[session.from_host].s2sout[session.to_host] = nil; - session:bounce_sendq(bounce_reason or reason); + if session.block_retries then + -- Reject any attempts to communicate with the failed domain + -- while we are bouncing the stanzas + session.send = function () return false; end + session.sends2s = function () return false; end + session:bounce_sendq(bounce_reason or reason); + hosts[session.from_host].s2sout[session.to_host] = nil; + else + hosts[session.from_host].s2sout[session.to_host] = nil; + session:bounce_sendq(bounce_reason or reason); + end elseif session.direction == "incoming" then if session.outgoing and hosts[session.to_host].s2sout[session.from_host] == session then hosts[session.to_host].s2sout[session.from_host] = nil;
--- a/core/sessionmanager.lua Sat May 30 19:21:08 2026 +0200 +++ b/core/sessionmanager.lua Sat May 30 19:22:51 2026 +0200 @@ -24,7 +24,7 @@ local sessionlib = require "prosody.util.session"; local initialize_filters = require "prosody.util.filters".initialize; -local gettime = require "socket".gettime; +local time_now = require "prosody.util.time".now; local _ENV = nil; -- luacheck: std none @@ -35,7 +35,7 @@ sessionlib.set_logger(session); sessionlib.set_conn(session, conn); - session.conntime = gettime(); + session.conntime = time_now(); local filter = initialize_filters(session); local w = conn.write;
--- a/core/usermanager.lua Sat May 30 19:21:08 2026 +0200 +++ b/core/usermanager.lua Sat May 30 19:22:51 2026 +0200 @@ -98,53 +98,106 @@ end; prosody.events.add_handler("host-activated", initialize_host, 100); +local function provider_helpers(provider_type) + local function get_provider(host) + local host_session = hosts[host]; + if not host_session then + return nil, "unknown host"; + end + local provider = host_session[provider_type]; + if not provider then + return nil, "no "..provider_type.." provider"; + end + return provider; + end + + local function get_provider_method(host, method_name) + local provider, err = get_provider(host); + if not provider then + return nil, err; + end + local method = provider[method_name]; + if not method then + return nil, "method not implemented"; + end + return method; + end + + local function call_method(host, method_name, log_message, pre_event, post_event, event, ...) + local method, method_err = get_provider_method(host, method_name); + if not method then return nil, method_err; end + if pre_event and prosody.events.fire_event(pre_event, event) ~= nil then + return nil, event.reason or "prevented by module"; + end + local ok, err = method(...); + if not ok then + return ok, err; + end + if log_message then + log("info", "%s: %s@%s", log_message, event.username, event.host); + end + if post_event then + prosody.events.fire_event(post_event, event); + end + return ok, err; + end + + local function call_quiet_method(host, method_name, ...) + return call_method(host, method_name, nil, nil, nil, nil, ...); + end + + return get_provider, get_provider_method, call_method, call_quiet_method; +end + +local get_provider, _, call_method, call_quiet_method = provider_helpers("users"); + local function test_password(username, host, password) - return hosts[host].users.test_password(username, password); + return call_quiet_method(host, "test_password", username, password); end local function get_password(username, host) - return hosts[host].users.get_password(username); + return call_quiet_method(host, "get_password", username); end local function set_password(username, password, host, resource) - local ok, err = hosts[host].users.set_password(username, password); - if ok then - log("info", "Account password changed: %s@%s", username, host); - prosody.events.fire_event("user-password-changed", { username = username, host = host, resource = resource }); - end - return ok, err; + return call_method(host, "set_password", "Account password changed", "pre-user-password-change", "user-password-changed", { + username = username, host = host, resource = resource + }, username, password); end local function get_account_info(username, host) - local method = hosts[host].users.get_account_info; - if not method then return nil, "method not supported"; end - return method(username); + return call_quiet_method(host, "get_account_info", username); end local function user_exists(username, host) - if hosts[host].sessions[username] then return true; end - return hosts[host].users.user_exists(username); + if hosts[host].sessions[username] then + -- Shortcut for online users, we know they exist :) + return true; + end + return call_quiet_method(host, "user_exists", username); end local function create_user(username, password, host) - local ok, err = hosts[host].users.create_user(username, password); - if ok then - log("info", "User account created: %s@%s", username, host); - end - return ok, err; + return call_method(host, "create_user", "User account created", "pre-create-user", "user-created", { + username = username, host = host + }, username, password); end local function delete_user(username, host) - local ok, err = hosts[host].users.delete_user(username); + local ok, err = call_method(host, "delete_user", "User account deleted", "pre-delete-user", "user-deleted", { + username = username, host = host + }, username); if not ok then return nil, err; end - log("info", "User account deleted: %s@%s", username, host); - prosody.events.fire_event("user-deleted", { username = username, host = host }); return storagemanager.purge(username, host); end local function user_is_enabled(username, host) - local method = hosts[host].users.is_enabled; - if method then return method(username); end + local ret, err = call_quiet_method(host, "is_enabled", username); + if ret ~= nil then + return ret; + elseif err ~= "method not implemented" then + return nil, err; + end -- Fallback local info, err = get_account_info(username, host); @@ -160,58 +213,54 @@ end local function enable_user(username, host) - local method = hosts[host].users.enable; - if not method then return nil, "method not supported"; end - local ret, err = method(username); - if ret then - log("info", "User account enabled: %s@%s", username, host); - prosody.events.fire_event("user-enabled", { username = username, host = host }); - end - return ret, err; + return call_method(host, "enable", "User account enabled", "pre-enable-user", "user-enabled", { + username = username, host = host + }, username); end local function disable_user(username, host, meta) - local method = hosts[host].users.disable; - if not method then return nil, "method not supported"; end - local ret, err = method(username, meta); - if ret then - log("info", "User account disabled: %s@%s", username, host); - prosody.events.fire_event("user-disabled", { username = username, host = host, meta = meta }); - end - return ret, err; + return call_method(host, "disable", "User account disabled", "pre-disable-user", "user-disabled", { + username = username, host = host, meta = meta + }, username, meta); end + local function users(host) - return hosts[host].users.users(); + return call_quiet_method(host, "users"); end local function get_sasl_handler(host, session) - return hosts[host].users.get_sasl_handler(session); + return call_quiet_method(host, "get_sasl_handler", session); end -local function get_provider(host) - return hosts[host].users; -end +local _, _, call_authz_method, call_quiet_authz_method = provider_helpers("authz"); + local function get_user_role(user, host) - if host and not hosts[host] then return false; end if type(user) ~= "string" then return false; end - - return hosts[host].authz.get_user_role(user); + return call_quiet_authz_method(host, "get_user_role", user); end local function set_user_role(user, host, role_name) - if host and not hosts[host] then return false; end if type(user) ~= "string" then return false; end - local role, err = hosts[host].authz.set_user_role(user, role_name); - if role then - log("info", "Account %s@%s role changed to %s", user, host, role_name); - prosody.events.fire_event("user-role-changed", { - username = user, host = host, role = role; - }); + local event = { username = user, host = host, role_name = role_name, role = nil }; + local role, err = call_authz_method( + host, + "set_user_role", + "Account role changed to <"..role_name..">", + "pre-user-role-change", + nil, + event, + user, + role_name + ); + if not role then + return nil, err; end - return role, err; + event.role = role; + prosody.events.fire_event("user-role-changed", event); + return role; end local function create_user_with_role(username, password, host, role) @@ -240,51 +289,58 @@ end local function user_can_assume_role(user, host, role_name) - if host and not hosts[host] then return false; end if type(user) ~= "string" then return false; end - - return hosts[host].authz.user_can_assume_role(user, role_name); + return call_quiet_authz_method(host, "user_can_assume_role", user, role_name); end local function add_user_secondary_role(user, host, role_name) - if host and not hosts[host] then return false; end if type(user) ~= "string" then return false; end - local role, err = hosts[host].authz.add_user_secondary_role(user, role_name); - if role then - prosody.events.fire_event("user-role-added", { - username = user, host = host, role_name = role_name, role = role; - }); + local event = { username = user, host = host, role_name = role_name, role = nil }; + local role, err = call_authz_method( + host, + "add_user_secondary_role", + "Account gained role <"..role_name..">", + "pre-add-user-role", + "user-role-added", + event, + user, + role_name + ); + if not role then + return nil, err; end - return role, err; + event.role = role; + prosody.events.fire_event("user-role-added", event); + return role; end local function remove_user_secondary_role(user, host, role_name) - if host and not hosts[host] then return false; end if type(user) ~= "string" then return false; end - local ok, err = hosts[host].authz.remove_user_secondary_role(user, role_name); - if ok then - prosody.events.fire_event("user-role-removed", { - username = user, host = host, role_name = role_name; - }); - end - return ok, err; + return call_authz_method( + host, + "remove_user_secondary_role", + "Account lost role <"..role_name..">", + "pre-remove-user-role", + "user-role-removed", + { username = user, host = host, role_name = role_name }, + user, + role_name + ); end local function get_user_secondary_roles(user, host) - if host and not hosts[host] then return false; end if type(user) ~= "string" then return false; end - - return hosts[host].authz.get_user_secondary_roles(user); + return call_quiet_authz_method(host, "get_user_secondary_roles", user); end local function get_jid_role(jid, host) local jid_node, jid_host = jid_split(jid); if host == jid_host and jid_node then - return hosts[host].authz.get_user_role(jid_node); + return call_quiet_authz_method(host, "get_user_role", jid_node); end - return hosts[host].authz.get_jid_role(jid); + return call_quiet_authz_method(host, "get_jid_role", jid); end local function set_jid_role(jid, host, role_name) @@ -292,7 +348,7 @@ if host == jid_host then return nil, "unexpected-local-jid"; end - return hosts[host].authz.set_jid_role(jid, role_name) + return call_quiet_authz_method(host, "set_jid_role", jid, role_name); end local strict_deprecate_is_admin; @@ -312,26 +368,23 @@ end local function get_users_with_role(role, host) - if not hosts[host] then return false; end if type(role) ~= "string" then return false; end - return hosts[host].authz.get_users_with_role(role); + return call_quiet_authz_method(host, "get_users_with_role", role); end local function get_jids_with_role(role, host) - if host and not hosts[host] then return false; end if type(role) ~= "string" then return false; end - return hosts[host].authz.get_jids_with_role(role); + return call_quiet_authz_method(host, "get_jids_with_role", role); end local function get_role_by_name(role_name, host) - if host and not hosts[host] then return false; end if type(role_name) ~= "string" then return false; end - return hosts[host].authz.get_role_by_name(role_name); + return call_quiet_authz_method(host, "get_role_by_name", role_name); end local function get_all_roles(host) if host and not hosts[host] then return false; end - return hosts[host].authz.get_all_roles(); + return call_quiet_authz_method(host, "get_all_roles"); end return {
--- a/doc/doap.xml Sat May 30 19:21:08 2026 +0200 +++ b/doc/doap.xml Sat May 30 19:22:51 2026 +0200 @@ -911,6 +911,7 @@ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0478.html"/> <xmpp:version>0.2.0</xmpp:version> <xmpp:since>13.0.0</xmpp:since> + <xmpp:status>complete</xmpp:status> </xmpp:SupportedXep> </implements> <implements> @@ -918,6 +919,7 @@ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0486.html"/> <xmpp:version>0.1.0</xmpp:version> <xmpp:since>13.0.0</xmpp:since> + <xmpp:status>complete</xmpp:status> </xmpp:SupportedXep> </implements> </Project>
--- a/fallbacks/lxp.lua Sat May 30 19:21:08 2026 +0200 +++ b/fallbacks/lxp.lua Sat May 30 19:22:51 2026 +0200 @@ -86,9 +86,9 @@ local newattr, n = {}, 0; for k,v in pairs(attr) do n = n+1; - k = apply_ns(k); - newattr[n] = k; - newattr[k] = v; + local k_ = apply_ns(k); + newattr[n] = k_; + newattr[k_] = v; end tag = apply_ns(tag, true); ns[0] = tag;
--- a/net/dns.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/dns.lua Sat May 30 19:22:51 2026 +0200 @@ -16,6 +16,7 @@ local have_timer, timer = pcall(require, "prosody.util.timer"); local new_ip = require "prosody.util.ip".new_ip; local have_util_net, util_net = pcall(require, "prosody.util.net"); +local time_now = require "prosody.util.time".now; local log = require "prosody.util.logger".init("dns"); @@ -237,7 +238,7 @@ local function prune(rrs, time, soft) -- - - - - - - - - - - - - - - prune - time = time or socket.gettime(); + time = time or time_now(); for i,rr in ipairs(rrs) do if rr.tod then if rr.tod < time then @@ -300,7 +301,7 @@ local cache_metatable = {}; -- - - - - - - - - - - - - - - - cache_metatable function cache_metatable.__tostring(cache) - local time = socket.gettime(); + local time = time_now(); local t = {}; for class,types in pairs(cache) do for type,names in pairs(types) do @@ -318,7 +319,7 @@ function dns.random(...) -- - - - - - - - - - - - - - - - - - - dns.random - math.randomseed(math.floor(10000*socket.gettime()) % 0x80000000); + math.randomseed(math.floor(10000 * time_now()) % 0x80000000); dns.random = math.random; return dns.random(...); end @@ -701,10 +702,10 @@ local resolv_conf = io.open("/etc/resolv.conf"); if resolv_conf then for line in resolv_conf:lines() do - line = line:gsub("#.*$", "") + local nameserver = line:gsub("#.*$", "") :match('^%s*nameserver%s+([%x:%.]*%%?%S*)%s*$'); - if line then - local ip = new_ip(line); + if nameserver then + local ip = new_ip(nameserver); if ip then self:addnameserver(ip.addr); end @@ -813,7 +814,7 @@ if not (rrs and rrs[1]) then return end return self:peek(rrs[1].cname, qtype, qclass, n - 1); end - if prune(rrs, socket.gettime()) and qtype == '*' or not next(rrs) then + if prune(rrs, time_now()) and qtype == '*' or not next(rrs) then set(self.cache, qclass, qtype, qname, nil); return nil; end @@ -824,7 +825,7 @@ function resolver:purge(soft) -- - - - - - - - - - - - - - - - - - - purge if soft == 'soft' then - self.time = socket.gettime(); + self.time = time_now(); for class,types in pairs(self.cache or {}) do for type,names in pairs(types) do for name,rrs in pairs(names) do @@ -859,7 +860,7 @@ packet = header..question, server = self.best_server, delay = 1, - retry = socket.gettime() + self.delays[1]; + retry = time_now() + self.delays[1]; qclass = qclass; qtype = qtype; qname = qname; @@ -914,7 +915,7 @@ sock = self:voidsocket(sock); -- Find all requests to the down server, and retry on the next server - self.time = socket.gettime(); + self.time = time_now(); log("debug", "servfail %d (of %d)", num, #self.server); for id,queries in pairs(self.active) do for question,o in pairs(queries) do @@ -970,7 +971,7 @@ function resolver:receive(rset) -- - - - - - - - - - - - - - - - - receive --print('receive'); print(self.socket); - self.time = socket.gettime(); + self.time = time_now(); rset = rset or self.socket; local response; @@ -1019,7 +1020,7 @@ function resolver:feed(sock, packet, force) --print('receive'); print(self.socket); - self.time = socket.gettime(); + self.time = time_now(); local response = self:decode(packet, force); if response and self.active[response.header.id] @@ -1072,7 +1073,7 @@ while self:receive() do end if not next(self.active) then return nil; end - self.time = socket.gettime(); + self.time = time_now(); for id,queries in pairs(self.active) do for question,o in pairs(queries) do if self.time >= o.retry then
--- a/net/http.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/http.lua Sat May 30 19:22:51 2026 +0200 @@ -339,7 +339,11 @@ end end - local http_service = basic_resolver.new(host, port_number, "tcp", { servername = req.host; use_dane = use_dane }); + local http_service = basic_resolver.new(host, port_number, "tcp", { + servername = req.host; + use_dane = use_dane; + filter = ex and ex.target_filter; + }); connect(http_service, listener, { sslctx = sslctx }, req); self.events.fire_event("request", { http = self, request = req, url = u });
--- a/net/http/files.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/http/files.lua Sat May 30 19:22:51 2026 +0200 @@ -29,8 +29,8 @@ local out = {}; local c = 0; - for component in path:gmatch("([^/]+)") do - component = urldecode(component); + for urlcomponent in path:gmatch("([^/]+)") do + local component = urldecode(urlcomponent); if component:find(forbidden_chars_pattern) then return nil; elseif component == ".." then
--- a/net/http/server.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/http/server.lua Sat May 30 19:22:51 2026 +0200 @@ -13,7 +13,7 @@ local codes = require "prosody.net.http.codes"; local promise = require "prosody.util.promise"; local errors = require "prosody.util.error"; -local blocksize = 2^16; +local blocksize = 65536; local async = require "prosody.util.async"; local id = require"prosody.util.id"; @@ -287,7 +287,7 @@ if err then response.status_code = err_code; - response:send(events.fire_event("http-error", { code = err_code, message = err, response = response })); + response:send(events.fire_event("http-error", { code = err_code, message = err, request = request, response = response })); return; end @@ -312,7 +312,7 @@ if err then response.status_code = err_code; - response:send(events.fire_event("http-error", { code = err_code, message = err, response = response })); + response:send(events.fire_event("http-error", { code = err_code, message = err, request = request, response = response })); return; end @@ -365,7 +365,7 @@ response.conn:write(t_concat(output)); response:done(); end -function _M.send_file(response, f) +function _M.send_file(response, f, length) if response.is_head_request then if f.close then f:close(); end return _M.send_head_response(response); @@ -379,14 +379,11 @@ incomplete[response.conn] = nil; return; end - local chunk = f:read(blocksize); - if chunk then - if chunked then - chunk = ("%x\r\n%s\r\n"):format(#chunk, chunk); - end - -- io.write("."); io.flush(); - response.conn:write(chunk); - else + local chunksize = blocksize; + if length and length < chunksize then chunksize = length; end + + local chunk = chunksize > 0 and f:read(chunksize); + if not chunk then incomplete[response.conn] = nil; if f.close then f:close(); end if chunked then @@ -394,6 +391,11 @@ end -- io.write("\n"); return response:done(); + else + if length then length = length - #chunk; end + if chunked then chunk = ("%x\r\n%s\r\n"):format(#chunk, chunk); end + -- io.write("."); io.flush(); + response.conn:write(chunk); end end _M.write_headers(response);
--- a/net/resolvers/basic.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/resolvers/basic.lua Sat May 30 19:22:51 2026 +0200 @@ -66,6 +66,9 @@ return; end local next_target = table.remove(self.targets, 1); + if self.filter and not self.filter(next_target[1], next_target[2], next_target[3], next_target[4], not not self.targets[1]) then + return self:next(cb); + end cb(next_target[1], next_target[2], next_target[3], next_target[4], not not self.targets[1]); return; end @@ -86,8 +89,8 @@ local dns_resolver = adns.resolver(); local dns_lookups = { + ipv6 = do_dns_lookup(self, dns_resolver, "AAAA", self.hostname, true); ipv4 = do_dns_lookup(self, dns_resolver, "A", self.hostname, true); - ipv6 = do_dns_lookup(self, dns_resolver, "AAAA", self.hostname, true); tlsa = do_dns_lookup(self, dns_resolver, "TLSA", ("_%d._%s.%s"):format(self.port, self.conn_type, self.hostname)); }; @@ -148,6 +151,7 @@ port = port; conn_type = conn_type; extra = extra or {}; + filter = extra and extra.filter; targets = targets; }, resolver_mt); end
--- a/net/server_epoll.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/server_epoll.lua Sat May 30 19:22:51 2026 +0200 @@ -550,7 +550,7 @@ -- Sent the whole 'data' but there's more in the buffer ok, err, partial = nil, "timeout", ok; end - self:debug("Sent %d out of %d buffered bytes", ok and #data or partial or 0, #buffer); + self:noise("Sent %d out of %d buffered bytes", ok and #data or partial or 0, #buffer); if ok then -- all the data we had was sent successfully self:set(nil, false); if cfg.keep_buffers and type(buffer) == "table" then
--- a/net/server_event.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/server_event.lua Sat May 30 19:22:51 2026 +0200 @@ -54,8 +54,7 @@ local inet_pton = inet.pton; local sslconfig = require "prosody.util.sslconfig"; local tls_impl = require "prosody.net.tls_luasec"; - -local socket_gettime = socket.gettime +local time_now = require "prosody.util.time".now; local log = require ("prosody.util.logger").init("socket") @@ -521,7 +520,7 @@ local interface = { type = "client"; conn = client; - currenttime = socket_gettime( ); -- safe the origin + currenttime = time_now(); -- safe the origin writebuffer = {}; -- writebuffer writebufferlen = 0; -- length of writebuffer send = client.send; -- caching table lookups @@ -918,7 +917,7 @@ local function add_task(delay, callback) local event_handle; event_handle = base:addevent(nil, 0, function () - local ret = callback(socket_gettime()); + local ret = callback(time_now()); if ret then return 0, ret; elseif event_handle then
--- a/net/server_select.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/server_select.lua Sat May 30 19:22:51 2026 +0200 @@ -48,7 +48,7 @@ --// extern libs //-- local luasocket = use "socket" or require "socket" -local luasocket_gettime = luasocket.gettime +local time_now = require "prosody.util.time".now; local inet = require "prosody.util.net"; local inet_pton = inet.pton; local sslconfig = require "prosody.util.sslconfig"; @@ -620,6 +620,7 @@ _sendlistlen = ( wrote and removesocket( _sendlist, client, _sendlistlen ) ) or _sendlistlen _readlistlen = ( read and removesocket( _readlist, client, _readlistlen ) ) or _readlistlen read, wrote = nil, nil + local _ _, err = client:dohandshake( ) if not err then out_put( "server.lua: ssl handshake done" ) @@ -918,7 +919,7 @@ local new_data = {}; function add_task(delay, callback) - local current_time = luasocket_gettime(); + local current_time = time_now(); delay = delay + current_time; if delay >= current_time then table_insert(new_data, {delay, callback}); @@ -969,7 +970,7 @@ loop = function(once) -- this is the main loop of the program if quitting then return "quitting"; end if once then quitting = "once"; end - _currenttime = luasocket_gettime( ) + _currenttime = time_now(); repeat -- Fire timers local next_timer_time = math_huge; @@ -1002,7 +1003,7 @@ handler:force_close() -- forced disconnect _closelist[ handler ] = nil; end - _currenttime = luasocket_gettime( ) + _currenttime = time_now(); -- Check for socket timeouts if _currenttime - _starttime > _checkinterval then @@ -1150,7 +1151,7 @@ use "setmetatable" ( _readtimes, { __mode = "k" } ) use "setmetatable" ( _writetimes, { __mode = "k" } ) -_starttime = luasocket_gettime( ) +_starttime = time_now(); local function setlogger(new_logger) local old_logger = log;
--- a/net/unbound.lua Sat May 30 19:21:08 2026 +0200 +++ b/net/unbound.lua Sat May 30 19:22:51 2026 +0200 @@ -108,6 +108,8 @@ return measure; end +local count = measure; + local function lookup(callback, qname, qtype, qclass) if not unbound then initialize(); end qtype = qtype and s_upper(qtype) or "A"; @@ -119,6 +121,7 @@ local function callback_wrapper(a, err) m(); if a then + count(qclass, qtype, #a); prep_answer(a); log_query("debug", "Results for %s %s %s: %s (%s)", qname, qclass, qtype, a.rcode == 0 and (#a .. " items") or a.status, a.secure and "Secure" or a.bogus or "Insecure"); -- Insecure as in unsigned @@ -221,7 +224,7 @@ }; } -_M.instrument = function(measure_) measure = measure_; end; +_M.instrument = function(measure_, count_) measure, count = measure_, count_; end; function _M.resolver() return wrapper; end
--- a/plugins/mod_account_activity.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_account_activity.lua Sat May 30 19:22:51 2026 +0200 @@ -51,17 +51,23 @@ section_desc = "View user activity data"; name = "list_inactive"; desc = "List inactive user accounts"; + flags = { + bool_params = { only_unknown = true, hide_logins = true }; + }; args = { { name = "host"; type = "string" }; { name = "duration"; type = "string" }; }; host_selector = "host"; - handler = function(self, host, duration) --luacheck: ignore 212/self + handler = function(self, host, duration, opts) --luacheck: ignore 212/self + opts = opts or {}; + local um = require "prosody.core.usermanager"; local duration_sec = require "prosody.util.human.io".parse_duration(duration or ""); if not duration_sec then return false, ("Invalid duration %q - try something like \"30d\""):format(duration); end + local only_unknown, show_logins = opts.only_unknown, not opts.hide_logins; local now = os.time(); local n_inactive, n_unknown = 0, 0; @@ -73,20 +79,27 @@ local created_at; if user_info then created_at = user_info.created; - if created_at and (now - created_at) > duration_sec then + if created_at and (now - created_at) > duration_sec and not only_unknown then self.session.print(username, ""); n_inactive = n_inactive + 1; end end if not created_at then n_unknown = n_unknown + 1; + if only_unknown then + self.session.print(username, ""); + end end - elseif (now - last_active) > duration_sec then + elseif show_logins and (now - last_active) > duration_sec and not only_unknown then self.session.print(username, os.date("%Y-%m-%dT%T", last_active)); n_inactive = n_inactive + 1; end end + if only_unknown then + return true, ("%d accounts with unknown last activity"):format(n_unknown); + end + if n_unknown > 0 then return true, ("%d accounts inactive since %s (%d unknown)"):format(n_inactive, os.date("%Y-%m-%dT%T", now - duration_sec), n_unknown); end
--- a/plugins/mod_admin_shell.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_admin_shell.lua Sat May 30 19:22:51 2026 +0200 @@ -20,6 +20,7 @@ local schema = require "prosody.util.jsonschema"; local st = require "prosody.util.stanza"; local parse_args = require "prosody.util.argparse".parse; +local dt = require "prosody.util.datetime"; local saslprep = require "prosody.util.encodings".stringprep.saslprep; local _G = _G; @@ -120,7 +121,7 @@ meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or "")); meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or "")); end - local row = format_table(meta_columns, self.session.width) + local row = format_table(meta_columns, self.session.width, self.session.column_separator) print(row()); for column, spec in iterators.sorted_pairs(available_columns) do print(row({ column, spec.title, spec.description })); @@ -346,12 +347,17 @@ local default_width = 132; -- The common default of 80 is a bit too narrow for e.g. s2s:show(), 132 was another common width for hardware terminals local margin = 2; -- To account for '| ' when lines are printed - session.width = (tonumber(event.stanza.attr.width) or default_width)-margin; + session.width = tonumber(event.stanza.attr.width) or default_width; local line = event.stanza:get_text(); local useglobalenv; session.repl = event.stanza.attr.repl ~= "0"; + if session.repl and session.width ~= -1 then + session.width = session.width - margin; + elseif session.width == -1 then + session.column_separator = "\t"; + end local result = st.stanza("repl-result"); @@ -492,7 +498,7 @@ print [[Commands are divided into multiple sections. For help on a particular section, ]] print [[type: help SECTION (for example, 'help c2s'). Sections are: ]] print [[]] - local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width) + local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width, session.column_separator) print(row()) for section_name, section in it.sorted_pairs(def_env) do local section_mt = getmetatable(section); @@ -992,6 +998,44 @@ return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err); end +describe_command [[config:dump() - Display the current server configuration from memory]] +function def_env.config:dump() + local config = require "prosody.core.configmanager".getconfig(); + local serialize = require "prosody.util.serialization".serialize; + + local print = self.session.print; + + print("-- Configuration dumped from Prosody "..prosody.version); + print("-- at "..os.date("%Y-%m-%d %R").."\n"); + print("-- Global options\n") + for opt, val in it.sorted_pairs(config["*"]) do + print(opt.." = "..serialize(val, "pretty").."\n"); + end + + print("-- Hosts and components\n"); + + local n_hosts = 0; + for host in it.filter("*", it.sorted_pairs(config)) do + n_hosts = n_hosts + 1; + local host_config = config[host]; + if host_config.type ~= "component" then + print(("\nVirtualHost %q"):format(host)); + else + print(("\nComponent %q %q"):format(host, host_config.component_module)); + end + + for opt, val in it.sorted_pairs(host_config) do + if opt ~= "component_module" then + print("\t"..opt.." = "..serialize(val, "pretty").."\n"); + end + end + end + + print("\n-- End of configuration --"); + + return true, "Showing server configuration with "..n_hosts.." hosts"; +end + def_env.c2s = new_section("Commands to manage local client-to-server sessions"); local function get_jid(session) @@ -1311,7 +1355,7 @@ function def_env.c2s:show(match_jid, colspec) local print = self.session.print; local columns = get_colspec(colspec, { "id"; "jid"; "role"; "ipv"; "status"; "secure"; "smacks"; "csi" }); - local row = format_table(columns, self.session.width); + local row = format_table(columns, self.session.width, self.session.column_separator); local function match(session) local jid = get_jid(session) @@ -1416,7 +1460,7 @@ function def_env.s2s:show(match_jid, colspec) local print = self.session.print; local columns = get_colspec(colspec, { "id"; "host"; "dir"; "remote"; "ipv"; "secure"; "s2s_sasl"; "dialback" }); - local row = format_table(columns, self.session.width); + local row = format_table(columns, self.session.width, self.session.column_separator); local function match(session) return match_s2s_jid(session, match_jid); @@ -1773,7 +1817,7 @@ { title = "Role"; width = 12; key = "role" }; -- longest role name { title = "JID"; width = "75%"; key = "bare_jid" }; { title = "Nickname"; width = "25%"; key = "nick"; mapper = jid_resource }; - }, self.session.width); + }, self.session.width, self.session.column_separator); local occupants = array.collect(iterators.select(2, room_obj:each_occupant())); local total = #occupants; if filter then @@ -1818,7 +1862,7 @@ { title = "Affiliation"; width = 12 }; -- longest affiliation name { title = "JID"; width = "75%" }; { title = "Nickname"; width = "25%"; key = "reserved_nickname" }; - }, self.session.width); + }, self.session.width, self.session.column_separator); local affiliated = array(); for affiliated_jid, affiliation, affiliation_data in room_obj:each_affiliation() do affiliated:push(setmetatable({ affiliation; affiliated_jid }, { __index = affiliation_data })); @@ -2178,12 +2222,12 @@ local output_simple = format_table({ { title = "Module"; width = "1p" }; { title = "External URL"; width = "6p" }; - }, self.session.width); + }, self.session.width, self.session.column_separator); local output_split = format_table({ { title = "Module"; width = "1p" }; { title = "External URL"; width = "3p" }; { title = "Internal URL"; width = "3p" }; - }, self.session.width); + }, self.session.width, self.session.column_separator); for _, host in ipairs(hosts) do local http_apps = modulemanager.get_items("http-provider", host); @@ -2399,7 +2443,7 @@ { title = "Function"; width = "10p" }; { title = "Status"; width = "16" }; { title = "Location"; width = "10p" }; - }, self.session.width); + }, self.session.width, self.session.column_separator); print(row()) local c = 0; @@ -2464,7 +2508,7 @@ { title = "Domain", width = max_domain }; { title = "Certificate", width = "100%" }; { title = "Service", width = 5 }; - }, self.session.width); + }, self.session.width, self.session.column_separator); print(row()); print(("-"):rep(self.session.width or 80)); for domain, certs in it.sorted_pairs(index) do @@ -2481,6 +2525,44 @@ return true, ("Showing %d certificates in %s"):format(c, path); end +describe_command [[debug:ping(localhost, remotehost) - Sends a ping to a remote XMPP server while printing debug logs]] +function def_env.debug:ping(localjid, remotejid, timeout) + local localnode, localhost, localresource = jid_split(localjid); + local remotenode, remotehost, remoteresource = jid_split(remotejid); + if not localhost then + return nil, "Invalid sender JID"; + elseif not prosody.hosts[localhost] then + return nil, "No such local host"; + elseif localresource then + return nil, "Server can't send ping from local resource"; -- Would require creating a temporary session with bound resource and everything + end + if not remotehost then + return nil, "Invalid destination JID"; + end + local iq = st.iq { from = jid_join(localnode, localhost); to = jid_join(remotenode, remotehost, remoteresource); type = "get"; id = new_id() } + :tag("ping", {xmlns="urn:xmpp:ping"}); + + local time_start = time.now(); + local print = self.session.print; + + local writing = false; + local sink = logger.add_simple_sink(function (source, level, message) + if writing then return; end + writing = true; + print(dt.time(time.now()), source, level, message); + writing = false; + end); + + return module:context(localhost):send_iq(iq, nil, timeout):finally(function() + if not logger.remove_sink(sink) then + module:log("warn", "Unable to remove watch:log() sink"); + end + end):next(function(pong) + return ("pong from %s on %s in %gs"):format(pong.stanza.attr.from, pong.origin.id, time.now() - time_start); + end); +end + + def_env.role = new_section("Role and access management"); describe_command [[role:list(host) - List known roles]]
--- a/plugins/mod_bosh.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_bosh.lua Sat May 30 19:22:51 2026 +0200 @@ -10,13 +10,12 @@ local new_xmpp_stream = require "prosody.util.xmppstream".new; local sm = require "prosody.core.sessionmanager"; +local sm_new_session = sm.new_session; local sm_destroy_session = sm.destroy_session; local new_uuid = require "prosody.util.uuid".generate; local core_process_stanza = prosody.core_process_stanza; local st = require "prosody.util.stanza"; -local logger = require "prosody.util.logger"; local log = module._log; -local initialize_filters = require "prosody.util.filters".initialize; local math_min = math.min; local tostring, type = tostring, type; local traceback = debug.traceback; @@ -26,7 +25,8 @@ local xmlns_streams = "http://etherx.jabber.org/streams"; local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; -local xmlns_bosh = "http://jabber.org/protocol/httpbind"; -- (hard-coded into a literal in session.send) +local xmlns_bosh = "http://jabber.org/protocol/httpbind"; +local xmlns_xbosh = "urn:xmpp:xbosh"; local stream_callbacks = { stream_ns = xmlns_bosh, stream_tag = "body", default_ns = "jabber:client" }; @@ -79,9 +79,11 @@ -- Used to respond to idle sessions (those with waiting requests) function on_destroy_request(request) + local log = request.log or log; log("debug", "Request destroyed: %s", request); local session = sessions[request.context.sid]; if session then + log = session.log or log; local requests = session.requests; for i, r in ipairs(requests) do if r == request then @@ -116,9 +118,8 @@ end function handle_POST(event) - log("debug", "Handling new request %s: %s\n----------", event.request, event.request.body); - local request, response = event.request, event.response; + local log = request.log or log; response.on_destroy = on_destroy_request; local body = request.body; @@ -224,7 +225,7 @@ local function bosh_reset_stream(session) session.notopen = true; end -local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" }; +local stream_xmlns_attr = { xmlns = xmlns_xmpp_streams }; local function bosh_close_stream(session, reason) (session.log or log)("info", "BOSH client disconnected: %s", (reason and reason.condition or reason) or "session close"); @@ -267,6 +268,7 @@ -- Handle the <body> tag in the request payload. function stream_callbacks.streamopened(context, attr) local request, response = context.request, context.response; + local log = request.log or log; local sid, rid = attr.sid, tonumber(attr.rid); log("debug", "BOSH body open (sid: %s)", sid or "<none>"); context.rid = rid; @@ -324,26 +326,29 @@ -- New session sid = new_uuid(); - -- TODO use util.session - local session = { - base_type = "c2s", type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to, - rid = rid - 1, -- Hack for initial session setup, "previous" rid was $current_request - 1 - bosh_version = attr.ver, bosh_wait = wait, streamid = sid, - bosh_max_inactive = bosh_max_inactivity, bosh_responses = cache.new(BOSH_HOLD+1):table(); - requests = { }, send_buffer = {}, reset_stream = bosh_reset_stream, - close = bosh_close_stream, dispatch_stanza = core_process_stanza, notopen = true, - log = logger.init("bosh"..sid), secure = consider_bosh_secure or request.secure, - ip = request.ip; - stanza_size_limit = unauthed_stanza_size_limit; - }; + local session = sm_new_session(request.conn); + session.sid = sid; + session.host = attr.to; + session.rid = rid - 1; -- Hack for initial session setup, "previous" rid was $current_request - 1 + session.bosh_version = attr.ver; + session.bosh_wait = wait; + session.streamid = sid; + session.bosh_max_inactive = bosh_max_inactivity; + session.bosh_responses = cache.new(BOSH_HOLD + 1):table(); + session.requests = {}; + session.send_buffer = {}; + session.stanza_size_limit = unauthed_stanza_size_limit; + session.reset_stream = bosh_reset_stream; + session.close = bosh_close_stream; + session.dispatch_stanza = core_process_stanza; + session.notopen = true; + session.secure = consider_bosh_secure or request.secure; sessions[sid] = session; session.thread = runner(function (stanza) session:dispatch_stanza(stanza); end, runner_callbacks, session); - local filter = initialize_filters(session); - session.log("debug", "BOSH session created for request from %s", session.ip); log("info", "New BOSH session, assigned it sid '%s'", sid); report_new_sid(); @@ -354,13 +359,13 @@ local creating_session = true; local r = session.requests; - function session.send(s) + function session.rawsend(s) -- We need to ensure that outgoing stanzas have the jabber:client xmlns if s.attr and not s.attr.xmlns then s = st.clone(s); s.attr.xmlns = "jabber:client"; end - s = filter("stanzas/out", s); + s = session.filter("bytes/out", tostring(s)); --log("debug", "Sending BOSH data: %s", s); if not s then return true end t_insert(session.send_buffer, tostring(s)); @@ -368,8 +373,8 @@ local oldest_request = r[1]; if oldest_request and not session.bosh_processing then log("debug", "We have an open request, so sending on that"); - local body_attr = { xmlns = "http://jabber.org/protocol/httpbind", - ["xmlns:stream"] = "http://etherx.jabber.org/streams"; + local body_attr = { xmlns = xmlns_bosh, + ["xmlns:stream"] = xmlns_streams; type = session.bosh_terminate and "terminate" or nil; sid = sid; }; @@ -382,9 +387,9 @@ body_attr.wait = tostring(session.bosh_wait); body_attr.authid = sid; body_attr.secure = "true"; - body_attr.ver = '1.6'; + body_attr.ver = '1.6'; body_attr.from = session.host; - body_attr["xmlns:xmpp"] = "urn:xmpp:xbosh"; + body_attr["xmlns:xmpp"] = xmlns_xbosh; body_attr["xmpp:version"] = "1.0"; end local response_xml = st.stanza("body", body_attr):top_tag()..t_concat(session.send_buffer).."</body>"; @@ -477,7 +482,6 @@ function stream_callbacks.handlestanza(context, stanza) if context.ignore then return; end - log("debug", "BOSH stanza received: %s\n", stanza:top_tag()); local session = sessions[context.sid]; if session then if stanza.attr.xmlns == xmlns_bosh then
--- a/plugins/mod_c2s.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_c2s.lua Sat May 30 19:22:51 2026 +0200 @@ -160,15 +160,14 @@ limits:reset(); end send(features); + elseif session.secure then + -- Here SASL should be offered, is mod_saslauth and mod_auth_* loaded and working correctly? + (session.log or log)("warn", "No stream features to offer on secure session. Check authentication settings."); + session:close{ condition = "undefined-condition", text = "No authentication method available" }; else - if session.secure then - -- Here SASL should be offered - (session.log or log)("warn", "No stream features to offer on secure session. Check authentication settings."); - else - -- Normally STARTTLS would be offered - (session.log or log)("warn", "No stream features to offer on insecure session. Check encryption and security settings."); - end - session:close{ condition = "undefined-condition", text = "No stream features to proceed with" }; + -- Normally STARTTLS would be offered, is mod_tls loaded? + (session.log or log)("warn", "No stream features to offer on insecure session. Check encryption and security settings."); + session:close{ condition = "undefined-condition", text = "No connection encryption available" }; end end @@ -221,7 +220,11 @@ local function session_close(session, reason) local log = session.log or log; local close_event_payload = { session = session, reason = reason }; - module:context(session.host):fire_event("pre-session-close", close_event_payload); + + -- Fire event on host if we know it, otherwise fire globally + local context = hosts[session.host] and module:context(session.host) or module:context("*"); + context:fire_event("pre-session-close", close_event_payload); + reason = close_event_payload.reason; stop_session_timers(session); if session.conn then
--- a/plugins/mod_carbons.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_carbons.lua Sat May 30 19:22:51 2026 +0200 @@ -149,6 +149,7 @@ :tag("forwarded", { xmlns = xmlns_forward }) :add_child(copy):reset(); + carbon:set_meta("archive-id", stanza:get_meta("archive-id")); end carbon.attr.to = session.full_jid;
--- a/plugins/mod_cron.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_cron.lua Sat May 30 19:22:51 2026 +0200 @@ -1,3 +1,4 @@ +-- This file is generated from teal-src/prosody/plugins/mod_cron.tl module:set_global(); local async = require("prosody.util.async"); @@ -9,7 +10,7 @@ local active_hosts = {} if prosody.process_type == "prosodyctl" then - return; -- Yes, it happens... + return end function module.add_host(host_module) @@ -17,14 +18,24 @@ local last_run_times = host_module:open_store("cron", "map"); active_hosts[host_module.host] = true; - local function save_task(task, started_at) last_run_times:set(nil, task.id, started_at); end + local function save_task(task, started_at) + last_run_times:set(nil, task.id, started_at); + end - local function restore_task(task) if task.last == nil then task.last = last_run_times:get(nil, task.id); end end + local function restore_task(task) + if task.last == nil then + task.last = last_run_times:get(nil, task.id); + end + end local function task_added(event) local task = event.item; - if task.name == nil then task.name = task.when; end - if task.id == nil then task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower(); end + if task.name == nil then + task.name = task.when; + end + if task.id == nil then + task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower(); + end task.period = host_module:get_option_period(task.id:gsub("/", "_") .. "_period", "1" .. task.when, 60, 86400 * 7 * 53); task.restore = restore_task; task.save = save_task; @@ -40,14 +51,20 @@ host_module:handle_items("task", task_added, task_removed, true); - function host_module.unload() active_hosts[host_module.host] = nil; end + function host_module.unload() + active_hosts[host_module.host] = nil; + end end -local function should_run(task, last) return not last or last + task.period * 0.995 <= os.time() end +local function should_run(task, last) + return not last or last + task.period * 0.995 <= os.time() +end local function run_task(task) task:restore(); - if not should_run(task, task.last) then return end + if not should_run(task, task.last) then + return + end local started_at = os.time(); task:run(started_at); task.last = started_at; @@ -55,7 +72,7 @@ end local function spread(t, factor) - return t * (1 - factor + 2*factor*math.random()); + return t * (1 - factor + 2 * factor * math.random()) end local task_runner = async.runner(run_task); @@ -64,7 +81,9 @@ local delay = spread(cron_check_delay, cron_spread_factor); for host in pairs(active_hosts) do module:log("debug", "Running periodic tasks for host %s", host); - for _, task in ipairs(module:context(host):get_host_items("task")) do task_runner:run(task); end + for _, task in ipairs(module:context(host):get_host_items("task")) do + task_runner:run(task); + end end module:log("debug", "Wait %gs", delay); return delay
--- a/plugins/mod_csi_simple.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_csi_simple.lua Sat May 30 19:22:51 2026 +0200 @@ -11,6 +11,7 @@ local dt = require "prosody.util.datetime"; local filters = require "prosody.util.filters"; local timer = require "prosody.util.timer"; +local jid_bare = require "prosody.util.jid".bare; local queue_size = module:get_option_integer("csi_queue_size", 256, 1); local resume_delay = module:get_option_period("csi_resume_inactive_delay", 5); @@ -40,13 +41,18 @@ -- TODO Some MUC awareness, e.g. check for the 'this relates to you' status code return true, "subscription request"; elseif st_name == "message" then + if st_type == "error" then + return true, "delivery failure"; + end + if stanza:get_child("event", "http://jabber.org/protocol/pubsub#event") then + if stanza.attr.from == nil or stanza.attr.from == jid_bare(stanza.attr.to) then + return true, "self-pep-event"; + end + end if st_type == "headline" then -- Headline messages are ephemeral by definition return false, "headline"; end - if st_type == "error" then - return true, "delivery failure"; - end if stanza:get_child("sent", "urn:xmpp:carbons:2") then return true, "carbon"; end @@ -116,6 +122,12 @@ { "reason" } ); +local hold_reasons = module:metric( + "counter", "hold", "", + "CSI queue held", + { "reason" } +); + local flush_sizes = module:metric("histogram", "flush_stanza_count", "", "Number of stanzas flushed at once", {}, { buckets = { 0, 1, 2, 4, 8, 16, 32, 64, 128, 256 } }):with_labels(); @@ -138,6 +150,7 @@ module:fire_event("csi-flushing", { session = session }); session.conn:resume_writes(); else + hold_reasons:with_labels(why or "unimportant"):add(1); session.log("debug", "Holding buffer (%s; queue size is %d)", why or "unimportant", session.csi_counter); stanza = with_timestamp(stanza, jid.join(session.username, session.host)) end
--- a/plugins/mod_external_services.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_external_services.lua Sat May 30 19:22:51 2026 +0200 @@ -101,6 +101,9 @@ srv.restricted = true; end end + if item.extra and st.is_stanza(item.extra) then + srv.extra = item.extra; + end return srv; end @@ -149,7 +152,11 @@ password = srv.password; expires = srv.expires and dt.datetime(srv.expires) or nil; restricted = srv.restricted and "1" or nil; - }):up(); + }); + if srv.extra then + reply:add_child(srv.extra); + end + reply:up(); end return reply; @@ -242,3 +249,43 @@ module:add_feature("urn:xmpp:extdisco:1"); module:hook("iq-get/host/urn:xmpp:extdisco:1:services", handle_services); module:hook("iq-get/host/urn:xmpp:extdisco:1:credentials", handle_credentials); + + +module:add_item("shell-command", { + section = "services"; + section_desc = "Inspect external services"; + name = "list"; + desc = "List all external services for a host"; + args = { + { name = "host", type = "string" }; + { name = "service", type = "string" }; + }; + host_selector = "host"; + handler = function (shell, host, service_type) --luacheck: ignore 212/host + local c = 0; + local print = shell.session.print; + local services = get_services(); + for _, srv in ipairs(services) do + if not service_type or service_type:lower() == srv.type:lower() then + c = c + 1; + print(srv.type.." ("..srv.transport..")", srv.host..(srv.port and (":"..srv.port) or "")); + print(" auth:", srv.restricted and "required" or "none"); + for _, prop in ipairs({ "username", "password", "expires" }) do + local val = srv[prop]; + if val then + if prop == "expires" then + val = dt.datetime(val); + end + print(" "..prop..":", val); + end + end + if srv.extra then + print(" extra:"); + print(" "..tostring(srv.extra:indent(3, " "))); + end + print(""); + end + end + return true, ("%d services"):format(c); + end; +});
--- a/plugins/mod_http_errors.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_http_errors.lua Sat May 30 19:22:51 2026 +0200 @@ -2,9 +2,13 @@ local server = require "prosody.net.http.server"; local codes = require "prosody.net.http.codes"; +local http_errors = require "prosody.net.http.errors".registry; +local json = require "prosody.util.json"; +local new_short_id = require "prosody.util.id".short; local xml_escape = require "prosody.util.stanza".xml_escape; local render = require "prosody.util.interpolation".new("%b{}", xml_escape); + local show_private = module:get_option_boolean("http_errors_detailed", false); local always_serve = module:get_option_boolean("http_errors_always_show", true); local default_message = { module:get_option_string("http_errors_default_message", "That's all I know.") }; @@ -77,6 +81,27 @@ return get_page(event.code, (show_private and event.private_message) or event.message or (event.error and event.error.text)); end); +-- Return error object as JSON if that's what requester prefers +module:hook_object_event(server, "http-error", function (event) + if event.request and (event.request.headers.accept or ""):match("^application/json") then + event.response.headers.content_type = "application/json"; + local error = event.request and event.error; + if not error then + error = http_errors[event.response.status_code]; + end + local extra = error.extra; + return json.encode({ + instance_id = error.instance_id or new_short_id(); + type = error.type; + condition = error.condition; + text = error.text or (show_private and event.private_message) or event.message; + namespace = extra and extra.namespace; + subcondition = extra and extra.condition; + source = error.source or "mod_http_errors"; + }); + end +end, 0.1); + -- Way to use the template for other things so to give a consistent appearance module:hook("http-message", function (event) if event.response then
--- a/plugins/mod_http_file_share.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_http_file_share.lua Sat May 30 19:22:51 2026 +0200 @@ -18,7 +18,9 @@ local dt = require "prosody.util.datetime"; local hi = require "prosody.util.human.units"; local cache = require "prosody.util.cache"; +local join_path = require "util.paths".join; local lfs = require "lfs"; +local ENOENT = require "prosody.util.pposix".ENOENT; local unknown = math.abs(0/0); local unlimited = math.huge; @@ -431,22 +433,22 @@ local request_range = request.headers.range; local response_range; + local response_len = nil; -- until eof -- "bytes=0-" just means "the whole file", so ignore it along with non-byte-range requests if request_range and request_range ~= "bytes=0-" and request_range:find("^bytes=") then local size = tonumber(filesize); - local last_byte = string.format("%d", size - 1); - local range_start, range_end = request_range:match("^bytes=(%d+)%-(%d*)$") - -- Only support resumption, ie ranges from somewhere in the middle until the end of the file. - if range_end == "" or range_end == last_byte then - local pos = tonumber(range_start); - local new_pos = pos < size and handle:seek("set", pos); - if new_pos and new_pos < size then - response_range = string.format("bytes %d-%d/%s", range_start, last_byte, filesize); - filesize = string.format("%d", size-pos); - else - handle:close(); - return 416; - end + local range_start, range_end = request_range:match("^bytes=(%d*)%-(%d*)$") + local pos, stop = tonumber(range_start) or 0, tonumber(range_end) or size-1; + if range_start == "" then pos, stop = size - stop, size - 1; end + if pos <= stop and stop < size then + pos = handle:seek("set", pos); + response_len = stop - pos + 1; + response_range = string.format("bytes %d-%d/%s", pos, stop, filesize); + filesize = string.format("%d", response_len); + else + handle:close(); + module:log("debug", "Invalid range requested: %q", request_range) + return 416; end end @@ -477,14 +479,13 @@ response.headers.x_frame_options = "DENY"; -- COMPAT IE missing support for CSP frame-ancestors response.headers.x_xss_protection = "1; mode=block"; - return response:send_file(handle); + return response:send_file(handle, response_len); end if expiry < math.huge and not external_base_url then -- TODO HTTP DELETE to the external endpoint? local array = require "prosody.util.array"; local async = require "prosody.util.async"; - local ENOENT = require "prosody.util.pposix".ENOENT; local function sleep(t) local wait, done = async.waiter(); @@ -570,9 +571,40 @@ local iter = assert(uploads:find(nil)); local count, sum = 0, 0; - for _, file in iter do - sum = sum + tonumber(file.attr.size); + local expected_filenames = {}; + for slot_id, file in iter do count = count + 1; + + local expected_size = tonumber(file.attr.size); + local filename = get_filename(slot_id); + local actual_size, err = lfs.attributes(filename, "size"); + expected_filenames[filename] = true; + if not actual_size then + module:log("warn", "Missing file for upload slot %s: %s", slot_id, err); + count = count - 1; + elseif actual_size ~= expected_size then + module:log("warn", "Size mismatch for upload slot %s, expected %s, actual %s", slot_id, B(expected_size), B(actual_size)); + -- TODO delete? + sum = sum + actual_size; + else + sum = sum + expected_size; + end + end + + local upload_dir = dm.getpath("", module.host, module.name, ""):gsub("/[^/]*$", ""); + local upload_dir_mode, err, errno = lfs.attributes(upload_dir, "mode"); + if upload_dir_mode == "directory" then + for file in lfs.dir(upload_dir) do + local file_path = join_path(upload_dir, file); + if file ~= "." and file ~= ".." and not expected_filenames[file_path] then + local mode, err = lfs.attributes(file_path, "mode"); + module:log("warn", "Unexpected %s in upload storage: %s", mode or "file", err or file); + end + end + elseif upload_dir_mode then + module:log("warn", "Unexpected type of upload directory: %s", upload_dir_mode); + elseif err and errno ~= ENOENT then + module:log("debug", "Could not determine type of upload directory: %s"); end module:log("info", "Uploaded files total: %s in %d files", B(sum), count);
--- a/plugins/mod_http_files.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_http_files.lua Sat May 30 19:22:51 2026 +0200 @@ -11,7 +11,7 @@ local open = io.open; local fileserver = require"prosody.net.http.files"; -local base_path = module:get_option_path("http_files_dir", module:get_option_path("http_path")); +local base_path = assert(module:get_option_path("http_files_dir", module:get_option_path("http_path")), "missing required setting 'http_files_dir'"); local cache_size = module:get_option_integer("http_files_cache_size", 128, 1); local cache_max_file_size = module:get_option_integer("http_files_cache_max_file_size", 4096, 1); local dir_indices = module:get_option_array("http_index_files", { "index.html", "index.htm" }); @@ -55,37 +55,12 @@ end -- COMPAT -- TODO deprecate -function serve(opts) - if type(opts) ~= "table" then -- assume path string - opts = { path = opts }; - end - if opts.directory_index == nil then - opts.directory_index = directory_index; - end - if opts.mime_map == nil then - opts.mime_map = mime_map; - end - if opts.cache_size == nil then - opts.cache_size = cache_size; - end - if opts.cache_max_file_size == nil then - opts.cache_max_file_size = cache_max_file_size; - end - if opts.index_files == nil then - opts.index_files = dir_indices; - end - module:log("warn", "%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module()); - return fileserver.serve(opts); +function serve() + error(string.format("%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module())); end -function wrap_route(routes) - module:log("debug", "%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module()); - for route,handler in pairs(routes) do - if type(handler) ~= "function" then - routes[route] = fileserver.serve(handler); - end - end - return routes; +function wrap_route() + error(string.format("%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module())); end module:provides("http", {
--- a/plugins/mod_invites.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_invites.lua Sat May 30 19:22:51 2026 +0200 @@ -77,9 +77,9 @@ end -- Create invitation to register an account (optionally restricted to the specified username) -function create_account(account_username, additional_data, ttl) --luacheck: ignore 131/create_account +function create_account(account_username, additional_data, ttl, reusable) --luacheck: ignore 131/create_account local jid = account_username and (account_username.."@"..module.host) or module.host; - return create_invite("register", jid, true, additional_data, ttl); + return create_invite("register", jid, true, additional_data, ttl, reusable); end -- Create invitation to reset the password for an account @@ -88,8 +88,8 @@ end -- Create invitation to become a contact of a local user -function create_contact(username, allow_registration, additional_data, ttl) --luacheck: ignore 131/create_contact - return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl); +function create_contact(username, allow_registration, additional_data, ttl, reusable) --luacheck: ignore 131/create_contact + return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl, reusable); end -- Create invitation to register an account and join a user group @@ -261,7 +261,7 @@ flags = { array_params = { role = true, group = have_group_invites }; value_params = { expires_after = true }; - kv_params = { admin = true }; + bool_params = { admin = true, reusable = true }; }; handler = function (self, user_jid, opts) --luacheck: ignore 212/self @@ -285,7 +285,7 @@ local invite = assert(create_account(username, { roles = roles; groups = groups; - }, ttl)); + }, ttl, opts.reusable)); return true, invite.landing_page or invite.uri; end; @@ -330,7 +330,7 @@ host_selector = "user_jid"; flags = { value_params = { expires_after = true }; - kv_params = { allow_registration = true }; + bool_params = { allow_registration = true, reusable = true }; }; handler = function (self, user_jid, opts) --luacheck: ignore 212/self @@ -345,7 +345,7 @@ return nil, "Unable to parse duration: "..opts.expires_after; end end - local invite, err = create_contact(username, opts and opts.allow_registration, nil, ttl); + local invite, err = create_contact(username, opts and opts.allow_registration, nil, ttl, opts.reusable); if not invite then return nil, err; end return true, invite.landing_page or invite.uri; end; @@ -386,6 +386,7 @@ print("Created:", os.date("%Y-%m-%d %T", invite.created_at)); print("Expires:", os.date("%Y-%m-%d %T", invite.expires)); + print("Single-use:", not invite.reusable and "Yes" or "No"); print("");
--- a/plugins/mod_mam/mod_mam.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_mam/mod_mam.lua Sat May 30 19:22:51 2026 +0200 @@ -195,6 +195,7 @@ local first, last; local count = 0; local complete = "true"; + local oldest, newest; for id, item, when in data do count = count + 1; if count > qmax then @@ -217,6 +218,13 @@ if not first then first = id; end last = id; + if not newest or when > newest then + newest = when; + end + if not oldest or when < oldest then + oldest = when; + end + if flip then results[count] = fwd_st; else @@ -238,6 +246,15 @@ :add_child(rsm.generate { first = first, last = last, count = total })); + module:fire_event("archive-query-result", { + origin = origin; + stanza = stanza; + first_time = oldest; + last_time = newest; + first_id = reverse and last or first; + last_id = reverse and first or last; + }); + -- That's all folks! module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1); return true; @@ -466,6 +483,7 @@ local clone_for_other_handlers = st.clone(stanza); local id = ok; clone_for_other_handlers:tag("stanza-id", { xmlns = xmlns_st_id, by = store_user.."@"..host, id = id }):up(); + clone_for_other_handlers:set_meta("archive-id", id); event.stanza = clone_for_other_handlers; schedule_cleanup(store_user); module:fire_event("archive-message-added", { origin = origin, stanza = clone_for_storage, for_user = store_user, id = id }); @@ -588,14 +606,11 @@ cleanup_map:set(cut_off, user, true); module:log("error", "Could not delete messages for user '%s': %s", user, err); end - local wait, done = async.waiter(); - module:add_timer(0.01, done); - wait(); + async.sleep(0.1); end module:log("info", "Deleted %d expired messages for %d users", sum, num_users); cleanup_done(); end); - else module:log("debug", "Archive expiry disabled"); -- Don't ask the backend to count the potentially unbounded number of items,
--- a/plugins/mod_muc_mam.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_muc_mam.lua Sat May 30 19:22:51 2026 +0200 @@ -34,7 +34,7 @@ local timestamp, datestamp = import("prosody.util.datetime", "datetime", "date"); local default_max_items, max_max_items = 20, module:get_option_integer("max_archive_query_results", 50, 0); -local cleanup_after = module:get_option_period("muc_log_expires_after", "1w"); +local cleanup_after = module:get_option_period("muc_log_expires_after", module:get_option("archive_expires_after", "1w")); local default_history_length = 20; local max_history_length = module:get_option_integer("max_history_messages", math.huge, 0);
--- a/plugins/mod_offline.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_offline.lua Sat May 30 19:22:51 2026 +0200 @@ -9,11 +9,22 @@ local datetime = require "prosody.util.datetime"; local jid_split = require "prosody.util.jid".split; +local datestamp = require "prosody.util.datetime".date; + +local cleanup_after = module:get_option_period("offline_expires_after", "never"); local offline_messages = module:open_store("offline", "archive"); module:add_feature("msgoffline"); +function schedule_cleanup(_username, _date) -- luacheck: ignore 212 + -- Called to make a note of which users have messages on which days, which in + -- turn is used to optimize the message expiry routine. + -- + -- This noop is conditionally replaced later depending on retention settings + -- and storage backend capabilities. +end + module:hook("message/offline/handle", function(event) local origin, stanza = event.origin, event.stanza; local to = stanza.attr.to; @@ -26,6 +37,7 @@ local ok = offline_messages:append(node, nil, stanza, os.time(), ""); if ok then + schedule_cleanup(node); module:log("debug", "Saved to offline storage: %s", stanza:top_tag()); end return ok; @@ -49,3 +61,93 @@ end return true; end, -1); + +if cleanup_after ~= math.huge then + local cleanup_storage = module:open_store("offline_cleanup"); + local cleanup_map = module:open_store("offline_cleanup", "map"); + + module:log("debug", "offline_expires_after = %d -- in seconds", cleanup_after); + + if not offline_messages.delete then + module:log("error", "offline_expires_after set but mod_%s does not support deleting", offline_messages._provided_by); + return false; + end + + -- For each day, store a set of users that have new messages. To expire + -- messages, we collect the union of sets of users from dates that fall + -- outside the cleanup range. + + if not (offline_messages.caps and offline_messages.caps.wildcard_delete) then + local last_date = require "prosody.util.cache".new(module:get_option_integer("archive_cleanup_date_cache_size", 1000, 1)); + function schedule_cleanup(username, date) + date = date or datestamp(); + if last_date:get(username) == date then return end + local ok = cleanup_map:set(date, username, true); + if ok then + last_date:set(username, date); + end + end + end + + local cleanup_time = module:measure("cleanup", "times"); + + local async = require "prosody.util.async"; + module:daily("Remove expired offline messages", function () + local cleanup_done = cleanup_time(); + + if offline_messages.caps and offline_messages.caps.wildcard_delete then + local ok, err = offline_messages:delete(true, { ["end"] = os.time() - cleanup_after }) + if ok then + local sum = tonumber(ok); + if sum then + module:log("info", "Deleted %d expired messages", sum); + else + -- driver did not tell + module:log("info", "Deleted all expired messages"); + end + else + module:log("error", "Could not delete messages: %s", err); + end + cleanup_done(); + return; + end + + local users = {}; + local cut_off = datestamp(os.time() - cleanup_after); + for date in cleanup_storage:users() do + if date <= cut_off then + module:log("debug", "Messages from %q should be expired", date); + local messages_this_day = cleanup_storage:get(date); + if messages_this_day then + for user in pairs(messages_this_day) do + users[user] = true; + end + if date < cut_off then + -- Messages from the same day as the cut-off might not have expired yet, + -- but all earlier will have, so clear storage for those days. + cleanup_storage:set(date, nil); + end + end + end + end + local sum, num_users = 0, 0; + for user in pairs(users) do + local ok, err = offline_messages:delete(user, { ["end"] = os.time() - cleanup_after; }) + if ok then + num_users = num_users + 1; + sum = sum + (tonumber(ok) or 0); + else + cleanup_map:set(cut_off, user, true); + module:log("error", "Could not delete messages for user '%s': %s", user, err); + end + local wait, done = async.waiter(); + module:add_timer(0.01, done); + wait(); + end + module:log("info", "Deleted %d expired messages for %d users", sum, num_users); + cleanup_done(); + end); + +else + module:log("debug", "Offline message expiry disabled"); +end
--- a/plugins/mod_pubsub/mod_pubsub.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_pubsub/mod_pubsub.lua Sat May 30 19:22:51 2026 +0200 @@ -30,6 +30,10 @@ return lib_pubsub.handle_pubsub_iq(event, service); end +function handle_pubsub_message(event) + return lib_pubsub.handle_pubsub_message(event, service); +end + -- An itemstore supports the following methods: -- items(): iterator over (id, item) -- get(id): return item with id @@ -43,6 +47,10 @@ -- get(node_name) -- users(): iterator over (node_name) +local supported_access_models = require "prosody.util.set".new({ + "open", "whitelist", "authorize" +}); + local max_max_items = module:get_option_integer("pubsub_max_items", 256, 1); local function tonumber_max_items(n) @@ -130,8 +138,7 @@ if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then return false; end - if new_config["access_model"] ~= "whitelist" - and new_config["access_model"] ~= "open" then + if not supported_access_models:contains(new_config["access_model"]) then return false; end return true; @@ -141,6 +148,12 @@ return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item" and #item.tags == 1; end +function get_item_metadata(item) + return { + publisher = item.attr.publisher; + }; +end + -- Compose a textual representation of Atom payloads local summary_templates = module:get_option("pubsub_summary_templates", { ["http://www.w3.org/2005/Atom"] = "{@pubsub:title|and{*{@pubsub:title}*\n\n}}{summary|or{{author/name|and{{author/name} posted }}{title}}}"; @@ -167,6 +180,8 @@ module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq); module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq); +module:hook("message/host", handle_pubsub_message); + local function add_disco_features_from_service(service) --luacheck: ignore 431/service for feature in lib_pubsub.get_feature_set(service) do module:add_feature(xmlns_pubsub.."#"..feature); @@ -231,11 +246,13 @@ service.config.itemstore = create_simple_itemstore; service.config.broadcaster = simple_broadcast; service.config.itemcheck = is_item_stanza; + service.config.iteminfo = get_item_metadata; service.config.check_node_config = check_node_config; service.config.get_affiliation = get_affiliation; module.environment.service = service; add_disco_features_from_service(service); + lib_pubsub.add_service_hooks(service); end function module.save() @@ -262,6 +279,7 @@ itemstore = create_simple_itemstore; broadcaster = simple_broadcast; itemcheck = is_item_stanza; + iteminfo = get_item_metadata; check_node_config = check_node_config; metadata_subset = { "title";
--- a/plugins/mod_pubsub/pubsub.lib.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_pubsub/pubsub.lib.lua Sat May 30 19:22:51 2026 +0200 @@ -5,6 +5,7 @@ local st = require "prosody.util.stanza"; local it = require "prosody.util.iterators"; local uuid_generate = require "prosody.util.uuid".generate; +local get_form_type = require "prosody.util.dataforms".get_type; local dataform = require"prosody.util.dataforms".new; local errors = require "prosody.util.error"; @@ -17,6 +18,9 @@ local handlers = {}; _M.handlers = handlers; +local form_handlers = {}; +_M.form_handlers = form_handlers; + local pubsub_errors = errors.init("pubsub", xmlns_pubsub_errors, { ["conflict"] = { "cancel", "conflict" }; ["invalid-jid"] = { "modify", "bad-request", nil, "invalid-jid" }; @@ -32,6 +36,7 @@ ["precondition-not-met"] = { "cancel", "conflict", nil, "precondition-not-met" }; ["invalid-item"] = { "modify", "bad-request", "invalid item" }; ["persistent-items-unsupported"] = { "cancel", "feature-not-implemented", nil, "persistent-items" }; + ["already-subscribed"] = { "cancel", "bad-request" }; }); local function pubsub_error_reply(stanza, error, context) local err = pubsub_errors.wrap(error, context); @@ -228,11 +233,37 @@ }; _M.node_metadata_form = node_metadata_form; +local sub_approval_form = dataform { + { + type = "hidden"; + var = "FORM_TYPE"; + value = "http://jabber.org/protocol/pubsub#subscribe_authorization"; + }; + { + type = "text-single"; + name = "node"; + var = "pubsub#node"; + label = "Node name"; + }; + { + type = "jid-single"; + name = "jid"; + var = "pubsub#subscriber_jid"; + label = "Subscriber JID"; + }; + { + type = "boolean"; + name = "allow"; + var = "pubsub#allow"; + label = "Allow subscription"; + }; +}; + local service_method_feature_map = { add_subscription = { "subscribe", "subscription-options" }; create = { "create-nodes", "instant-nodes", "item-ids", "create-and-configure" }; delete = { "delete-nodes" }; - get_items = { "retrieve-items" }; + get_items = { "retrieve-items", "retrieve-affiliations" }; get_subscriptions = { "retrieve-subscriptions" }; node_defaults = { "retrieve-default" }; publish = { "publish", "multi-items", "publish-options" }; @@ -269,7 +300,7 @@ end for affiliation in pairs(service.config.capabilities) do - if affiliation ~= "none" and affiliation ~= "owner" then + if affiliation ~= "none" and affiliation ~= "unauthorized" and affiliation ~= "owner" then supported_features:add(affiliation.."-affiliation"); end end @@ -337,6 +368,29 @@ end end +function _M.handle_pubsub_message(event, service) + local origin, stanza = event.origin, event.stanza; + + if stanza.attr.type == "error" then + return; + end + + local form = stanza:get_child("x", "jabber:x:data"); + if form and form.attr.type == "submit" then + local form_type = get_form_type(form); + if not form_type then return; end + + local form_name = form_type:match("http://jabber.org/protocol/pubsub#([%w_]+)$"); + if not form_name then return; end + + local handler = form_handlers[form_name]; + if not handler then return; end + + handler(origin, stanza, form, service); + return true; + end +end + function handlers.get_items(origin, stanza, items, service) local node = items.attr.node; @@ -398,7 +452,7 @@ function handlers.get_subscriptions(origin, stanza, subscriptions, service) local node = subscriptions.attr.node; - local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from); + local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from, true); if not ok then origin.send(pubsub_error_reply(stanza, ret)); return true; @@ -407,7 +461,7 @@ :tag("pubsub", { xmlns = xmlns_pubsub }) :tag("subscriptions"); for _, sub in ipairs(ret) do - reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up(); + reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = sub.subscription and 'subscribed' or 'pending' }):up(); end origin.send(reply); return true; @@ -415,17 +469,19 @@ function handlers.owner_get_subscriptions(origin, stanza, subscriptions, service) local node = subscriptions.attr.node; - local ok, ret = service:get_subscriptions(node, stanza.attr.from); + local ok, ret = service:get_subscriptions(node, stanza.attr.from, nil, true); if not ok then origin.send(pubsub_error_reply(stanza, ret)); return true; end + local reply = st.reply(stanza) :tag("pubsub", { xmlns = xmlns_pubsub_owner }) :tag("subscriptions"); for _, sub in ipairs(ret) do - reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up(); + reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = sub.subscription and 'subscribed' or 'pending' }):up(); end + origin.send(reply); return true; end @@ -545,7 +601,22 @@ return true end end - local ok, ret = service:add_subscription(node, stanza.attr.from, jid, options); + + -- Test whether we can subscribe or need to request a subscription + -- Known issue: this test fails if we are subscribing someone else, + -- but it would be an unusual case to have permission to do that but + -- not subscribe ourselves. + local ok, ret, success_state; + if service:may(node, stanza.attr.from, "subscribe") then + module:log("debug", "Subscribing to %s", node); + success_state = "subscribed"; + ok, ret = service:add_subscription(node, stanza.attr.from, jid, options); + else + module:log("debug", "Requesting subscription to %s (%q %q)", node, stanza.attr.from, jid); + success_state = "pending"; + ok, ret = service:request_subscription(node, stanza.attr.from, jid, options); + end + local reply; if ok then reply = st.reply(stanza) @@ -553,7 +624,7 @@ :tag("subscription", { node = node, jid = jid, - subscription = "subscribed" + subscription = success_state; }):up(); if options_tag then reply:add_child(options_tag); @@ -793,6 +864,33 @@ return true; end +function handlers.get_affiliations(origin, stanza, affiliations, service) + local ok, nodes = service:get_nodes(stanza.attr.from); + if not ok then + origin.send(pubsub_error_reply(stanza, nodes)); + return true; + end + local node = affiliations.attr.node; + if node then + nodes = { [node] = nodes[node] }; + end + + local reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("affiliations"); + + local jid = service.config.normalize_jid(stanza.attr.from); + for node_, node_obj in pairs(nodes) do + local affiliation = node_obj.affiliations[jid]; + if affiliation then + reply:tag("affiliation", { node = node_; affiliation = affiliation }):up(); + end + end + + origin.send(reply); + return true; +end + function handlers.owner_get_affiliations(origin, stanza, affiliations, service) local node = affiliations.attr.node; if not node then @@ -844,7 +942,9 @@ local affiliation = affiliation_tag.attr.affiliation; jid = jid_prep(jid); - if affiliation == "none" then affiliation = nil; end + if affiliation == "none" or affiliation == "unauthorized" then + affiliation = nil; + end local ok, err = service:set_affiliation(node, stanza.attr.from, jid, affiliation); if not ok then @@ -946,4 +1046,73 @@ end _M.archive_itemstore = archive_itemstore; +local function notify_subscription_request(event) + local service, node = event.service, event.node; + + local node_obj = service.nodes[node]; + if not node_obj then + return; + end + + local notify_jids = {}; + + for jid, affiliation in pairs(node_obj.affiliations) do + if affiliation == "owner" then + table.insert(notify_jids, jid); + end + end + + if #notify_jids == 0 then + return; + end + + local notification = st.message({ from = module.host }) + :add_child(sub_approval_form:form({ + node = node; + jid = event.jid; + allow = false; + })); + + module:log("debug", "Sending subscription request notification to %d JIDs", #notify_jids); + + if #notify_jids == 1 then + notification.attr.to = notify_jids[1]; + module:send(notification); + return; + end + + for _, recipient in ipairs(notify_jids) do + local clone = st.clone(notification); + clone.attr.to = recipient; + module:send(clone); + end +end + +function _M.form_handlers.subscribe_authorization(origin, stanza, form, service) + local form_data, errs = sub_approval_form:data(form); + if not form_data then + local reply = st.error_reply(stanza, "modify", "not-acceptable", dataform_error_message(errs)); + origin.send(reply); + return; + end + + local node, jid, allow = form_data.node, form_data.jid, form_data.allow; + + local ok, ret; + if allow then + ok, ret = service:add_subscription(node, stanza.attr.from, jid); + else + ok, ret = service:remove_subscription(node, stanza.attr.from, jid); + end + + if not ok then + origin.send(pubsub_error_reply(stanza, ret)); + return; + end +end + +function _M.add_service_hooks(service) + module:hook_object_event(service.events, "subscription-requested", notify_subscription_request); +end + return _M;
--- a/plugins/mod_s2s.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_s2s.lua Sat May 30 19:22:51 2026 +0200 @@ -45,6 +45,7 @@ local authed_stanza_size_limit = module:get_option_integer("s2s_stanza_size_limit", 1024*512, 10000); local max_child_elements = module:get_option_integer("s2s_max_child_elements", 25000, 0); local sendq_size = module:get_option_integer("s2s_send_queue_size", 1024*32, 1); +local block_retries = module:get_option_boolean("s2s_block_immediate_retries", false); local advertised_idle_timeout = 14*60; -- default in all net.server implementations local network_settings = module:get_option("network_settings"); @@ -202,12 +203,29 @@ host.sendq = queue.new(sendq_size); end if not host.sendq:push(st.clone(stanza)) then - host.log("warn", "stanza [%s] not queued ", stanza.name); - if not event.origin or stanza.attr.type == "error" or stanza.attr.type == "result" then return false; end - event.origin.send(st.error_reply(stanza, "wait", "resource-constraint", "Outgoing stanza queue full")); - return true; + -- We shouldn't get here, because the last stanza + -- should have triggered the 'full?' check. + host.log("warn", "Pending connection %s->%s has full queue - unable to queue %s stanza", from_host, to_host, stanza.name); + return false; + else + host.log("debug", "stanza [%s] queued ", stanza.name); end - host.log("debug", "stanza [%s] queued ", stanza.name); + + if host.sendq:full() then + -- A pending connection with a full queue could indicate + -- a stalled connection (trouble authenticating, etc.). + -- Our choice is to keep it open, and discard any additional stanzas + -- (which could cause inconsistencies, e.g. in MUC joins) or + -- force-close the connection. + host.log("warn", "Pending connection %s->%s has full queue - closing connection and bouncing stanzas", from_host, to_host); + host:close({ + condition = "resource-constraint", + text = "Too many pending stanzas for this connection", + }, nil, "Too many pending stanzas for this connection"); + end + + -- Although in some cases the stanza was not delivered, it + -- was processed (queued or bounced), so we return true return true; elseif host.type == "local" or host.type == "component" then log("error", "Trying to send a stanza to ourselves??") @@ -226,6 +244,9 @@ local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza; log("debug", "opening a new outgoing connection for this stanza"); local host_session = s2s_new_outgoing(from_host, to_host); + if block_retries then + host_session.block_retries = true; + end host_session.version = 1; host_session.stanza_size_limit = unauthed_stanza_size_limit; @@ -358,11 +379,11 @@ end else - if session.outgoing and not hosts[to].s2sout[from] then + local host_session = hosts[to]; + if session.outgoing and not host_session.s2sout[from] then session.log("debug", "Setting up to handle route from %s to %s", to, from); - hosts[to].s2sout[from] = session; -- luacheck: ignore 122 + host_session.s2sout[from] = session; -- luacheck: ignore 122 end - local host_session = hosts[to]; session.send = function(stanza) return host_session.events.fire_event("route/remote", { from_host = to, to_host = from, stanza = stanza }); end; @@ -1075,6 +1096,17 @@ module:hook("s2s-check-certificate", check_auth_policy, -1); +-- When an outgoing connection fails, close a corresponding incoming connection with a stream error to indicate the problem to the remote +module:hook("s2s-destroyed", function(event) + local s2sout = event.session; + if s2sout.type ~= "s2sout_unauthed" then return end + for s2sin in pairs(prosody.incoming_s2s) do + if s2sin.from_host == s2sout.to_host and s2sin.to_host == s2sout.from_host then + s2sin:close(errors.new({ condition = "reset", text = "Could not deliver return stanzas, please check your DNS and firewall" })); + end + end +end); + module:hook("server-stopping", function(event) -- Close ports local pm = require "prosody.core.portmanager";
--- a/plugins/mod_s2s_auth_dane_in.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_s2s_auth_dane_in.lua Sat May 30 19:22:51 2026 +0200 @@ -25,11 +25,6 @@ return r; end -local function ensure_nonempty(r) - assert(r[1], "empty"); - return r; -end - local function flatten(a) local seen = {}; local ret = {}; @@ -101,7 +96,8 @@ return promise.all(tlsas):next(flatten); end - local ret = async.wait_for(resolver:lookup_promise("_xmpp-server." .. dns_domain, "TLSA"):next(ensure_secure):next(ensure_nonempty):catch(function() + local ret = async.wait_for(resolver:lookup_promise("_xmpp-server." .. dns_domain, "TLSA"):next(ensure_secure):next(function(ret) + if ret[1] then return ret; end return promise.all({ resolver:lookup_promise("_xmpps-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa); resolver:lookup_promise("_xmpp-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa);
--- a/plugins/mod_storage_sql.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_storage_sql.lua Sat May 30 19:22:51 2026 +0200 @@ -10,6 +10,7 @@ local is_stanza = require"prosody.util.stanza".is_stanza; local t_concat = table.concat; +local t_move = require "prosody.util.table".move; local have_dbisql, dbisql = pcall(require, "prosody.util.sql"); local have_sqlite, sqlite = pcall(require, "prosody.util.sqlite3"); @@ -161,7 +162,7 @@ return result; end function keyval_store:set(username, data) - return engine:transaction(keyval_store_set, data, username, self.store); + return engine:write_transaction(keyval_store_set, data, username, self.store); end function keyval_store:users() local ok, result = engine:transaction(function() @@ -219,7 +220,7 @@ return self:set_keys(username, { [key] = data }); end function map_store:set_keys(username, keydatas) - local ok, result = engine:transaction(function() + local ok, result = engine:write_transaction(function() local delete_sql = [[ DELETE FROM "prosody" WHERE "host"=? AND "user"=? AND "store"=? AND "key"=?; @@ -303,7 +304,7 @@ if type(key) ~= "string" or key == "" then return nil, "delete_all only supports non-empty string keys"; end - local ok, result = engine:transaction(function() + local ok, result = engine:write_transaction(function() local delete_sql = [[ DELETE FROM "prosody" WHERE "host"=? AND "store"=? AND "key"=?; @@ -333,7 +334,7 @@ if archive_item_limit then if not item_count then item_count_cache_miss(); - local ok, ret = engine:transaction(function() + local ok, ret = engine:write_transaction(function() local count_sql = [[ SELECT COUNT(*) FROM "prosodyarchive" WHERE "host"=? AND "user"=? AND "store"=?; @@ -367,7 +368,7 @@ when = math.floor(when); end with = with or ""; - local ok, ret = engine:transaction(function() + local ok, ret = engine:write_transaction(function() local delete_sql = [[ DELETE FROM "prosodyarchive" WHERE "host"=? AND "user"=? AND "store"=? AND "key"=?; @@ -430,9 +431,7 @@ if query.ids then local nids, nargs = #query.ids, #args; where[#where + 1] = "\"key\" IN (" .. string.rep("?", nids, ",") .. ")"; - for i, id in ipairs(query.ids) do - args[nargs+i] = id; - end + t_move(query.ids, 1, nids, nargs + 1, args); end end local function archive_where_id_range(query, args, where) @@ -542,7 +541,7 @@ function archive_store:set(username, key, new_value, new_when, new_with) local user,store = username,self.store; - local ok, result = engine:transaction(function () + local ok, result = engine:write_transaction(function () local update_query = [[ UPDATE "prosodyarchive" @@ -621,7 +620,7 @@ function archive_store:delete(username, query) query = query or {}; local user,store = username,self.store; - local ok, stmt = engine:transaction(function() + local ok, stmt = engine:write_transaction(function() local sql_query = "DELETE FROM \"prosodyarchive\" WHERE %s;"; local args = { host, user or "", store, }; local where = { "\"host\" = ?", "\"user\" = ?", "\"store\" = ?", }; @@ -751,7 +750,7 @@ end function driver:purge(username) - return engine:transaction(function() + return engine:write_transaction(function() engine:delete("DELETE FROM \"prosody\" WHERE \"host\"=? AND \"user\"=?", host, username); engine:delete("DELETE FROM \"prosodyarchive\" WHERE \"host\"=? AND \"user\"=?", host, username); end);
--- a/plugins/mod_storage_xep0227.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/mod_storage_xep0227.lua Sat May 30 19:22:51 2026 +0200 @@ -251,10 +251,10 @@ usere:add_child(roster); for contact_jid, item in pairs(data) do if contact_jid ~= false then - contact_jid = jid_bare(jid_prep(contact_jid)); - if contact_jid ~= user_jid then -- Skip self-contacts + local bare_contact_jid = jid_bare(jid_prep(contact_jid)); + if bare_contact_jid ~= user_jid then -- Skip self-contacts roster:tag("item", { - jid = contact_jid, + jid = bare_contact_jid, subscription = item.subscription, ask = item.ask, name = item.name,
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/plugins/muc/gc3.lib.lua Sat May 30 19:22:51 2026 +0200 @@ -0,0 +1,57 @@ + +local jid_bare = require "prosody.util.jid".bare; + +local st = require "prosody.util.stanza"; + +local muc_util = module:require "muc/util"; +local valid_affiliations = muc_util.valid_affiliations; + +local xmlns_gc3 = "urn:xmpp:gc3:tmp" + +--luacheck: ignore 113/get_room_from_jid + +function fetch_participant_list(room, stanza, origin) + local from_jid = stanza.attr.from; + local from_aff = room:get_affiliation(from_jid); + local from_rank = valid_affiliations[from_aff or "none"]; + + local required_aff_rank = valid_affiliations[room:get_members_only() and "member" or "none"]; + if from_rank < required_aff_rank then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + + local can_see_real_jids = not room:is_anonymous_for(from_jid); + + local reply = st.reply(stanza) + :tag("participants", { xmlns = xmlns_gc3 }); + + for jid, aff in room:each_affiliation() do + local nick = room:get_registered_nick(jid); + local visible_jid = can_see_real_jids and jid or nil; + reply:text_tag("item", nil, { + affiliation = aff; + jid = visible_jid; + nick = nick; + id = room:get_occupant_id_from_jid(jid); + }); + end + + origin.send(reply); + return true; +end + +local function room_event_handler(handler, allow_nonexistent) + return 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 not room and allow_nonexistent ~= true then + origin.send(st.error_reply(stanza, "cancel", "item-not-found")); + return true; + end + return handler(room, stanza, origin); + end; +end + +module:hook("iq-get/bare/"..xmlns_gc3..":participants", room_event_handler(fetch_participant_list));
--- a/plugins/muc/mod_muc.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/muc/mod_muc.lua Sat May 30 19:22:51 2026 +0200 @@ -102,6 +102,11 @@ local occupant_id = module:require "muc/occupant_id"; room_mt.get_salt = occupant_id.get_room_salt; room_mt.get_occupant_id = occupant_id.get_occupant_id; +room_mt.get_occupant_id_from_jid = occupant_id.get_occupant_id_from_jid; + +if module:get_option_boolean("muc_enable_experimental_gc3", false) then + module:require "muc/gc3"; +end local jid_split = require "prosody.util.jid".split; local jid_prep = require "prosody.util.jid".prep;
--- a/plugins/muc/muc.lib.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/muc/muc.lib.lua Sat May 30 19:22:51 2026 +0200 @@ -24,6 +24,7 @@ local base64 = require "prosody.util.encodings".base64; local hmac_sha256 = require "prosody.util.hashes".hmac_sha256; local new_id = require "prosody.util.id".medium; +local time = require "prosody.util.time"; local log = module._log; @@ -133,11 +134,27 @@ return occupant end -function room_mt:route_to_occupant(occupant, stanza) +function room_mt:route_to_occupant(occupant, stanza, session_filter) local to = stanza.attr.to; - for jid in occupant:each_session() do - stanza.attr.to = jid; - self:route_stanza(stanza); + for session_jid, session_pres, session_meta in occupant:each_session() do + if session_filter then + stanza = session_filter(session_jid, session_pres, session_meta, stanza); + end + if stanza then + stanza.attr.to = session_jid; + self:route_stanza(stanza); + end + end + stanza.attr.to = to; +end + +function room_mt:route_presence_to_occupant(occupant, stanza) + local to = stanza.attr.to; + for jid, _, session_meta in occupant:each_session() do + if not session_meta.filter_presence then + stanza.attr.to = jid; + self:route_stanza(stanza); + end end stanza.attr.to = to; end @@ -297,7 +314,7 @@ end if recipient then - return self:route_to_occupant(recipient, get_p(recipient)); + return self:route_presence_to_occupant(recipient, get_p(recipient)); end local broadcast_roles = self:get_presence_broadcast(); @@ -305,11 +322,11 @@ for occupant_nick, n_occupant in self:each_occupant() do if occupant_nick ~= occupant.nick then if broadcast_roles[occupant.role or "none"] or force_unavailable then - self:route_to_occupant(n_occupant, get_p(n_occupant)); + self:route_presence_to_occupant(n_occupant, get_p(n_occupant)); elseif prev_role and broadcast_roles[prev_role] then local pr = get_p(n_occupant); pr.attr.type = 'unavailable'; - self:route_to_occupant(n_occupant, pr); + self:route_presence_to_occupant(n_occupant, pr); end end @@ -332,10 +349,14 @@ end end -function room_mt:send_occupant_list(to, filter) - local to_bare = jid_bare(to); +-- Send the current room list to an occupant (specifically to the real jid 'to', +-- which is one of the occupant's sessions). +function room_mt:send_occupant_list(to_occupant, to_jid, filter) + local to_session = to_occupant:get_session(to_jid); + if to_session.filter_presence then return; end + local to_bare = to_occupant.bare_jid; local broadcast_roles = self:get_presence_broadcast(); - local is_anonymous = self:is_anonymous_for(to); + local is_anonymous = self:is_anonymous_for(to_jid); local broadcast_bare_jids = {}; -- Track which bare JIDs we have sent presence for for occupant_jid, occupant in self:each_occupant() do broadcast_bare_jids[occupant.bare_jid] = true; @@ -343,7 +364,7 @@ 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.attr.to = to_jid; pres:add_child(x); module:fire_event("muc-build-occupant-presence", { room = self, occupant = occupant, stanza = pres }); self:route_stanza(pres); @@ -356,7 +377,7 @@ if (nick or not is_anonymous) and not broadcast_bare_jids[affiliated_jid] and (filter == nil or filter(affiliated_jid, nil)) then local from = nick and (self.jid.."/"..nick) or self.jid; - local pres = st.presence({ to = to, from = from, type = "unavailable" }) + local pres = st.presence({ to = to_jid, from = from, type = "unavailable" }) :tag("x", { xmlns = 'http://jabber.org/protocol/muc#user' }) :tag("item", { affiliation = affiliation; @@ -758,7 +779,7 @@ if orig_occupant == nil or muc_x then -- Send occupant list to newly joined or desynced user - self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212 + self:send_occupant_list(dest_occupant, real_jid, function(nick, occupant) -- luacheck: ignore 212 -- Don't include self return (not occupant) or occupant:get_presence(real_jid) == nil; end) @@ -1437,6 +1458,15 @@ end end + data = data or {}; + local old_data = self._affiliation_data[jid]; + + local current_time = time.now(); + data.affiliation_created_at = old_data and old_data.affiliation_created_at or current_time; + data.affiliation_created_by = old_data and old_data.affiliation_created_by or actor; + data.affiliation_modified_at = current_time; + data.affiliation_modified_by = actor; + local event_data = { room = self; actor = actor; @@ -1444,7 +1474,7 @@ affiliation = affiliation or "none"; reason = reason; previous_affiliation = target_affiliation or "none"; - data = data and data or nil; -- coerce false to nil + data = data; previous_data = self._affiliation_data[jid] or nil; }; @@ -1453,10 +1483,6 @@ local err = event_data.error or { type = "cancel", condition = "not-allowed" }; return nil, err.type, err.condition; end - if affiliation and not data and event_data.data then - -- Allow handlers to add data when none was going to be set - data = event_data.data; - end -- Set in 'database' self._affiliations[jid] = affiliation; @@ -1514,7 +1540,7 @@ (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 + self:send_occupant_list(occupant, real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433 return (not occupant) or occupant.bare_jid ~= jid; end); end
--- a/plugins/muc/occupant.lib.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/muc/occupant.lib.lua Sat May 30 19:22:51 2026 +0200 @@ -23,10 +23,11 @@ -- Deep copy an occupant local function copy_occupant(occupant) local sessions = {}; - for full_jid, presence_stanza in pairs(occupant.sessions) do + for full_jid, session in pairs(occupant.sessions) do + local presence_stanza = session.presence; -- 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; + sessions[full_jid] = session; end end return setmetatable({ @@ -48,12 +49,16 @@ return nil; end +function occupant_mt:get_session(real_jid) + return self.sessions[real_jid]; +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; + self.sessions[real_jid] = { presence = pr }; if replace_primary then self.jid = real_jid; elseif self.jid == nil or (pr.attr.type == "unavailable" and self.jid == real_jid) then @@ -70,12 +75,23 @@ end end +-- An iterator over self.sessions that returns (jid, presence, session) +local function _session_iter(s, var) + local k = next(s, var); + if k then + local v = s[k]; + return k, v.presence, v; + end +end + function occupant_mt:each_session() - return pairs(self.sessions) + return _session_iter, self.sessions; end function occupant_mt:get_presence(real_jid) - return self.sessions[real_jid or self.jid] + local session = self.sessions[real_jid or self.jid]; + if not session then return; end + return session.presence; end return {
--- a/plugins/muc/occupant_id.lib.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/muc/occupant_id.lib.lua Sat May 30 19:22:51 2026 +0200 @@ -31,6 +31,11 @@ return occupant.stable_id; end +local function get_occupant_id_from_jid(room, real_bare_jid) + local salt = get_room_salt(room) + return b64encode(hmac_sha256(real_bare_jid, salt)); +end + local function update_occupant(event) local stanza, room, occupant, dest_occupant = event.stanza, event.room, event.occupant, event.dest_occupant; @@ -73,4 +78,5 @@ return { get_room_salt = get_room_salt; get_occupant_id = get_occupant_id; + get_occupant_id_from_jid = get_occupant_id_from_jid; };
--- a/plugins/muc/register.lib.lua Sat May 30 19:21:08 2026 +0200 +++ b/plugins/muc/register.lib.lua Sat May 30 19:22:51 2026 +0200 @@ -4,9 +4,10 @@ local st = require "prosody.util.stanza"; local dataforms = require "prosody.util.dataforms"; -local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", false); +local gc3_mode = module:get_option_boolean("muc_enable_experimental_gc3", false); -local enforce_nick = module:get_option_boolean("enforce_registered_nickname", false); +local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", gc3_mode); +local enforce_nick = module:get_option_boolean("enforce_registered_nickname", gc3_mode); -- Whether to include the current registration data as a dataform. Disabled -- by default currently as it hasn't been widely tested with clients. @@ -125,9 +126,16 @@ if affiliation == "outcast" then origin.send(st.error_reply(stanza, "auth", "forbidden")); return true; - elseif not (affiliation or allow_unaffiliated) then - origin.send(st.error_reply(stanza, "auth", "registration-required")); - return true; + elseif not affiliation then + if room:get_members_only() then + -- Never allow unaffiliated users to register their way into a members-only room + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + elseif not allow_unaffiliated then + -- Not members-only, but we're not permitting registration due to config + origin.send(st.error_reply(stanza, "auth", "registration-required")); + return true; + end end local reply = st.reply(stanza); local registered_nick = get_registered_nick(room, user_jid);
--- a/prosody Sat May 30 19:21:08 2026 +0200 +++ b/prosody Sat May 30 19:22:51 2026 +0200 @@ -85,7 +85,7 @@ prosody.events.fire_event("very-bad-error", {error = err, traceback = traceback}); end - local sleep = require"socket".sleep; + local sleep = require "prosody.util.time".sleep; local server = require "prosody.net.server"; while select(2, xpcall(server.loop, catch_uncaught_error)) ~= "quitting" do
--- a/prosodyctl Sat May 30 19:21:08 2026 +0200 +++ b/prosodyctl Sat May 30 19:22:51 2026 +0200 @@ -65,13 +65,13 @@ local configmanager = require "prosody.core.configmanager"; local modulemanager = require "prosody.core.modulemanager" local prosodyctl = require "prosody.util.prosodyctl" -local socket = require "socket" local dependencies = require "prosody.util.dependencies"; local lfs = dependencies.softreq "lfs"; ----------------------- local parse_args = require "prosody.util.argparse".parse; +local sleep = require "prosody.util.time".sleep; local human_io = require "prosody.util.human.io"; local show_message, show_warning = prosodyctl.show_message, prosodyctl.show_warning; @@ -265,7 +265,7 @@ show_message("Prosody is still not running. Please give it some time or check your log files for errors."); return 2; end - socket.sleep(0.5); + sleep(0.5); i = i + 1; end show_message("Started"); @@ -346,7 +346,7 @@ show_message("Prosody is still running. Please give it some time or check your log files for errors."); return 2; end - socket.sleep(0.5); + sleep(0.5); i = i + 1; end show_message("Stopped"); @@ -456,6 +456,7 @@ -- These diverge from the module._VERSION convention readline = "Version"; } + dependencies.softreq"socket"; local lunbound = dependencies.softreq"lunbound"; local lxp = dependencies.softreq"lxp"; local hashes = dependencies.softreq"prosody.util.hashes"; @@ -463,15 +464,15 @@ local version_field = alternate_version_fields[name] or "_VERSION"; if type(module) == "table" and rawget(module, version_field) and name ~= "_G" and not name:match("%.") then - name = friendly_names[name] or name; - if #name > longest_name then - longest_name = #name; + local friendly_name = friendly_names[name] or name; + if #friendly_name > longest_name then + longest_name = #friendly_name; end local mod_version = module[version_field]; - if tostring(mod_version):sub(1, #name+1) == name .. " " then - mod_version = mod_version:sub(#name+2); + if tostring(mod_version):sub(1, #friendly_name+1) == friendly_name .. " " then + mod_version = mod_version:sub(#friendly_name+2); end - module_versions[name] = mod_version; + module_versions[friendly_name] = mod_version; end end if lunbound then
--- a/spec/scansion/pubsub_advanced.scs Sat May 30 19:21:08 2026 +0200 +++ b/spec/scansion/pubsub_advanced.scs Sat May 30 19:22:51 2026 +0200 @@ -83,6 +83,22 @@ </iq> Balthasar sends: + <iq type="get" id='aff0' to='pubsub.localhost'> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <affiliations node="princely_musings"/> + </pubsub> + </iq> + +Balthasar receives: + <iq from="pubsub.localhost" id="aff0" type="result"> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <affiliations> + <affiliation affiliation="owner" node="princely_musings"/> + </affiliations> + </pubsub> + </iq> + +Balthasar sends: <iq type="get" id='aff1' to='pubsub.localhost'> <pubsub xmlns="http://jabber.org/protocol/pubsub#owner"> <affiliations node="princely_musings"/>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/spec/scansion/pubsub_authorize.scs Sat May 30 19:22:51 2026 +0200 @@ -0,0 +1,158 @@ +# Pubsub: Node 'authorize' access model + +[Client] Balthasar + jid: admin@localhost + password: password + +[Client] Romeo + jid: romeo@localhost + password: password + +[Client] Juliet + jid: juliet@localhost + password: password + +--------- + +Romeo connects + +Balthasar connects + +Balthasar sends: + <presence/> + +Balthasar receives: + <presence/> + +Balthasar sends: + <iq type='set' to='pubsub.localhost' id='create2'> + <pubsub xmlns='http://jabber.org/protocol/pubsub'> + <create node='secret_musings'/> + <configure> + <x xmlns="jabber:x:data" type="submit"> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/pubsub#node_config</value> + </field> + <field var="pubsub#access_model"> + <value>authorize</value> + </field> + </x> + </configure> + </pubsub> + </iq> + +Balthasar receives: + <iq type="result" id='create2'/> + +Juliet connects + +Juliet sends: + <iq type="set" to="pubsub.localhost" id='sub1'> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <subscribe node="secret_musings" jid="${Romeo's full JID}"/> + </pubsub> + </iq> + +Juliet receives: + <iq type="error" id='sub1'/> + +Juliet sends: + <iq type="set" to="pubsub.localhost" id='sub2'> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <subscribe node="secret_musings" jid="${Juliet's full JID}"/> + </pubsub> + </iq> + +Juliet receives: + <iq type="result" id='sub2'> + <pubsub xmlns='http://jabber.org/protocol/pubsub'> + <subscription jid="${Juliet's full JID}" node='secret_musings' subscription='pending'/> + </pubsub> + </iq> + +Juliet sends: + <iq type="get" to="pubsub.localhost" id="sub3"> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <subscriptions/> + </pubsub> + </iq> + +Juliet receives: + <iq type="result" from="pubsub.localhost" id="sub3"> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <subscriptions> + <subscription jid="${Juliet's full JID}" node='secret_musings' subscription='pending'/> + </subscriptions> + </pubsub> + </iq> + +Balthasar receives: + <message from="pubsub.localhost"> + <x xmlns="jabber:x:data" type="form"> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/pubsub#subscribe_authorization</value> + </field> + <field var="pubsub#node" type="text-single" label='Node name'> + <value>secret_musings</value> + </field> + <field var="pubsub#subscriber_jid" type="jid-single" label='Subscriber JID'> + <value>${Juliet's full JID}</value> + </field> + <field var="pubsub#allow" type="boolean" label='Allow subscription'> + <value>0</value> + </field> + </x> + </message> + +Balthasar sends: + <iq type="get" to="pubsub.localhost" id="subman0"> + <pubsub xmlns="http://jabber.org/protocol/pubsub#owner"> + <subscriptions node="secret_musings"/> + </pubsub> + </iq> + +Balthasar receives: + <iq from="pubsub.localhost" id="subman0" type="result"> + <pubsub xmlns="http://jabber.org/protocol/pubsub#owner"> + <subscriptions> + <subscription node='secret_musings' subscription="pending" jid="${Juliet's full JID}"/> + </subscriptions> + </pubsub> + </iq> + +Balthasar sends: + <message to="pubsub.localhost"> + <x xmlns="jabber:x:data" type="submit"> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/pubsub#subscribe_authorization</value> + </field> + <field var="pubsub#node" type="text-single"> + <value>secret_musings</value> + </field> + <field var="pubsub#subscriber_jid" type="jid-single"> + <value>${Juliet's full JID}</value> + </field> + <field var="pubsub#allow" type="boolean"> + <value>true</value> + </field> + </x> + </message> + + +Balthasar sends: + <iq type="get" to="pubsub.localhost" id="subman1"> + <pubsub xmlns="http://jabber.org/protocol/pubsub#owner"> + <subscriptions node="secret_musings"/> + </pubsub> + </iq> + +Balthasar receives: + <iq from="pubsub.localhost" id="subman1" type="result"> + <pubsub xmlns="http://jabber.org/protocol/pubsub#owner"> + <subscriptions> + <subscription subscription="subscribed" node='secret_musings' jid="${Juliet's full JID}"/> + </subscriptions> + </pubsub> + </iq> + +// vim: syntax=xml:
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/spec/scansion/pubsub_retract_own.scs Sat May 30 19:22:51 2026 +0200 @@ -0,0 +1,152 @@ +# Pubsub: Support for retracting own items on third-party node + +[Client] Romeo + jid: admin@localhost + password: password + +// admin@localhost is assumed to have node creation privileges + +[Client] Juliet + jid: juliet@localhost + password: password + +[Client] Mercutio + jid: mercutio@localhost + password: password + +--------- + +Romeo connects + +Romeo sends: + <iq type="set" to="pubsub.localhost" id='create1'> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <create node="romeos_rambles/comments"/> + <configure> + <x xmlns="jabber:x:data" type="submit"> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/pubsub#node_config</value> + </field> + <field var="pubsub#publish_model"> + <value>subscribers</value> + </field> + </x> + </configure> + </pubsub> + </iq> + +Romeo receives: + <iq type="result" id='create1'/> + +Juliet connects + +Juliet sends: + <iq type="set" to="pubsub.localhost" id='jpub1'> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <publish node="romeos_rambles/comments"> + <item id="current"> + <entry xmlns="http://www.w3.org/2005/Atom"> + <title>Should fail, no permission</title> + <summary>Not yet a subscriber</summary> + </entry> + </item> + </publish> + </pubsub> + </iq> + +Juliet receives: + <iq type="error" from="pubsub.localhost" id="jpub1"> + <error type="auth"> + <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + </error> + </iq> + +Juliet sends: + <iq type="set" to="pubsub.localhost" id='sub1'> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <subscribe node="romeos_rambles/comments" jid="${Juliet's full JID}"/> + </pubsub> + </iq> + +Juliet receives: + <iq type="result" id='sub1'/> + +Juliet sends: + <iq type="set" to="pubsub.localhost" id='jpub2'> + <pubsub xmlns="http://jabber.org/protocol/pubsub"> + <publish node="romeos_rambles/comments"> + <item id="bedd9e0e-923f-465b-9e2e-139fc6ccc7e7"> + <entry xmlns="http://www.w3.org/2005/Atom"> + <title>Thoughts from Juliet</title> + <summary>O sweet sir, thy tender speech doth pierce my soul</summary> + </entry> + </item> + </publish> + </pubsub> + </iq> + +Juliet receives: + <message from='pubsub.localhost' type='headline'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='romeos_rambles/comments' xmlns='http://jabber.org/protocol/pubsub#event'> + <item id='bedd9e0e-923f-465b-9e2e-139fc6ccc7e7' publisher="${Juliet's JID}" xmlns='http://jabber.org/protocol/pubsub#event'> + <entry xmlns='http://www.w3.org/2005/Atom'> + <title xmlns='http://www.w3.org/2005/Atom'> + Thoughts from Juliet + </title> + <summary xmlns='http://www.w3.org/2005/Atom'> + O sweet sir, thy tender speech doth pierce my soul + </summary> + </entry> + </item> + </items> + </event> + </message> + +Juliet receives: + <iq type="result" from="pubsub.localhost" id="jpub2"/> + +Mercutio connects + +Mercutio sends: + <iq type='set' to='pubsub.localhost' id='retract1'> + <pubsub xmlns='http://jabber.org/protocol/pubsub'> + <retract node='romeos_rambles/comments'> + <item id='bedd9e0e-923f-465b-9e2e-139fc6ccc7e7'/> + </retract> + </pubsub> + </iq> + +Mercutio receives: + <iq type="error" from="pubsub.localhost" id="retract1"> + <error type="auth"> + <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + </error> + </iq> + + +Juliet sends: + <iq type='set' to='pubsub.localhost' id='retract2'> + <pubsub xmlns='http://jabber.org/protocol/pubsub'> + <retract node='romeos_rambles/comments'> + <item id='bedd9e0e-923f-465b-9e2e-139fc6ccc7e7'/> + </retract> + </pubsub> + </iq> + +Juliet receives: + <iq type="result" from="pubsub.localhost" id="retract2"/> + +Romeo sends: + <iq type="set" to="pubsub.localhost" id='del1'> + <pubsub xmlns="http://jabber.org/protocol/pubsub#owner"> + <delete node="romeos_rambles/comments"/> + </pubsub> + </iq> + +Romeo receives: + <iq type="result" id='del1'/> + +Romeo disconnects + +// vim: syntax=xml:
--- a/spec/util_argparse_spec.lua Sat May 30 19:21:08 2026 +0200 +++ b/spec/util_argparse_spec.lua Sat May 30 19:22:51 2026 +0200 @@ -42,14 +42,14 @@ do -- Same test with strict mode enabled and all parameters declared - local opts, err = parse({ "--foo"; "-b" }, { kv_params = { foo = true, bar = true }; short_params = { b = "bar" }, strict = true }); + local opts, err = parse({ "--foo"; "-b" }, { bool_params = { foo = true, bar = true }; short_params = { b = "bar" }, strict = true }); assert.falsy(err); assert.same({ foo = true; bar = true }, opts); end end); it("supports value arguments", function() - local opts, err = parse({ "--foo"; "bar"; "--baz=moo" }, { value_params = { foo = true; bar = true } }); + local opts, err = parse({ "--foo"; "bar"; "--baz=moo" }, { value_params = { foo = true; bar = true, baz = true } }); assert.falsy(err); assert.same({ foo = "bar"; baz = "moo" }, opts); end); @@ -96,9 +96,9 @@ assert.same("--foobar", err2); end); - it("accepts known kv parameters in strict mode", function () - local opts, err = parse({ "--item=foo" }, { kv_params = { item = true }, strict = true }); + it("accepts known bool parameters in strict mode", function () + local opts, err = parse({ "--item" }, { bool_params = { item = true }, strict = true }); assert.falsy(err); - assert.same("foo", opts.item); + assert.same(true, opts.item); end); end);
--- a/spec/util_format_spec.lua Sat May 30 19:21:08 2026 +0200 +++ b/spec/util_format_spec.lua Sat May 30 19:22:51 2026 +0200 @@ -15,7 +15,7 @@ assert.equal("% [true]", format("%%", true)); assert.equal("{}", format("%q", {})); assert.equal("[1.5]", format("%d", 1.5)); - assert.equal("[7.3786976294838e+19]", format("%d", 73786976294838206464)); + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%d", 73786976294838206464)); end); it("escapes ascii control stuff", function () @@ -262,7 +262,7 @@ it("works", function () assert.equal("a", format("%c", 97)) assert.equal("[1.5]", format("%c", 1.5)) - assert.equal("[7.3786976294838e+19]", format("%c", 73786976294838206464)) + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%c", 73786976294838206464)) assert.equal("[inf]", format("%c", math.huge)) end); end); @@ -272,7 +272,7 @@ assert.equal("97", format("%d", 97)) assert.equal("-12345", format("%d", -12345)) assert.equal("[1.5]", format("%d", 1.5)) - assert.equal("[7.3786976294838e+19]", format("%d", 73786976294838206464)) + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%d", 73786976294838206464)) assert.equal("[inf]", format("%d", math.huge)) assert.equal("2147483647", format("%d", 2147483647)) end); @@ -283,7 +283,7 @@ assert.equal("97", format("%i", 97)) assert.equal("-12345", format("%i", -12345)) assert.equal("[1.5]", format("%i", 1.5)) - assert.equal("[7.3786976294838e+19]", format("%i", 73786976294838206464)) + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%i", 73786976294838206464)) assert.equal("[inf]", format("%i", math.huge)) assert.equal("2147483647", format("%i", 2147483647)) end); @@ -294,7 +294,7 @@ assert.equal("141", format("%o", 97)) assert.equal("[-12345]", format("%o", -12345)) assert.equal("[1.5]", format("%o", 1.5)) - assert.equal("[7.3786976294838e+19]", format("%o", 73786976294838206464)) + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%o", 73786976294838206464)) assert.equal("[inf]", format("%o", math.huge)) assert.equal("17777777777", format("%o", 2147483647)) end); @@ -305,7 +305,7 @@ assert.equal("97", format("%u", 97)) assert.equal("[-12345]", format("%u", -12345)) assert.equal("[1.5]", format("%u", 1.5)) - assert.equal("[7.3786976294838e+19]", format("%u", 73786976294838206464)) + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%u", 73786976294838206464)) assert.equal("[inf]", format("%u", math.huge)) assert.equal("2147483647", format("%u", 2147483647)) end); @@ -316,7 +316,7 @@ assert.equal("61", format("%x", 97)) assert.equal("[-12345]", format("%x", -12345)) assert.equal("[1.5]", format("%x", 1.5)) - assert.equal("[7.3786976294838e+19]", format("%x", 73786976294838206464)) + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%x", 73786976294838206464)) assert.equal("[inf]", format("%x", math.huge)) assert.equal("7fffffff", format("%x", 2147483647)) end); @@ -327,7 +327,7 @@ assert.equal("61", format("%X", 97)) assert.equal("[-12345]", format("%X", -12345)) assert.equal("[1.5]", format("%X", 1.5)) - assert.equal("[7.3786976294838e+19]", format("%X", 73786976294838206464)) + assert.matches("%[7%.3786976294838%d*e%+19%]", format("%X", 73786976294838206464)) assert.equal("[inf]", format("%X", math.huge)) assert.equal("7FFFFFFF", format("%X", 2147483647)) end); @@ -426,7 +426,7 @@ assert.equal("97", format("%s", 97)) assert.equal("-12345", format("%s", -12345)) assert.equal("1.5", format("%s", 1.5)) - assert.equal("7.3786976294838e+19", format("%s", 73786976294838206464)) + assert.matches("7%.3786976294838%d*e%+19", format("%s", 73786976294838206464)) assert.equal("inf", format("%s", math.huge)) assert.equal("2147483647", format("%s", 2147483647)) end);
--- a/spec/util_smqueue_spec.lua Sat May 30 19:21:08 2026 +0200 +++ b/spec/util_smqueue_spec.lua Sat May 30 19:22:51 2026 +0200 @@ -81,4 +81,85 @@ end end) end) + + describe("#add_checkpoint", function() + it("fires the callback when ack reaches the checkpoint", function() + local q = smqueue.new(10); + for i = 1, 4 do q:push(i); end + local called = false; + q:add_checkpoint(function() called = true; end); + assert.falsy(called, "should not fire before ack"); + q:ack(4); + assert.truthy(called, "should fire when ack reaches checkpoint"); + end) + + it("fires when ack exceeds the checkpoint", function() + local q = smqueue.new(10); + for i = 1, 4 do q:push(i); end + local called = false; + q:add_checkpoint(function() called = true; end); + for i = 5, 8 do q:push(i); end + q:ack(7); -- acks past the checkpoint at 4 + assert.truthy(called); + end) + + it("does not fire for acks below the checkpoint threshold", function() + local q = smqueue.new(10); + for i = 1, 5 do q:push(i); end + local called = false; + q:add_checkpoint(function() called = true; end); + q:ack(4); + assert.falsy(called, "should not fire when ack is below checkpoint"); + end) + + it("fires the callback with optional data argument", function() + local q = smqueue.new(10); + for i = 1, 4 do q:push(i); end + local received_data; + q:add_checkpoint(function(d) received_data = d; end, "hello"); + q:ack(4); + assert.equal("hello", received_data); + end) + + it("fires multiple checkpoints in a single ack call", function() + local q = smqueue.new(10); + for i = 1, 3 do q:push(i); end + local fired = {}; + q:add_checkpoint(function() fired[#fired+1] = "first"; end); + q:push(4); + q:add_checkpoint(function() fired[#fired+1] = "second-1"; end); + q:add_checkpoint(function() fired[#fired+1] = "second-2"; end); + q:push(5); + q:add_checkpoint(function() fired[#fired+1] = "third"; end); + q:ack(5); -- should call all checkpoints, in order + assert.same({ "first", "second-1", "second-2", "third" }, fired); + end) + + it("fires checkpoints in order across multiple acks", function() + local q = smqueue.new(10); + for i = 1, 3 do q:push(i); end + local order = {}; + q:add_checkpoint(function() order[#order+1] = 1; end); + q:push(4); + q:add_checkpoint(function() order[#order+1] = 2; end); + q:push(5); + q:add_checkpoint(function() order[#order+1] = 3; end); + q:ack(3); -- fires only first + assert.same({ 1 }, order); + q:ack(5); -- fires second and third + assert.same({ 1, 2, 3 }, order); + end) + + it("fires each checkpoint exactly once", function() + local q = smqueue.new(10); + for i = 1, 4 do q:push(i); end + local count = 0; + q:add_checkpoint(function() count = count + 1; end); + q:ack(4); + q:ack(4); -- duplicate ack + for i = 5, 8 do q:push(i); end + q:ack(8); + assert.equal(1, count); + end) + end) end);
--- a/teal-src/prosody/net/http.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/net/http.d.tl Sat May 30 19:22:51 2026 +0200 @@ -17,6 +17,9 @@ record http_client_options sslctx : sslctx + suppress_errors : boolean + connection_pooling : boolean + use_dane : boolean end record http_options
--- a/teal-src/prosody/plugins/mod_cron.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/plugins/mod_cron.tl Sat May 30 19:22:51 2026 +0200 @@ -36,6 +36,10 @@ local active_hosts : { string : boolean } = { } +if prosody.process_type == "prosodyctl" then + return -- Yes, it happens... +end + function module.add_host(host_module : moduleapi) local last_run_times = host_module:open_store("cron", "map") as map_store<string,integer>; @@ -63,13 +67,13 @@ task.restore = restore_task; task.save = save_task; module:log("debug", "%s task %s added", task.when, task.id); - return true; + return true end local function task_removed(event : task_event) : boolean local task = event.item; host_module:log("debug", "Task %s removed", task.id); - return true; + return true end host_module:handle_items("task", task_added, task_removed, true); @@ -80,13 +84,13 @@ end local function should_run(task : task_spec, last : integer) : boolean - return not last or last + task.period * 0.995 <= os.time(); + return not last or last + task.period * 0.995 <= os.time() end local function run_task(task : task_spec) task:restore(); if not should_run(task, task.last) then - return; + return end local started_at = os.time(); task:run(started_at); @@ -95,7 +99,7 @@ end local function spread(t : number, factor : number) : number - return t * (1 - factor + 2*factor*math.random()); + return t * (1 - factor + 2*factor*math.random()) end local task_runner : async.runner_t<task_spec> = async.runner(run_task); @@ -109,7 +113,7 @@ end end module:log("debug", "Wait %gs", delay); - return delay; + return delay end); -- TODO measure load, pick a good time to do stuff @@ -122,7 +126,7 @@ args = {}; handler = function (self, filter_host : string) local format_table = require "prosody.util.human.io".table; - local it = require "util.iterators"; + local it = require "prosody.util.iterators"; local row = format_table({ { title = "Host", width = "2p" }; { title = "Task", width = "3p" };
--- a/teal-src/prosody/util/array.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/array.d.tl Sat May 30 19:22:51 2026 +0200 @@ -1,9 +1,10 @@ -local record array_t<T> - { T } +local record array_t<T> is { T } + -- TODO methods end local record lib metamethod __call : function () : array_t + -- TODO library functions end return lib
--- a/teal-src/prosody/util/crypto.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/crypto.d.tl Sat May 30 19:22:51 2026 +0200 @@ -2,7 +2,9 @@ record key private_pem : function (key) : string public_pem : function (key) : string + public_raw : function (key) : string get_type : function (key) : string + derive : function (key, key) : string end type base_evp_sign = function (key, message : string) : string @@ -44,9 +46,11 @@ aes_256_ctr_decrypt : Levp_decrypt generate_ed25519_keypair : function () : key + generate_p256_keypair : function () : key import_private_pem : function (string) : key import_public_pem : function (string) : key + import_public_ec_raw : function (string, string) : key parse_ecdsa_signature : function (string, integer) : string, string build_ecdsa_signature : function (r : string, s : string) : string
--- a/teal-src/prosody/util/dataforms.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/dataforms.d.tl Sat May 30 19:22:51 2026 +0200 @@ -5,7 +5,7 @@ title : string instructions : string - record form_field + record form_field is { form_field } enum field_type "boolean" @@ -35,7 +35,6 @@ options : table end - { form_field } enum form_type "form"
--- a/teal-src/prosody/util/datamapper.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/datamapper.tl Sat May 30 19:22:51 2026 +0200 @@ -25,7 +25,7 @@ local json = require "prosody.util.json" local pointer = require "prosody.util.jsonpointer"; -local json_type_name = json.json_type_name; +local type json_type_name = json.json_type_name; local json_schema_object = require "prosody.util.jsonschema" local type schema_t = boolean | json_schema_object @@ -42,7 +42,7 @@ local function totype(t : json_type_name, s : string) : any if not s then return nil end if t == "string" then - return s; + return s elseif t == "boolean" then return toboolean(s) elseif t == "number" or t == "integer" then @@ -63,10 +63,10 @@ local function resolve_schema(schema : schema_t, root : json_schema_object) : schema_t if schema is json_schema_object then if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then - return pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t; + return pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t end end - return schema; + return schema end local function guess_schema_type(schema : json_schema_object) : json_type_name @@ -95,8 +95,6 @@ if propschema is json_schema_object then proptype = guess_schema_type(propschema); - elseif propschema is string then -- Teal says this can never be a string, but it could before so best be sure - error("schema as string is not supported: "..propschema.." {"..current_ns.."}"..propname) end if proptype == "object" or proptype == "array" then @@ -158,7 +156,7 @@ c = s:get_child(nil, namespace); end if c then - return c.name; + return c.name end elseif value_where == "in_attribute" then local attr = name @@ -250,7 +248,7 @@ table.insert(out, totype(proptype, value)); end end - return out; + return out end local function parse (schema : json_schema_object, s : st.stanza_t) : table @@ -269,7 +267,7 @@ return v elseif proptype == "number" and v is number then return string.format("%g", v) - elseif proptype == "integer" and v is number then -- TODO is integer + elseif proptype == "integer" and v is integer then return string.format("%d", v) elseif proptype == "boolean" then return v and "1" or "0" @@ -361,7 +359,7 @@ unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root) end end - return out; + return out elseif s_type == "array" then local itemschema = resolve_schema(schema.items, root) @@ -369,7 +367,7 @@ for _, item in ipairs(t as { string }) do unparse_property(out, item, proptype, itemschema, value_where, name, namespace, current_ns, prefix, single_attribute, root) end - return out; + return out end end
--- a/teal-src/prosody/util/jsonschema.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/jsonschema.tl Sat May 30 19:22:51 2026 +0200 @@ -10,12 +10,8 @@ if not math.type then require "prosody.util.mathcompat" end - -local utf8_enc = rawget(_G, "utf8") or require"prosody.util.encodings".utf8; -local utf8_len = utf8_enc.len or function(s : string) : integer - local _, count = s:gsub("[%z\001-\127\194-\253][\128-\191]*", ""); - return count; -end; +-- XXX util.encodings seems to count differently from the Lua builtin +local utf8_len = rawget(_G, "utf8") and utf8.len or require"prosody.util.encodings".utf8.length; local json = require "prosody.util.json" local null = json.null; @@ -166,7 +162,7 @@ end local type errors = { validation_error } local function mkerr(sloc:string,iloc:string,err:string) : validation_error - return { schemaLocation = sloc; instanceLocation = iloc; error = err }; + return { schemaLocation = sloc; instanceLocation = iloc; error = err } end local function validate (schema : schema_t, data : any, root : json_schema_object, sloc : string, iloc : string, errs:errors) : boolean, errors @@ -186,14 +182,14 @@ if referenced ~= nil and referenced ~= root and referenced ~= schema then if not validate(referenced, data, root, schema["$ref"], iloc, errs) then table.insert(errs, mkerr(sloc.."/$ref", iloc, "Subschema failed validation")) - return false, errs; + return false, errs end end end if not simple_validate(schema.type, data) then table.insert(errs, mkerr(sloc.."/type", iloc, "unexpected type")); - return false, errs; + return false, errs end if schema.type == "object" then @@ -202,7 +198,7 @@ for k in pairs(data) do if not k is string then table.insert(errs, mkerr(sloc.."/type", iloc, "'object' had non-string keys")); - return false, errs; + return false, errs end end end @@ -214,7 +210,7 @@ for i in pairs(data) do if not i is integer then table.insert(errs, mkerr(sloc.."/type", iloc, "'array' had non-integer keys")); - return false, errs; + return false, errs end end end @@ -231,7 +227,7 @@ end if not match then table.insert(errs, mkerr(sloc.."/enum", iloc, "not one of the enumerated values")); - return false, errs; + return false, errs end end @@ -240,42 +236,42 @@ if data is string then if schema.maxLength and utf8_len(data) > schema.maxLength then table.insert(errs, mkerr(sloc.."/maxLength", iloc, "string too long")) - return false, errs; + return false, errs end if schema.minLength and utf8_len(data) < schema.minLength then table.insert(errs, mkerr(sloc.."/maxLength", iloc, "string too short")) - return false, errs; + return false, errs end if schema.luaPattern and not data:match(schema.luaPattern) then table.insert(errs, mkerr(sloc.."/luaPattern", iloc, "string does not match pattern")) - return false, errs; + return false, errs end end if data is number then if schema.multipleOf and (data == 0 or data % schema.multipleOf ~= 0) then table.insert(errs, mkerr(sloc.."/luaPattern", iloc, "not a multiple")) - return false, errs; + return false, errs end if schema.maximum and not ( data <= schema.maximum ) then table.insert(errs, mkerr(sloc.."/maximum", iloc, "number exceeds maximum")) - return false, errs; + return false, errs end if schema.exclusiveMaximum and not ( data < schema.exclusiveMaximum ) then table.insert(errs, mkerr(sloc.."/exclusiveMaximum", iloc, "number exceeds exclusive maximum")) - return false, errs; + return false, errs end if schema.minimum and not ( data >= schema.minimum ) then table.insert(errs, mkerr(sloc.."/minimum", iloc, "number below minimum")) - return false, errs; + return false, errs end if schema.exclusiveMinimum and not ( data > schema.exclusiveMinimum ) then table.insert(errs, mkerr(sloc.."/exclusiveMinimum", iloc, "number below exclusive minimum")) - return false, errs; + return false, errs end end @@ -283,7 +279,7 @@ for i, sub in ipairs(schema.allOf) do if not validate(sub, data, root, sloc.."/allOf/"..i, iloc, errs) then table.insert(errs, mkerr(sloc.."/allOf", iloc, "did not match all subschemas")) - return false, errs; + return false, errs end end end @@ -297,7 +293,7 @@ end if valid ~= 1 then table.insert(errs, mkerr(sloc.."/oneOf", iloc, "did not match exactly one subschema")) - return false, errs; + return false, errs end end @@ -311,14 +307,14 @@ end if not match then table.insert(errs, mkerr(sloc.."/anyOf", iloc, "did not match any subschema")) - return false, errs; + return false, errs end end if schema["not"] ~= nil then if validate(schema["not"], data, root, sloc.."/not", iloc, errs) then table.insert(errs, mkerr(sloc.."/not", iloc, "did match subschema")) - return false, errs; + return false, errs end end @@ -327,14 +323,14 @@ if schema["then"] ~= nil then if not validate(schema["then"], data, root, sloc.."/then", iloc, errs) then table.insert(errs, mkerr(sloc.."/then", iloc, "did not match subschema")) - return false, errs; + return false, errs end end else if schema["else"] ~= nil then if not validate(schema["else"], data, root, sloc.."/else", iloc, errs) then table.insert(errs, mkerr(sloc.."/else", iloc, "did not match subschema")) - return false, errs; + return false, errs end end end @@ -342,7 +338,7 @@ if schema.const ~= nil and schema.const ~= data then table.insert(errs, mkerr(sloc.."/const", iloc, "did not match constant value")) - return false, errs; + return false, errs end if data is table then @@ -352,19 +348,19 @@ if schema.maxItems and #(data as {any}) > schema.maxItems then table.insert(errs, mkerr(sloc.."/maxItems", iloc, "too many items")) - return false, errs; + return false, errs end if schema.minItems and #(data as {any}) < schema.minItems then table.insert(errs, mkerr(sloc.."/minItems", iloc, "too few items")) - return false, errs; + return false, errs end if schema.required then for _, k in ipairs(schema.required) do if data[k] == nil then table.insert(errs, mkerr(sloc.."/required", iloc.."/"..tostring(k), "missing required property")) - return false, errs; + return false, errs end end end @@ -375,7 +371,7 @@ for _, req in ipairs(reqs) do if data[req] == nil then table.insert(errs, mkerr(sloc.."/dependentRequired", iloc, "missing dependent required property")) - return false, errs; + return false, errs end end end @@ -387,7 +383,7 @@ for k in pairs(data) do if not validate(schema.propertyNames, k, root, sloc.."/propertyNames", iloc.."/"..tostring(k), errs) then table.insert(errs, mkerr(sloc.."/propertyNames", iloc.."/"..tostring(k), "a property name did not match subschema")) - return false, errs; + return false, errs end end end @@ -401,7 +397,7 @@ for k, sub in pairs(schema.properties) do if data[k] ~= nil and not validate(sub, data[k], root, sloc.."/"..tostring(k), iloc.."/"..tostring(k), errs) then table.insert(errs, mkerr(sloc.."/"..tostring(k), iloc.."/"..tostring(k), "a property did not match subschema")) - return false, errs; + return false, errs end seen_properties[k] = true end @@ -414,7 +410,7 @@ if k is string and k:match(pattern) then if not validate(sub, data[k], root, sloc.."/luaPatternProperties", iloc, errs) then table.insert(errs, mkerr(sloc.."/luaPatternProperties/"..pattern, iloc.."/"..tostring(k), "a property did not match subschema")) - return false, errs; + return false, errs end seen_properties[k] = true end @@ -427,7 +423,7 @@ if not seen_properties[k as string] then if not validate(schema.additionalProperties, v, root, sloc.."/additionalProperties", iloc.."/"..tostring(k), errs) then table.insert(errs, mkerr(sloc.."/additionalProperties", iloc.."/"..tostring(k), "additional property did not match subschema")) - return false, errs; + return false, errs end end end @@ -437,7 +433,7 @@ for k, sub in pairs(schema.dependentSchemas) do if data[k] ~= nil and not validate(sub, data, root, sloc.."/dependentSchemas/"..k, iloc, errs) then table.insert(errs, mkerr(sloc.."/dependentSchemas", iloc.."/"..tostring(k), "did not match dependent subschema")) - return false, errs; + return false, errs end end end @@ -448,7 +444,7 @@ for _, v in pairs(data) do if values[v] then table.insert(errs, mkerr(sloc.."/uniqueItems", iloc, "had duplicate items")) - return false, errs; + return false, errs end values[v] = true end @@ -463,7 +459,7 @@ p = i else table.insert(errs, mkerr(sloc.."/prefixItems/"..i, iloc.."/"..tostring(i), "did not match subschema")) - return false, errs; + return false, errs end end end @@ -472,7 +468,7 @@ for i = p+1, #(data as {any}) do if not validate(schema.items, data[i], root, sloc, iloc.."/"..i, errs) then table.insert(errs, mkerr(sloc.."/prefixItems/"..i, iloc.."/"..i, "did not match subschema")) - return false, errs; + return false, errs end end end @@ -488,18 +484,18 @@ end if found < (schema.minContains or 1) then table.insert(errs, mkerr(sloc.."/minContains", iloc, "too few matches")) - return false, errs; + return false, errs elseif found > (schema.maxContains or math.huge) then table.insert(errs, mkerr(sloc.."/maxContains", iloc, "too many matches")) - return false, errs; + return false, errs end end end - return true; + return true end json_schema_object.validate = validate; -return json_schema_object; +return json_schema_object
--- a/teal-src/prosody/util/pposix.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/pposix.d.tl Sat May 30 19:22:51 2026 +0200 @@ -86,6 +86,14 @@ mkdir : function (dir : string) : boolean, string + enum pipe_flag_names + "cloexec" + "direct" + "nonblock" + end + pipe : function (... : pipe_flag_names) : integer, integer + fdopen : function (integer, string) : FILE, string + setrlimit : function (resource : ulimit_resource, soft : ulimit_limit, hard : ulimit_limit) : boolean, string getrlimit : function (resource : ulimit_resource) : boolean, ulimit_limit, ulimit_limit getrlimit : function (resource : ulimit_resource) : boolean, string @@ -103,7 +111,7 @@ ENOENT : integer _NAME : string - _VESRION : string + _VERSION : string end return pposix
--- a/teal-src/prosody/util/queue.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/queue.d.tl Sat May 30 19:22:51 2026 +0200 @@ -18,4 +18,4 @@ new : function<T> (size:integer, allow_wrapping:boolean) : queue<T> end -return lib; +return lib
--- a/teal-src/prosody/util/set.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/set.d.tl Sat May 30 19:22:51 2026 +0200 @@ -1,22 +1,22 @@ local record lib record Set<T> - add : function<T> (Set<T>, T) - contains : function<T> (Set<T>, T) : boolean - contains_set : function<T> (Set<T>, Set<T>) : boolean - items : function<T> (Set<T>) : function<T> (Set<T>, T) : T - remove : function<T> (Set<T>, T) - add_list : function<T> (Set<T>, { T }) - include : function<T> (Set<T>, Set<T>) - exclude : function<T> (Set<T>, Set<T>) - empty : function<T> (Set<T>) : boolean + add : function (Set, T) + contains : function (Set, T) : boolean + contains_set : function (Set, Set) : boolean + items : function (Set) : function (Set, T) : T + remove : function (Set, T) + add_list : function (Set, { T }) + include : function (Set, Set) + exclude : function (Set, Set) + empty : function (Set) : boolean end new : function<T> ({ T }) : Set<T> is_set : function (any) : boolean - union : function<T> (Set<T>, Set<T>) : Set <T> - difference : function<T> (Set<T>, Set<T>) : Set <T> - intersection : function<T> (Set<T>, Set<T>) : Set <T> - xor : function<T> (Set<T>, Set<T>) : Set <T> + union : function (Set, Set) : Set + difference : function (Set, Set) : Set + intersection : function (Set, Set) : Set + xor : function (Set, Set) : Set end return lib
--- a/teal-src/prosody/util/signal.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/signal.d.tl Sat May 30 19:22:51 2026 +0200 @@ -36,6 +36,7 @@ signal : function (integer | Signal, function, boolean) : boolean raise : function (integer | Signal) kill : function (integer, integer | Signal) + signalfd : function (integer) : FILE -- enum : integer end return lib
--- a/teal-src/prosody/util/smqueue.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/smqueue.tl Sat May 30 19:22:51 2026 +0200 @@ -6,18 +6,21 @@ _queue : queue.queue<T> _head : integer _tail : integer + _next_checkpoint : integer + type checkpoint = { integer, function(any), any } + _checkpoints : { checkpoint } enum ack_errors "tail" "head" "pop" end - push : function (smqueue, T) - ack : function (smqueue, integer) : { T }, ack_errors + push : function (smqueue<T>, T) + ack : function (smqueue<T>, integer) : { T }, ack_errors resumable : function (smqueue<T>) : boolean resume : function (smqueue<T>) : queue.queue.iterator, any, integer - type consume_iter = function (smqueue<T>) : T - consume : function (smqueue<T>) : consume_iter + consume : function (smqueue<T>) : function() : T + _call_checkpoints : function(smqueue<T>, integer) table : function (smqueue<T>) : { T } end @@ -26,17 +29,22 @@ local type smqueue = lib.smqueue; -function smqueue:push(v) +function smqueue:push(v : T) self._head = self._head + 1; -- Wraps instead of errors assert(self._queue:push(v)); end -function smqueue:ack(h : integer) : { any }, smqueue.ack_errors +-- Call when receiver has acknowledged some stanzas +-- `h` (sent by the receiver) is the running count of +-- successfully received stanzas +function smqueue:ack(h : integer) : { T }, smqueue.ack_errors if h < self._tail then - return nil, "tail"; + -- h is less than a previous h + return nil, "tail" elseif h > self._head then - return nil, "head"; + -- h is greater than the number of stanzas we sent + return nil, "head" end -- TODO optimize? cache table fields local acked = {}; @@ -44,56 +52,94 @@ local expect = self._head - self._tail; while expect < self._queue:count() do local v = self._queue:pop(); - if not v then return nil, "pop"; end + if not v then return nil, "pop" end table.insert(acked, v); end - return acked; + + local c = self._next_checkpoint; + if c and h >= c then + self:_call_checkpoints(h); + end + return acked end function smqueue:count_unacked() : integer - return self._head - self._tail; + return self._head - self._tail end function smqueue:count_acked() : integer - return self._tail; + return self._tail end function smqueue:resumable() : boolean - return self._queue:count() >= (self._head - self._tail); + return self._queue:count() >= (self._head - self._tail) end function smqueue:resume() : queue.queue.iterator, any, integer - return self._queue:items(); + return self._queue:items() end -function smqueue:consume() : queue.queue.consume_iter - return self._queue:consume() +function smqueue:consume() : (function() : T) + return self._queue:consume() as (function() : T) end -- Compatibility layer, plain ol' table -function smqueue:table() : { any } - local t : { any } = {}; +function smqueue:table() : { T } + local t : { T } = {}; for i, v in self:resume() do t[i] = v; end - return t; + return t +end + +-- Add a 'checkpoint' which will call a function when all the stanzas +-- currently in the queue have been successfully delivered and acknowledged +function smqueue:add_checkpoint(cb : function (any), ud : any) + table.insert(self._checkpoints, { self._head, cb, ud }); + if not self._next_checkpoint then + self._next_checkpoint = self._head; + end +end + +function smqueue:_call_checkpoints(h : integer) + local checkpoints = self._checkpoints; + + -- Pop the checkpoint info and update the next checkpoint info + local c = table.remove(checkpoints, 1); + self._next_checkpoint = checkpoints[1] and checkpoints[1][1] or nil; + + -- Call the callback + local cb, ud = c[2], c[3]; + cb(ud); + + -- In case we passed over multiple checkpoints, call them also + if h >= self._next_checkpoint then + return self:_call_checkpoints(h) + end end local function freeze(q : smqueue<any>) : { string:integer } return { head = q._head, tail = q._tail } end -local queue_mt = { +local smqueue_mt = { -- __name = "smqueue"; __index = smqueue; __len = smqueue.count_unacked; + -- Return a simple object for serialization + -- This is forbidden by Teal. Thanks Teal! __freeze = freeze; } -function lib.new<T>(size : integer) : queue.queue<T> +function lib.new<T>(size : integer) : smqueue<T> assert(size>0); - return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt); + return setmetatable({ + _head = 0; + _tail = 0; + _queue = queue.new(size, true); + _checkpoints = {}; + }, smqueue_mt) end -return lib; +return lib
--- a/teal-src/prosody/util/stanza.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/stanza.d.tl Sat May 30 19:22:51 2026 +0200 @@ -37,10 +37,9 @@ "unexpected-request" end - record stanza_t + record stanza_t is { stanza_t | string } name : string attr : { string : string } - { stanza_t | string } tags : { stanza_t } query : function ( stanza_t, string ) : stanza_t @@ -77,10 +76,9 @@ indent : function ( stanza_t, integer, string ) : stanza_t end - record serialized_stanza_t + record serialized_stanza_t is { serialized_stanza_t | string } name : string attr : { string : string } - { serialized_stanza_t | string } end record message_attr
--- a/teal-src/prosody/util/time.d.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/time.d.tl Sat May 30 19:22:51 2026 +0200 @@ -2,5 +2,6 @@ local record lib now : function () : number monotonic : function () : number + sleep : function (number) end return lib
--- a/teal-src/prosody/util/xtemplate.tl Sat May 30 19:21:08 2026 +0200 +++ b/teal-src/prosody/util/xtemplate.tl Sat May 30 19:22:51 2026 +0200 @@ -56,7 +56,7 @@ if func == "each" and tmpl then if not st.is_stanza(value) then - return pre_blank..post_blank; + return pre_blank..post_blank end if not args then value, args = root, path; end local ns, name = s_match(args, "^(%b{})(.*)$"); @@ -94,15 +94,15 @@ if value is string then if not is_escaped then value = escape(value); end - return pre_blank .. value .. post_blank; + return pre_blank .. (value as string) .. post_blank elseif st.is_stanza(value) then value = value:get_text(); if value then - return pre_blank .. escape(value) .. post_blank; + return pre_blank .. escape(value) .. post_blank end end - return pre_blank .. post_blank; - end)); + return pre_blank .. post_blank + end)) end -return { render = render }; +return { render = render }
--- a/util-src/crypto.c Sat May 30 19:21:08 2026 +0200 +++ b/util-src/crypto.c Sat May 30 19:22:51 2026 +0200 @@ -29,6 +29,7 @@ #include <openssl/obj_mac.h> #include <openssl/param_build.h> #include <openssl/pem.h> +#include <openssl/rsa.h> #if (LUA_VERSION_NUM == 501) #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
--- a/util-src/pposix.c Sat May 30 19:21:08 2026 +0200 +++ b/util-src/pposix.c Sat May 30 19:22:51 2026 +0200 @@ -664,13 +664,17 @@ } const char *pipe_flag_names[] = { +#if !defined(__APPLE__) "cloexec", "direct", +#endif "nonblock" }; const int pipe_flag_values[] = { +#if !defined(__APPLE__) O_CLOEXEC, O_DIRECT, +#endif O_NONBLOCK };
--- a/util-src/time.c Sat May 30 19:21:08 2026 +0200 +++ b/util-src/time.c Sat May 30 19:22:51 2026 +0200 @@ -4,6 +4,7 @@ #include <time.h> #include <lua.h> +#include <lauxlib.h> static lua_Number tv2number(struct timespec *tv) { return tv->tv_sec + tv->tv_nsec * 1e-9; @@ -23,6 +24,23 @@ return 1; } +/* This function has been imported from luasocket */ +static int lc_sleep(lua_State *L) { + double n = luaL_checknumber(L, 1); + struct timespec t, r; + if (n < 0.0) n = 0.0; + if (n > INT_MAX) n = INT_MAX; + t.tv_sec = (int) n; + n -= t.tv_sec; + t.tv_nsec = (int) (n * 1000000000); + if (t.tv_nsec >= 1000000000) t.tv_nsec = 999999999; + while (nanosleep(&t, &r) != 0) { + t.tv_sec = r.tv_sec; + t.tv_nsec = r.tv_nsec; + } + return 0; +} + int luaopen_prosody_util_time(lua_State *L) { lua_createtable(L, 0, 2); { @@ -30,6 +48,8 @@ lua_setfield(L, -2, "now"); lua_pushcfunction(L, lc_time_monotonic); lua_setfield(L, -2, "monotonic"); + lua_pushcfunction(L, lc_sleep); + lua_setfield(L, -2, "sleep"); } return 1; }
--- a/util/adminstream.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/adminstream.lua Sat May 30 19:22:51 2026 +0200 @@ -1,7 +1,7 @@ local st = require "prosody.util.stanza"; local new_xmpp_stream = require "prosody.util.xmppstream".new; local sessionlib = require "prosody.util.session"; -local gettime = require "prosody.util.time".now; +local time_now = require "prosody.util.time".now; local runner = require "prosody.util.async".runner; local add_task = require "prosody.util.timer".add_task; local events = require "prosody.util.events"; @@ -197,7 +197,7 @@ sessionlib.set_logger(session); sessionlib.set_conn(session, conn); - session.conntime = gettime(); + session.conntime = time_now(); session.type = "admin"; local stream = new_xmpp_stream(session, stream_callbacks); @@ -254,7 +254,7 @@ function s.listeners.ondisconnect(conn, err) local session = sessions[conn]; if session then - session.log("info", "Admin client disconnected: %s", err or "connection closed"); + session.log("debug", "Admin client disconnected: %s", err or "connection closed"); session.conn = nil; sessions[conn] = nil; s.events.fire_event("disconnected", { session = session }); @@ -327,7 +327,7 @@ end function listeners.ondisconnect(conn, err) --luacheck: ignore 212/conn - client.log("info", "Admin client disconnected: %s", err or "connection closed"); + client.log("debug", "Admin client disconnected: %s", err or "connection closed"); client.conn = nil; client.events.fire_event("disconnected"); end
--- a/util/argparse.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/argparse.lua Sat May 30 19:22:51 2026 +0200 @@ -2,7 +2,7 @@ local short_params = config and config.short_params or {}; local value_params = config and config.value_params or {}; local array_params = config and config.array_params or {}; - local kv_params = config and config.kv_params or {}; + local bool_params = config and (config.bool_params or config.kv_params) or {}; -- COMPAT: kv_params in 13.0 and earlier local strict = config and config.strict; local stop_on_positional = not config or config.stop_on_positional ~= false; @@ -48,16 +48,13 @@ end end else - param_k, param_v = param:match("^([^=]+)=(.+)$"); - if not param_k then - if param:match("^no%-") then - param_k, param_v = param:sub(4), false; - else - param_k, param_v = param, true; - end + if param:match("^no%-") then + param_k, param_v = param:sub(4), false; + else + param_k, param_v = param, true; end param_k = param_k:gsub("%-", "_"); - if strict and not kv_params[param_k] then + if strict and not bool_params[param_k] then return nil, "param-not-found", raw_param; end end
--- a/util/datamapper.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/datamapper.lua Sat May 30 19:22:51 2026 +0200 @@ -1,13 +1,13 @@ --- This file is generated from teal-src/util/datamapper.lua - +-- This file is generated from teal-src/prosody/util/datamapper.tl if not math.type then require("prosody.util.mathcompat") end local st = require("prosody.util.stanza"); +local json = require("prosody.util.json") local pointer = require("prosody.util.jsonpointer"); -local schema_t = {} +local json_schema_object = require("prosody.util.jsonschema") local function toboolean(s) if s == "true" or s == "1" then @@ -32,8 +32,6 @@ end end -local value_goes = {} - local function resolve_schema(schema, root) if type(schema) == "table" then if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then @@ -69,8 +67,6 @@ if type(propschema) == "table" then proptype = guess_schema_type(propschema); - elseif type(propschema) == "string" then - error("schema as string is not supported: " .. propschema .. " {" .. current_ns .. "}" .. propname) end if proptype == "object" or proptype == "array" then @@ -103,7 +99,7 @@ end end if propschema["const"] then - enums = {propschema["const"]} + enums = { propschema["const"] } elseif propschema["enum"] then enums = propschema["enum"] end @@ -137,7 +133,7 @@ elseif value_where == "in_attribute" then local attr = name if prefix then - attr = prefix .. ":" .. name + attr = prefix .. ':' .. name elseif namespace and namespace ~= s.attr.xmlns then attr = namespace .. "\1" .. name end @@ -243,7 +239,7 @@ return v elseif proptype == "number" and type(v) == "number" then return string.format("%g", v) - elseif proptype == "integer" and type(v) == "number" then + elseif proptype == "integer" and math.type(v) == "integer" then return string.format("%d", v) elseif proptype == "boolean" then return v and "1" or "0" @@ -252,13 +248,12 @@ local unparse -local function unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, - single_attribute, root) +local function unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root) if value_where == "in_attribute" then local attr = name if prefix then - attr = prefix .. ":" .. name + attr = prefix .. ':' .. name elseif namespace and namespace ~= current_ns then attr = namespace .. "\1" .. name end @@ -280,7 +275,7 @@ else local propattr if namespace ~= current_ns then - propattr = {xmlns = namespace} + propattr = { xmlns = namespace } end if value_where == "in_tag_name" then if proptype == "string" and type(v) == "string" then @@ -324,7 +319,7 @@ end - local out = ctx or st.stanza(current_name, {xmlns = current_ns}) + local out = ctx or st.stanza(current_name, { xmlns = current_ns }) local s_type = guess_schema_type(schema) if s_type == "object" then @@ -350,4 +345,4 @@ end end -return {parse = parse; unparse = unparse} +return { parse = parse; unparse = unparse }
--- a/util/debug.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/debug.lua Sat May 30 19:22:51 2026 +0200 @@ -145,7 +145,8 @@ local last_source_desc; local lines = {}; - for nlevel, current_level in ipairs(levels) do + for n, current_level in ipairs(levels) do + local nlevel = n-1; local info = current_level.info; local line; local func_type = info.namewhat.." "; @@ -171,7 +172,6 @@ last_source_desc = source_desc; table.insert(lines, "\t "..build_source_boundary_marker(last_source_desc)); end - nlevel = nlevel-1; table.insert(lines, "\t"..(nlevel==0 and ">" or " ")..getstring(styles.level_num, "("..nlevel..") ")..line); local npadding = (" "):rep(#tostring(nlevel)); if current_level.locals then
--- a/util/gc.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/gc.lua Sat May 30 19:22:51 2026 +0200 @@ -5,7 +5,9 @@ generational = set.new { "mode", "minor_threshold", "major_threshold" }; }; -if _VERSION ~= "Lua 5.4" then +if _VERSION == "Lua 5.5" then + known_options.generational = set.new { "mode", "minor_multiplier", "minor_major_threshold", "major_minor_threshold" }; +elseif _VERSION ~= "Lua 5.4" then known_options.generational = nil; known_options.incremental:remove("step_size"); end @@ -31,15 +33,27 @@ user.speed or defaults.speed, user.step_size or defaults.step_size ); + elseif _VERSION == "Lua 5.5" then + collectgarbage(mode); + collectgarbage("param", "pause", user.threshold or defaults.threshold); + collectgarbage("param", "stepmul", user.speed or defaults.speed); + collectgarbage("param", "stepsize", user.step_size or defaults.step_size); else collectgarbage("setpause", user.threshold or defaults.threshold); collectgarbage("setstepmul", user.speed or defaults.speed); end elseif mode == "generational" then - collectgarbage(mode, - user.minor_threshold or defaults.minor_threshold, - user.major_threshold or defaults.major_threshold - ); + if _VERSION == "Lua 5.4" then + collectgarbage(mode, + user.minor_threshold or defaults.minor_threshold, + user.major_threshold or defaults.major_threshold + ); + elseif _VERSION == "Lua 5.5" then + collectgarbage(mode); + collectgarbage("param", "minormul", user.minor_multiplier or defaults.minor_multiplier); + collectgarbage("param", "majorminor", user.major_minor_threshold or defaults.major_minor_threshold); + collectgarbage("param", "minormajor", user.minor_major_threshold or defaults.minor_major_threshold); + end end return true; end
--- a/util/http.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/http.lua Sat May 30 19:22:51 2026 +0200 @@ -45,8 +45,8 @@ local function formdecode(s) if not s:match("=") then return urldecode(s); end local r = {}; - for k, v in s:gmatch("([^=&]*)=([^&]*)") do - k, v = k:gsub("%+", "%%20"), v:gsub("%+", "%%20"); + for uk, uv in s:gmatch("([^=&]*)=([^&]*)") do + local k, v = uk:gsub("%+", "%%20"), uv:gsub("%+", "%%20"); k, v = urldecode(k), urldecode(v); t_insert(r, { name = k, value = v }); r[k] = v;
--- a/util/human/io.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/human/io.lua Sat May 30 19:22:51 2026 +0200 @@ -131,10 +131,7 @@ return utf8_cut(s, width - 1) .. "…"; end -local function new_table(col_specs, max_width) - max_width = max_width or term_width(80); - local separator = " | "; - +local function calculate_column_widths(col_specs, separator, max_width) local widths = {}; local total_width = max_width - #separator * (#col_specs-1); local free_width = total_width; @@ -166,6 +163,20 @@ end end + return widths; +end + +local function new_table(col_specs, max_width, separator) + separator = separator or " | "; + + local widths; + if max_width ~= -1 then + max_width = max_width or term_width(80); + widths = calculate_column_widths(col_specs, separator, max_width) + else + widths = {}; + end + return function (row) local titles; if not row then @@ -183,14 +194,16 @@ else v = tostring(v); end - if len(v) < width then - if column.align == "right" then - v = padleft(v, width); - else - v = padright(v, width); + if width then + if len(v) < width then + if column.align == "right" then + v = padleft(v, width); + else + v = padright(v, width); + end + elseif len(v) > width then + v = (column.ellipsis or ellipsis)(v, width); end - elseif len(v) > width then - v = (column.ellipsis or ellipsis)(v, width); end table.insert(output, v); end
--- a/util/jsonpointer.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/jsonpointer.lua Sat May 30 19:22:51 2026 +0200 @@ -1,5 +1,4 @@ -local m_type = math.type; - +-- This file is generated from teal-src/prosody/util/jsonpointer.tl local function unescape_token(escaped_token) local unescaped = escaped_token:gsub("~1", "/"):gsub("~0", "~") return unescaped @@ -17,10 +16,10 @@ if type(idx) == "string" then new_ref = ref[token] - elseif m_type(idx) == "integer" then + elseif math.type(idx) == "integer" then local i = tonumber(token) if token == "-" then - i = #ref + 1 + i = #(ref) + 1 end new_ref = ref[i + 1] else
--- a/util/jsonschema.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/jsonschema.lua Sat May 30 19:22:51 2026 +0200 @@ -1,14 +1,9 @@ --- This file is generated from teal-src/util/jsonschema.lua - +-- This file is generated from teal-src/prosody/util/jsonschema.tl if not math.type then require("prosody.util.mathcompat") end -local utf8_enc = rawget(_G, "utf8") or require("prosody.util.encodings").utf8; -local utf8_len = utf8_enc.len or function(s) - local _, count = s:gsub("[%z\001-\127\194-\253][\128-\191]*", ""); - return count -end; +local utf8_len = rawget(_G, "utf8") and utf8.len or require("prosody.util.encodings").utf8.length; local json = require("prosody.util.json") local null = json.null;
--- a/util/pluginloader.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/pluginloader.lua Sat May 30 19:22:51 2026 +0200 @@ -10,10 +10,9 @@ local dir_sep, path_sep = package.config:match("^(%S+)%s(%S+)"); local lua_version = _VERSION:match(" (.+)$"); local plugin_dir = {}; -for path in (CFG_PLUGINDIR or "./plugins/"):gsub("[/\\]", dir_sep):gmatch("[^"..path_sep.."]+") do - path = path..dir_sep; -- add path separator to path end - path = path:gsub(dir_sep..dir_sep.."+", dir_sep); -- coalesce multiple separators - plugin_dir[#plugin_dir + 1] = path; +for path in (CFG_PLUGINDIR or "./plugins/"):gsub("[/\\]", dir_sep):gmatch("[^" .. path_sep .. "]+") do + plugin_dir[#plugin_dir + 1] = (path .. dir_sep) -- add path separator to path end + :gsub(dir_sep .. dir_sep .. "+", dir_sep); -- coalesce multiple separators end local io_open = io.open;
--- a/util/prosodyctl/check.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/prosodyctl/check.lua Sat May 30 19:22:51 2026 +0200 @@ -12,6 +12,7 @@ local async = require "prosody.util.async"; local httputil = require "prosody.util.http"; local human_units = require "prosody.util.human.units"; +local have_psl, publicsuffix = pcall(require, "psl"); local function api(host) return setmetatable({ name = "prosodyctl.check"; host = host; log = prosody.log }, { __index = moduleapi }) @@ -74,7 +75,7 @@ local result = { warnings = {} }; -- Create UDP socket for communication with the server - local sock = assert(require "socket".udp()); + local sock = assert(socket.udp()); do local ok, err = sock:setsockname("*", 0); if not ok then @@ -588,14 +589,19 @@ print(" You need to move the following option"..(n>1 and "s" or "")..": "..table.concat(it.to_array(misplaced_options), ", ")); end end + local psl = have_psl and publicsuffix.latest(api("*"):get_option_string("public_suffix_file", nil)); for host, options in enabled_hosts() do + -- This is meant to prod people away from xmpp.example.com towards example.com, + -- but will not do the right thing with second level hierarchies like .co.uk + -- unless lua-psl is installed local host_options = set.new(it.to_array(it.keys(options))); local subdomain = host:match("^[^.]+"); + local suggestion = host:gsub("^[^.]+%.", ""); if not(host_options:contains("component_module")) and (subdomain == "jabber" or subdomain == "xmpp" - or subdomain == "chat" or subdomain == "im") then + or subdomain == "chat" or subdomain == "im") and (suggestion:match("%.") or psl and not psl:is_public_suffix(suggestion)) then 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(" "..suggestion..". You can use SRV records to redirect XMPP clients and servers to "..host.."."); print(" For more information see: https://prosody.im/doc/dns"); end end @@ -1140,8 +1146,7 @@ local http_internal_host = http_host; local http_url = api(host):get_option_string("http_external_url"); if http_url then - local url_parse = require "socket.url".parse; - local external_url_parts = url_parse(http_url); + local external_url_parts = socket_url.parse(http_url); if external_url_parts then http_host = external_url_parts.host; else
--- a/util/prosodyctl/shell.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/prosodyctl/shell.lua Sat May 30 19:22:51 2026 +0200 @@ -30,7 +30,7 @@ end local function send_line(client, line, interactive) - client.send(st.stanza("repl-input", { width = tostring(term_width()), repl = interactive == false and "0" or "1" }):text(line)); + client.send(st.stanza("repl-input", { width = tostring(term_width(-1)), repl = interactive == false and "0" or "1" }):text(line)); end local function repl(client) @@ -82,7 +82,7 @@ elseif err == "missing-value" then print("Expected a value to follow command-line option: "..where); end - os.exit(1); + os.exit(64); end if arg[1] then @@ -97,7 +97,7 @@ local errors = 0; -- TODO This is weird, but works for now. client.events.add_handler("received", function(stanza) - if stanza.name == "repl-output" or stanza.name == "repl-result" then + if stanza.name == "repl-output" or (stanza.name == "repl-result" and not opts.quiet) then local dest = io.stdout; if stanza.attr.type == "error" then errors = errors + 1; @@ -110,7 +110,7 @@ end end if stanza.name == "repl-result" then - os.exit(errors); + os.exit(errors > 0 and 1 or 0); end return true; end, 1); @@ -141,7 +141,7 @@ }):text(password or "")); else io.stderr:write("Internal error - unexpected input request type "..tostring(stanza.attr.type).."\n"); - os.exit(1); + os.exit(76); end return true; end, 2); @@ -174,7 +174,7 @@ print("** Unable to connect to server - is it running? Is mod_admin_shell enabled?"); print("** Connection error: "..err); end - os.exit(1); + os.exit(69); end server.loop(); end
--- a/util/pubsub.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/pubsub.lua Sat May 30 19:22:51 2026 +0200 @@ -9,6 +9,7 @@ broadcaster = function () end; subscriber_filter = function (subs) return subs end; itemcheck = function () return true; end; + iteminfo = function () return nil; end; get_affiliation = function () end; normalize_jid = function (jid) return jid; end; metadata_subset = {}; @@ -33,8 +34,39 @@ be_subscribed = false; be_unsubscribed = true; + request_subscribe = false; + request_subscribe_other = false; + set_affiliation = false; }; + -- 'unauthorized' is the default for unaffiliated in access_model 'authorize' + unauthorized = { + create = false; + publish = false; + retract = false; + get_nodes = true; + + subscribe = false; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = false; + get_metadata = true; + + subscribe_other = false; + unsubscribe_other = false; + get_subscription_other = false; + get_subscriptions_other = false; + + be_subscribed = true; + be_unsubscribed = true; + + request_subscribe = true; + request_subscribe_other = false; + + set_affiliation = false; + }; + -- default for unaffiliated in open access_model none = { create = false; publish = false; @@ -56,6 +88,9 @@ be_subscribed = true; be_unsubscribed = true; + request_subscribe = true; + request_subscribe_other = false; + set_affiliation = false; }; member = { @@ -79,6 +114,9 @@ be_subscribed = true; be_unsubscribed = true; + request_subscribe = true; + request_subscribe_other = false; + set_affiliation = false; }; publisher = { @@ -103,6 +141,9 @@ be_subscribed = true; be_unsubscribed = true; + request_subscribe = true; + request_subscribe_other = false; + set_affiliation = false; }; owner = { @@ -127,9 +168,14 @@ get_subscription_other = true; get_subscriptions_other = true; + get_pending_subscriptions = true; + be_subscribed = true; be_unsubscribed = true; + request_subscribe = true; + request_subscribe_other = true; + set_affiliation = true; }; }; @@ -150,6 +196,7 @@ local function load_node_from_store(service, node_name) local node = service.config.nodestore:get(node_name); node.config = setmetatable(node.config or {}, {__index=service.node_defaults}); + node.pending_subscribers = node.pending_subscribers or {}; return node; end @@ -158,6 +205,7 @@ name = node.name; config = node.config; subscribers = node.subscribers; + pending_subscribers = node.pending_subscribers; affiliations = node.affiliations; }); end @@ -262,6 +310,8 @@ return "member"; elseif access_model == "whitelist" then return "outcast"; + elseif access_model == "authorize" then + return "unauthorized"; end if self.config.access_models then @@ -313,6 +363,66 @@ return true; end +function service:request_subscription(node, actor, jid, options) --> ok, err + -- Access checking + local cap; + if actor == true or jid == actor or self:jids_equal(actor, jid) then + cap = "request_subscribe"; + else + cap = "request_subscribe_other"; + end + if not self:may(node, actor, cap) then + return false, "forbidden"; + end + if not self:may(node, jid, "be_subscribed") then + return false, "forbidden"; + end + -- + local node_obj = self.nodes[node]; + if not node_obj then + if not self.config.autocreate_on_subscribe then + return false, "item-not-found"; + else + local ok, err = self:create(node, true); + if not ok then + return ok, err; + end + node_obj = self.nodes[node]; + end + end + local old_subscription = node_obj.subscribers[jid]; + if old_subscription then + return false, "already-subscribed"; + end + node_obj.pending_subscribers[jid] = options or true; + + local normal_jid = self.config.normalize_jid(jid); + local subs = self.subscriptions[normal_jid]; + if subs then + if not subs[jid] then + subs[jid] = { [node] = false }; + else + subs[jid][node] = false; + end + else + self.subscriptions[normal_jid] = { [jid] = { [node] = false } }; + end + + if self.config.nodestore then + -- TODO pass the error from storage to caller eg wrapped in an util.error + local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err + if not ok then + -- Store failed, so clear in-memory structures + node_obj.pending_subscribers[jid] = nil; + self.subscriptions[normal_jid][jid][node] = nil; + return ok, "internal-server-error"; + end + end + + self.events.fire_event("subscription-requested", { service = self, node = node, jid = jid, normalized_jid = normal_jid, options = options }); + return true; +end + function service:add_subscription(node, actor, jid, options) --> ok, err -- Access checking local cap; @@ -341,7 +451,18 @@ end end local old_subscription = node_obj.subscribers[jid]; + local pending_subscription = node_obj.pending_subscribers[jid]; + + if not options and pending_subscription then + -- Use the pending subscription request's options now we + -- are upgrading to a real subscription. + options = pending_subscription; + end node_obj.subscribers[jid] = options or true; + + -- Clear pending subscription request, if any + node_obj.pending_subscribers[jid] = nil; + local normal_jid = self.config.normalize_jid(jid); local subs = self.subscriptions[normal_jid]; if subs then @@ -358,8 +479,10 @@ -- TODO pass the error from storage to caller eg wrapped in an util.error local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err if not ok then + -- Store failed, revert in-memory to old subscription node_obj.subscribers[jid] = old_subscription; self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil; + node_obj.pending_subscribers[normal_jid] = pending_subscription; return ok, "internal-server-error"; end end @@ -387,11 +510,17 @@ if not node_obj then return false, "item-not-found"; end - if not node_obj.subscribers[jid] then + + local old_subscription = node_obj.subscribers[jid]; + local pending_subscription = node_obj.pending_subscribers[jid]; + + if not old_subscription and not pending_subscription then return false, "not-subscribed"; end - local old_subscription = node_obj.subscribers[jid]; + node_obj.subscribers[jid] = nil; + node_obj.pending_subscribers[jid] = nil; + local normal_jid = self.config.normalize_jid(jid); local subs = self.subscriptions[normal_jid]; if subs then @@ -411,8 +540,20 @@ -- TODO pass the error from storage to caller eg wrapped in an util.error local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err if not ok then + -- Restore previous in-memory state node_obj.subscribers[jid] = old_subscription; - self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil; + node_obj.pending_subscribers[jid] = pending_subscription; + if not self.subscriptions[normal_jid] then + self.subscriptions[normal_jid] = {}; + end + if not self.subscriptions[normal_jid][jid] then + self.subscriptions[normal_jid][jid] = {}; + end + if pending_subscription then + self.subscriptions[normal_jid][jid][node] = false; + elseif old_subscription then + self.subscriptions[normal_jid][jid][node] = true; + end return ok, "internal-server-error"; end end @@ -440,6 +581,21 @@ return true, node_obj.subscribers[jid]; end +function service:get_pending_subscriptions(node, actor) + -- Access checking + if not self:may(node, actor, "get_pending_subscriptions") then + return false, "forbidden"; + end + -- + + local node_obj = self.nodes[node]; + if not node_obj then + return false, "item-not-found"; + end + + return true, node_obj.pending_subscribers; +end + function service:create(node, actor, options) --> ok, err -- Access checking if not self:may(node, actor, "create") then @@ -462,6 +618,7 @@ self.nodes[node] = { name = node; subscribers = {}; + pending_subscribers = {}; config = config; affiliations = {}; }; @@ -596,8 +753,21 @@ function service:retract(node, actor, id, retract) --> ok, err -- Access checking - if not self:may(node, actor, "retract") then - return false, "forbidden"; + local may_retract = self:may(node, actor, "retract"); + + if not may_retract then + local node_obj = self.nodes[node]; + local publish_model = node_obj and node_obj.config.publish_model; + local may_publish = ( + publish_model == "open" + or (publish_model == "subscribers" and node_obj.subscribers[actor]) + ); + if not may_publish then + return false, "forbidden"; + end + -- Otherwise, we continue on, with may_retract = false, which + -- indicates they cannot retract items published by *others* + -- but may still retract their own items end -- local node_obj = self.nodes[node]; @@ -605,9 +775,20 @@ return false, "item-not-found"; end if self.data[node] then - if not self.data[node]:get(id) then + local item = self.data[node]:get(id); + if not item then return false, "item-not-found"; end + + if not may_retract then + -- May not generally retract all items, so check if they are the publisher + local info = self.config.iteminfo(item); + local is_publisher = info and info.publisher and self:jids_equal(info.publisher, actor); + if not is_publisher then + return false, "forbidden"; + end + end + local ok = self.data[node]:set(id, nil); if not ok then return nil, "internal-server-error"; @@ -714,10 +895,11 @@ return true, self.nodes; end -local function flatten_subscriptions(ret, serv, subs, node, node_obj) +local function flatten_subscriptions(ret, serv, subs, node, node_obj, inc_pending) for subscribed_jid, subscribed_nodes in pairs(subs) do if node then -- Return only subscriptions to this node - if subscribed_nodes[node] then + local state = subscribed_nodes[node]; + if state or (state == false and inc_pending) then ret[#ret+1] = { node = node; jid = subscribed_jid; @@ -726,18 +908,20 @@ end else -- Return subscriptions to all nodes local nodes = serv.nodes; - for subscribed_node in pairs(subscribed_nodes) do - ret[#ret+1] = { - node = subscribed_node; - jid = subscribed_jid; - subscription = nodes[subscribed_node].subscribers[subscribed_jid]; - }; + for subscribed_node, state in pairs(subscribed_nodes) do + if state or inc_pending then + ret[#ret+1] = { + node = subscribed_node; + jid = subscribed_jid; + subscription = nodes[subscribed_node].subscribers[subscribed_jid]; + }; + end end end end end -function service:get_subscriptions(node, actor, jid) --> (true, array) or (false, err) +function service:get_subscriptions(node, actor, jid, include_pending) --> (true, array) or (false, err) -- Access checking local cap; if actor == true or jid == actor or self:jids_equal(actor, jid) then @@ -759,7 +943,7 @@ local ret = {}; if jid == nil then for _, subs in pairs(self.subscriptions) do - flatten_subscriptions(ret, self, subs, node, node_obj) + flatten_subscriptions(ret, self, subs, node, node_obj, include_pending) end return true, ret; end @@ -768,7 +952,7 @@ -- We return the subscription object from the node to save -- a get_subscription() call for each node. if subs then - flatten_subscriptions(ret, self, subs, node, node_obj) + flatten_subscriptions(ret, self, subs, node, node_obj, include_pending) end return true, ret; end
--- a/util/queue.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/queue.lua Sat May 30 19:22:51 2026 +0200 @@ -21,6 +21,8 @@ _items = t; size = size; count = function (self) return items; end; + empty = function () return items == 0; end; + full = function () return items == size; end; push = function (self, item) if items >= size then if allow_wrapping then
--- a/util/serialization.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/serialization.lua Sat May 30 19:22:51 2026 +0200 @@ -62,6 +62,7 @@ ["or"] = true; ["nil"] = true; ["true"] = true; ["until"] = true; ["elseif"] = true; ["function"] = true; ["not"] = true; ["repeat"] = true; ["return"] = true; ["while"] = true; + ["global"] = true; }; local function new(opt)
--- a/util/smqueue.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/smqueue.lua Sat May 30 19:22:51 2026 +0200 @@ -1,3 +1,4 @@ +-- This file is generated from teal-src/prosody/util/smqueue.tl local queue = require("prosody.util.queue"); local lib = { smqueue = {} } @@ -12,8 +13,10 @@ function smqueue:ack(h) if h < self._tail then + return nil, "tail" elseif h > self._head then + return nil, "head" end @@ -22,35 +25,70 @@ local expect = self._head - self._tail; while expect < self._queue:count() do local v = self._queue:pop(); - if not v then return nil, "pop" end + if not v then + return nil, "pop" + end table.insert(acked, v); end + + self:_call_checkpoints(h); + return acked end -function smqueue:count_unacked() return self._head - self._tail end +function smqueue:count_unacked() + return self._head - self._tail +end -function smqueue:count_acked() return self._tail end +function smqueue:count_acked() + return self._tail +end -function smqueue:resumable() return self._queue:count() >= (self._head - self._tail) end +function smqueue:resumable() + return self._queue:count() >= (self._head - self._tail) +end -function smqueue:resume() return self._queue:items() end +function smqueue:resume() + return self._queue:items() +end -function smqueue:consume() return self._queue:consume() end +function smqueue:consume() + return self._queue:consume() +end function smqueue:table() local t = {}; - for i, v in self:resume() do t[i] = v; end + for i, v in self:resume() do + t[i] = v; + end return t end -local function freeze(q) return { head = q._head; tail = q._tail } end +function smqueue:add_checkpoint(cb, ud) + table.insert(self._checkpoints, { self._head; cb; ud }); +end + +function smqueue:_call_checkpoints(h) + local checkpoints = self._checkpoints; + local c = checkpoints[1]; -local queue_mt = { __name = "smqueue"; __index = smqueue; __len = smqueue.count_unacked; __freeze = freeze } + while c and h >= c[1] do + table.remove(checkpoints, 1); + local cb, ud = c[2], c[3]; + cb(ud); + c = checkpoints[1]; + end +end + +local function freeze(q) + return { head = q._head; tail = q._tail } +end + +local smqueue_mt = { __name = "smqueue"; __index = smqueue; __len = smqueue.count_unacked; __freeze = freeze } function lib.new(size) assert(size > 0); - return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt) + return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true); _checkpoints = {} }, smqueue_mt) end return lib
--- a/util/sql.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/sql.lua Sat May 30 19:22:51 2026 +0200 @@ -244,6 +244,7 @@ end return ok, ret, b, c; end +engine.write_transaction = engine.transaction; -- TODO can you signal intent to write upfront in pg or mysql? function engine:_create_index(index) local sql = "CREATE INDEX \""..index.name.."\" ON \""..index.table.."\" ("; if self.params.driver ~= "MySQL" then
--- a/util/sqlite3.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/sqlite3.lua Sat May 30 19:22:51 2026 +0200 @@ -261,36 +261,49 @@ if ret ~= lsqlite3.OK then return nil, self.conn:errmsg(); end return true; end -function engine:_transaction(func, ...) +function engine:_transaction(rw, func, ...) if not self.conn then local a,b = self:connect(); if not a then return a,b; end end --assert(not self.__transaction, "Recursive transactions not allowed"); - local ok, err = self:_"BEGIN"; + local begin = rw and "BEGIN IMMEDIATE" or "BEGIN"; + local ok, err = self:_(begin); if not ok then return ok, err; end self.__transaction = true; local success, a, b, c = xpcall(func, debug_traceback, ...); self.__transaction = nil; if success then - log("debug", "SQL transaction success [%s]", tostring(func)); + log("debug", "SQL %s transaction success [%s]", rw and "write" or "read", tostring(func)); local ok, err = self:_"COMMIT"; if not ok then return ok, err; end -- commit failed return success, a, b, c; else - log("debug", "SQL transaction failure [%s]: %s", tostring(func), a); + log("debug", "SQL %s transaction failure [%s]: %s", rw and "write" or "read", tostring(func), a); if self.conn then self:_"ROLLBACK"; end return success, a; end end function engine:transaction(...) - local ok, ret = self:_transaction(...); + local ok, ret = self:_transaction(nil, ...); if not ok then local conn = self.conn; if not conn or not conn:isopen() then self.conn = nil; self:ondisconnect(); - ok, ret = self:_transaction(...); + ok, ret = self:_transaction(nil, ...); + end + end + return ok, ret; +end +function engine:write_transaction(...) + local ok, ret = self:_transaction(true, ...); + if not ok then + local conn = self.conn; + if not conn or not conn:isopen() then + self.conn = nil; + self:ondisconnect(); + ok, ret = self:_transaction(true, ...); end end return ok, ret;
--- a/util/stanza.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/stanza.lua Sat May 30 19:22:51 2026 +0200 @@ -300,6 +300,27 @@ until not self end +function stanza_mt:set_meta(k, v) + local meta = self.meta; + if not meta then + if v == nil then + return; -- no need to unset something that isn't set + end + meta = {}; + self.meta = meta; + end + meta[k] = v; + return self; +end + +function stanza_mt:get_meta(k) + local meta = self.meta; + if not meta then + return nil; + end + return meta[k]; +end + local function _clone(stanza, only_top) local attr = {}; for k,v in pairs(stanza.attr) do attr[k] = v; end
--- a/util/startup.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/startup.lua Sat May 30 19:22:51 2026 +0200 @@ -20,6 +20,10 @@ threshold = 105, speed = 500; -- Generational mode defaults minor_threshold = 20, major_threshold = 50; + -- Lua 5.5 + minor_multiplier = 20; + major_minor_threshold = 50; + minor_major_threshold = 68; }; local arg_settigs = { @@ -459,9 +463,13 @@ local adns = require "prosody.net.adns"; if adns.instrument then local m = statsmanager.metric("histogram", "prosody_dns", "seconds", "DNS lookups", { "qclass"; "qtype" }, { - buckets = { 1 / 1024; 1 / 256; 1 / 64; 1 / 16; 1 / 4; 1; 4 }; + buckets = { 1 / 1024; 1 / 256; 1 / 64; 1 / 16; 1 / 4; 1; 4; 16 }; }); - adns.instrument(function(qclass, qtype) return timed(m:with_labels(qclass, qtype)); end); + local c = statsmanager.metric("histogram", "prosody_dns_response", "records", "DNS response records", { "qclass"; "qtype" }, { + buckets = { 0; 1; 2; 4; 8; 16 } }) + local function m_times(qclass, qtype) return timed(m:with_labels(qclass, qtype)); end + local function m_counts(qclass, qtype, num) return c:with_labels(qclass, qtype):sample(num); end + adns.instrument(m_times, m_counts); end end
--- a/util/termcolours.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/termcolours.lua Sat May 30 19:22:51 2026 +0200 @@ -18,13 +18,6 @@ local type = type; local setmetatable = setmetatable; local pairs = pairs; - -local windows; -if os.getenv("WINDIR") then - windows = require "prosody.util.windows"; -end -local orig_color = windows and windows.get_consolecolor and windows.get_consolecolor(); - local _ENV = nil; -- luacheck: std none @@ -36,13 +29,6 @@ bold = 1, dark = 2, underline = 4, underlined = 4, normal = 0; } -local winstylemap = { - ["0"] = orig_color, -- reset - ["1"] = 7+8, -- bold - ["1;33"] = 2+4+8, -- bold yellow - ["1;31"] = 4+8 -- bold red -} - local cssmap = { [1] = "font-weight: bold", [2] = "opacity: 0.5", [4] = "text-decoration: underline", [8] = "visibility: hidden", [30] = "color:black", [31] = "color:red", [32]="color:green", [33]="color:#FFD700", @@ -95,8 +81,7 @@ } for colorname, rgb in pairs(csscolors) do stylemap[colorname] = stylemap[colorname] or stylemap[rgb]; - colorname, rgb = colorname .. " background", rgb .. " background" - stylemap[colorname] = stylemap[colorname] or stylemap[rgb]; + stylemap[colorname .. " background"] = stylemap[colorname .. " background"] or stylemap[rgb .. " background"]; end local function getstyle(...) @@ -119,19 +104,6 @@ end end -if windows then - function setstyle(style) - style = style or "0"; - if style ~= last then - windows.set_consolecolor(winstylemap[style] or orig_color); - last = style; - end - end - if not orig_color then - function setstyle() end - end -end - local function ansi2css(ansi_codes) if ansi_codes == "0" then return "</span>"; end local css = {};
--- a/util/throttle.lua Sat May 30 19:21:08 2026 +0200 +++ b/util/throttle.lua Sat May 30 19:22:51 2026 +0200 @@ -1,5 +1,5 @@ -local gettime = require "prosody.util.time".now +local time_now = require "prosody.util.time".now; local setmetatable = setmetatable; local _ENV = nil; @@ -9,7 +9,7 @@ local throttle_mt = { __index = throttle }; function throttle:update() - local newt = gettime(); + local newt = time_now(); local elapsed = newt - self.t; self.t = newt; local balance = (self.rate * elapsed) + self.balance; @@ -40,7 +40,7 @@ end local function create(max, period) - return setmetatable({ rate = max / period, max = max, t = gettime(), balance = max }, throttle_mt); + return setmetatable({ rate = max / period, max = max, t = time_now(), balance = max }, throttle_mt); end return {
