Changeset

5942:abd1bbe5006e draft default tip

Merge
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Sun, 16 Feb 2025 16:09:03 +0700
parents 5856:75dee6127829 (current diff) 5941:5c4e102e2563 (diff)
children
files
diffstat 70 files changed, 1489 insertions(+), 264 deletions(-) [+]
line wrap: on
line diff
--- a/misc/systemd/prosody.service	Tue Feb 06 18:32:01 2024 +0700
+++ b/misc/systemd/prosody.service	Sun Feb 16 16:09:03 2025 +0700
@@ -1,3 +1,5 @@
+# This is an example service file. For some time there's now also one in used in our Debian releases at https://hg.prosody.im/debian/
+
 [Unit]
 ### see man systemd.unit
 Description=Prosody XMPP Server
@@ -30,7 +32,7 @@
 User=prosody
 Group=prosody
 
-Umask=0027
+UMask=0027
 
 # Nice=0
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_anti_spam/mod_anti_spam.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -0,0 +1,165 @@
+local ip = require "util.ip";
+local jid_bare = require "util.jid".bare;
+local jid_split = require "util.jid".split;
+local set = require "util.set";
+local sha256 = require "util.hashes".sha256;
+local st = require"util.stanza";
+local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
+local full_sessions = prosody.full_sessions;
+
+local user_exists = require "core.usermanager".user_exists;
+
+local new_rtbl_subscription = module:require("rtbl").new_rtbl_subscription;
+local trie = module:require("trie");
+
+local spam_source_domains = set.new();
+local spam_source_ips = trie.new();
+local spam_source_jids = set.new();
+
+local count_spam_blocked = module:metric("counter", "anti_spam_blocked", "stanzas", "Stanzas blocked as spam", {"reason"});
+
+function block_spam(event, reason, action)
+	event.spam_reason = reason;
+	event.spam_action = action;
+	if module:fire_event("spam-blocked", event) == false then
+		module:log("debug", "Spam allowed by another module");
+		return;
+	end
+
+	count_spam_blocked:with_labels(reason):add(1);
+
+	if action == "bounce" then
+		module:log("debug", "Bouncing likely spam %s from %s (%s)", event.stanza.name, event.stanza.attr.from, reason);
+		event.origin.send(st.error_reply("cancel", "policy-violation", "Rejected as spam"));
+	else
+		module:log("debug", "Discarding likely spam %s from %s (%s)", event.stanza.name, event.stanza.attr.from, reason);
+	end
+
+	return true;
+end
+
+function is_from_stranger(from_jid, event)
+	local stanza = event.stanza;
+	local to_user, to_host, to_resource = jid_split(stanza.attr.to);
+
+	if not to_user then return false; end
+
+	local to_session = full_sessions[stanza.attr.to];
+	if to_session then return false; end
+
+	if not is_contact_subscribed(to_user, to_host, from_jid) then
+		-- Allow all messages from your own jid
+		if from_jid == to_user.."@"..to_host then
+			return false; -- Pass through
+		end
+		if to_resource and stanza.attr.type == "groupchat" then
+			return false; -- Pass through
+		end
+		return true; -- Stranger danger
+	end
+end
+
+function is_spammy_server(session)
+	if spam_source_domains:contains(session.from_host) then
+		return true;
+	end
+	local origin_ip = ip.new(session.ip);
+	if spam_source_ips:contains_ip(origin_ip) then
+		return true;
+	end
+end
+
+function is_spammy_sender(sender_jid)
+	return spam_source_jids:contains(sha256(sender_jid, true));
+end
+
+local spammy_strings = module:get_option_array("anti_spam_block_strings");
+local spammy_patterns = module:get_option_array("anti_spam_block_patterns");
+
+function is_spammy_content(stanza)
+	-- Only support message content
+	if stanza.name ~= "message" then return; end
+	if not (spammy_strings or spammy_patterns) then return; end
+
+	local body = stanza:get_child_text("body");
+	if spammy_strings then
+		for _, s in ipairs(spammy_strings) do
+			if body:find(s, 1, true) then
+				return true;
+			end
+		end
+	end
+	if spammy_patterns then
+		for _, s in ipairs(spammy_patterns) do
+			if body:find(s) then
+				return true;
+			end
+		end
+	end
+end
+
+-- Set up RTBLs
+
+local anti_spam_services = module:get_option_array("anti_spam_services");
+
+for _, rtbl_service_jid in ipairs(anti_spam_services) do
+	new_rtbl_subscription(rtbl_service_jid, "spam_source_domains", {
+		added = function (item)
+			spam_source_domains:add(item);
+		end;
+		removed = function (item)
+			spam_source_domains:remove(item);
+		end;
+	});
+	new_rtbl_subscription(rtbl_service_jid, "spam_source_ips", {
+		added = function (item)
+			spam_source_ips:add_subnet(ip.parse_cidr(item));
+		end;
+		removed = function (item)
+			spam_source_ips:remove_subnet(ip.parse_cidr(item));
+		end;
+	});
+	new_rtbl_subscription(rtbl_service_jid, "spam_source_jids_sha256", {
+		added = function (item)
+			spam_source_jids:add(item);
+		end;
+		removed = function (item)
+			spam_source_jids:remove(item);
+		end;
+	});
+end
+
+module:hook("message/bare", function (event)
+	local to_bare = jid_bare(event.stanza.attr.to);
+
+	if not user_exists(to_bare) then return; end
+
+	local from_bare = jid_bare(event.stanza.attr.from);
+	if not is_from_stranger(from_bare, event) then return; end
+
+	if is_spammy_server(event.origin) then
+		return block_spam(event, "known-spam-source", "drop");
+	end
+
+	if is_spammy_sender(from_bare) then
+		return block_spam(event, "known-spam-jid", "drop");
+	end
+
+	if is_spammy_content(event.stanza) then
+		return block_spam(event, "spam-content", "drop");
+	end
+end, 500);
+
+module:hook("presence/bare", function (event)
+	if event.stanza.type ~= "subscribe" then
+		return;
+	end
+
+	if is_spammy_server(event.origin) then
+		return block_spam(event, "known-spam-source", "drop");
+	end
+
+	if is_spammy_sender(event.stanza) then
+		return block_spam(event, "known-spam-jid", "drop");
+	end
+end, 500);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_anti_spam/rtbl.lib.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -0,0 +1,122 @@
+local array = require "util.array";
+local id = require "util.id";
+local it = require "util.iterators";
+local set = require "util.set";
+local st = require "util.stanza";
+
+module:depends("pubsub_subscription");
+
+local function new_rtbl_subscription(rtbl_service_jid, rtbl_node, handlers)
+	local items = {};
+
+	local function notify(event_type, hash)
+		local handler = handlers[event_type];
+		if not handler then return; end
+		handler(hash);
+	end
+
+	module:add_item("pubsub-subscription", {
+		service = rtbl_service_jid;
+		node = rtbl_node;
+
+		-- Callbacks:
+		on_subscribed = function()
+			module:log("info", "RTBL active: %s:%s", rtbl_service_jid, rtbl_node);
+		end;
+
+		on_error = function(err)
+			module:log(
+				"error",
+				"Failed to subscribe to RTBL: %s:%s %s::%s:  %s",
+				rtbl_service_jid,
+				rtbl_node,
+				err.type,
+				err.condition,
+				err.text
+			);
+		end;
+
+		on_item = function(event)
+			local hash = event.item.attr.id;
+			if not hash then return; end
+			module:log("debug", "Received new hash from %s:%s: %s", rtbl_service_jid, rtbl_node, hash);
+			items[hash] = true;
+			notify("added", hash);
+		end;
+
+		on_retract = function (event)
+			local hash = event.item.attr.id;
+			if not hash then return; end
+			module:log("debug", "Retracted hash from %s:%s: %s", rtbl_service_jid, rtbl_node, hash);
+			items[hash] = nil;
+			notify("removed", hash);
+		end;
+
+		purge = function()
+			module:log("debug", "Purge all hashes from %s:%s", rtbl_service_jid, rtbl_node);
+			for hash in pairs(items) do
+				items[hash] = nil;
+				notify("removed", hash);
+			end
+		end;
+	});
+
+	local request_id = "rtbl-request-"..id.short();
+
+	local function request_list()
+		local items_request = st.iq({ to = rtbl_service_jid, from = module.host, type = "get", id = request_id })
+			:tag("pubsub", { xmlns = "http://jabber.org/protocol/pubsub" })
+				:tag("items", { node = rtbl_node }):up()
+			:up();
+		module:send(items_request);
+	end
+
+	local function update_list(event)
+		local from_jid = event.stanza.attr.from;
+		if from_jid ~= rtbl_service_jid then
+			module:log("debug", "Ignoring RTBL response from unknown sender: %s", from_jid);
+			return;
+		end
+		local items_el = event.stanza:find("{http://jabber.org/protocol/pubsub}pubsub/items");
+		if not items_el then
+			module:log("warn", "Invalid items response from RTBL service %s:%s", rtbl_service_jid, rtbl_node);
+			return;
+		end
+
+		local old_entries = set.new(array.collect(it.keys(items)));
+
+		local n_added, n_removed, n_total = 0, 0, 0;
+		for item in items_el:childtags("item") do
+			local hash = item.attr.id;
+			if hash then
+				n_total = n_total + 1;
+				if not old_entries:contains(hash) then
+					-- New entry
+					n_added = n_added + 1;
+					items[hash] = true;
+					notify("added", hash);
+				else
+					-- Entry already existed
+					old_entries:remove(hash);
+				end
+			end
+		end
+
+		-- Remove old entries that weren't in the received list
+		for hash in old_entries do
+			n_removed = n_removed + 1;
+			items[hash] = nil;
+			notify("removed", hash);
+		end
+
+		module:log("info", "%d RTBL entries received from %s:%s (%d added, %d removed)", n_total, from_jid, rtbl_node, n_added, n_removed);
+		return true;
+	end
+
+	module:hook("iq-result/host/"..request_id, update_list);
+	module:add_timer(0, request_list);
+end
+
+return {
+	new_rtbl_subscription = new_rtbl_subscription;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_anti_spam/trie.lib.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -0,0 +1,168 @@
+local bit = require "prosody.util.bitcompat";
+
+local trie_methods = {};
+local trie_mt = { __index = trie_methods };
+
+local function new_node()
+	return {};
+end
+
+function trie_methods:set(item, value)
+	local node = self.root;
+	for i = 1, #item do
+		local c = item:byte(i);
+		if not node[c] then
+			node[c] = new_node();
+		end
+		node = node[c];
+	end
+	node.terminal = true;
+	node.value = value;
+end
+
+local function _remove(node, item, i)
+	if i > #item then
+		if node.terminal then
+			node.terminal = nil;
+			node.value = nil;
+		end
+		if next(node) ~= nil then
+			return node;
+		end
+		return nil;
+	end
+	local c = item:byte(i);
+	local child = node[c];
+	local ret;
+	if child then
+		ret = _remove(child, item, i+1);
+		node[c] = ret;
+	end
+	if ret == nil and next(node) == nil then
+		return nil;
+	end
+	return node;
+end
+
+function trie_methods:remove(item)
+	return _remove(self.root, item, 1);
+end
+
+function trie_methods:get(item, partial)
+	local value;
+	local node = self.root;
+	local len = #item;
+	for i = 1, len do
+		if partial and node.terminal then
+			value = node.value;
+		end
+		local c = item:byte(i);
+		node = node[c];
+		if not node then
+			return value, i - 1;
+		end
+	end
+	return node.value, len;
+end
+
+function trie_methods:add(item)
+	return self:set(item, true);
+end
+
+function trie_methods:contains(item, partial)
+	return self:get(item, partial) ~= nil;
+end
+
+function trie_methods:longest_prefix(item)
+	return select(2, self:get(item));
+end
+
+function trie_methods:add_subnet(item, bits)
+	item = item.packed:sub(1, math.ceil(bits/8));
+	local existing = self:get(item);
+	if not existing then
+		existing = { bits };
+		return self:set(item, existing);
+	end
+
+	-- Simple insertion sort
+	for i = 1, #existing do
+		local v = existing[i];
+		if v == bits then
+			return; -- Already in there
+		elseif v > bits then
+			table.insert(existing, v, i);
+			return;
+		end
+	end
+end
+
+function trie_methods:remove_subnet(item, bits)
+	item = item.packed:sub(1, math.ceil(bits/8));
+	local existing = self:get(item);
+	if not existing then
+		return;
+	end
+
+	-- Simple insertion sort
+	for i = 1, #existing do
+		local v = existing[i];
+		if v == bits then
+			table.remove(existing, i);
+			break;
+		elseif v > bits then
+			return; -- Stop search
+		end
+	end
+
+	if #existing == 0 then
+		self:remove(item);
+	end
+end
+
+function trie_methods:has_ip(item)
+	item = item.packed;
+	local node = self.root;
+	local len = #item;
+	for i = 1, len do
+		if node.terminal then
+			return true;
+		end
+
+		local c = item:byte(i);
+		local child = node[c];
+		if not child then
+			for child_byte, child_node in pairs(node) do
+				if type(child_byte) == "number" and child_node.terminal then
+					local bits = child_node.value;
+					for j = #bits, 1, -1 do
+						local b = bits[j]-((i-1)*8);
+						if b ~= 8 then
+							local mask = bit.bnot(2^b-1);
+							if bit.band(bit.bxor(c, child_byte), mask) == 0 then
+								return true;
+							end
+						end
+					end
+				end
+			end
+			return false;
+		end
+		node = child;
+	end
+end
+
+local function new()
+	return setmetatable({
+		root = new_node();
+	}, trie_mt);
+end
+
+local function is_trie(o)
+	return getmetatable(o) == trie_mt;
+end
+
+return {
+	new = new;
+	is_trie = is_trie;
+};
--- a/mod_audit_auth/mod_audit_auth.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_audit_auth/mod_audit_auth.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -1,25 +1,55 @@
-local jid = require"util.jid";
+local cache = require "util.cache";
+local jid = require "util.jid";
 local st = require "util.stanza";
 
 module:depends("audit");
 -- luacheck: read globals module.audit
 
 local only_passwords = module:get_option_boolean("audit_auth_passwords_only", true);
+local cache_size = module:get_option_number("audit_auth_cache_size", 128);
+local repeat_failure_timeout = module:get_option_number("audit_auth_repeat_failure_timeout");
+local repeat_success_timeout = module:get_option_number("audit_auth_repeat_success_timeout");
 
+local failure_cache = cache.new(cache_size);
 module:hook("authentication-failure", function(event)
 	local session = event.session;
-	module:audit(jid.join(session.sasl_handler.username, module.host), "authentication-failure", {
-		session = session,
+
+	local username = session.sasl_handler.username;
+	if repeat_failure_timeout then
+		local cache_key = ("%s\0%s"):format(username, session.ip);
+		local last_failure = failure_cache:get(cache_key);
+		local now = os.time();
+		if last_failure and (now - last_failure) > repeat_failure_timeout then
+			return;
+		end
+		failure_cache:set(cache_key, now);
+	end
+
+	module:audit(jid.join(username, module.host), "authentication-failure", {
+		session = session;
 	});
 end)
 
+local success_cache = cache.new(cache_size);
 module:hook("authentication-success", function(event)
 	local session = event.session;
 	if only_passwords and session.sasl_handler.fast then
 		return;
 	end
-	module:audit(jid.join(session.sasl_handler.username, module.host), "authentication-success", {
-		session = session,
+
+	local username = session.sasl_handler.username;
+	if repeat_success_timeout then
+		local cache_key = ("%s\0%s"):format(username, session.ip);
+		local last_success = success_cache:get(cache_key);
+		local now = os.time();
+		if last_success and (now - last_success) > repeat_success_timeout then
+			return;
+		end
+		success_cache:set(cache_key, now);
+	end
+
+	module:audit(jid.join(username, module.host), "authentication-success", {
+		session = session;
 	});
 end)
 
--- a/mod_audit_status/mod_audit_status.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_audit_status/mod_audit_status.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -9,10 +9,14 @@
 
 local store = module:open_store(nil, "keyval+");
 
+-- This is global, to make it available to other modules
+crashed = false; --luacheck: ignore 131/crashed
+
 module:hook_global("server-started", function ()
 	local recorded_status = store:get();
 	if recorded_status and recorded_status.status == "started" then
 		module:audit(nil, "server-crashed", { timestamp = recorded_status.heartbeat });
+		crashed = true;
 	end
 	module:audit(nil, "server-started");
 	store:set_key(nil, "status", "started");
--- a/mod_auth_oauth_external/README.md	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_auth_oauth_external/README.md	Sun Feb 16 16:09:03 2025 +0700
@@ -4,7 +4,7 @@
 - Stage-Alpha
 ---
 
-This module provides external authentication via an external [AOuth
+This module provides external authentication via an external [OAuth
 2](https://datatracker.ietf.org/doc/html/rfc7628) authorization server
 and supports the [SASL OAUTHBEARER authentication][rfc7628]
 mechanism as well as PLAIN for legacy clients (this is all of them).
--- a/mod_blocking/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_blocking/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,8 +1,17 @@
 ---
 labels:
-- 'Stage-Alpha'
-summary: 'XEP-0191: Simple Communications Blocking support'
-...
+- Stage-Deprecated
+rockspec:
+  dependencies:
+  - mod_privacy_lists
+summary: "XEP-0191: Simple Communications Blocking support"
+---
+
+::: {.alert .alert-warning}
+This module is deprecated as it depends on the deprecated
+[mod_privacy_lists], use the core module
+[mod_blocklist][doc:modules:mod_blocklist] instead.
+:::
 
 Introduction
 ============
@@ -33,12 +42,12 @@
 Configuration
 =============
 
-Simply ensure that mod\_privacy (or [mod\_privacy\_lists] in 0.10+) and
-mod\_blocking are loaded in your modules\_enabled list:
+Simply ensure that [mod_privacy_lists] and mod_blocking are loaded in
+your modules_enabled list:
 
         modules_enabled = {
                         -- ...
-                        "privacy", -- or privacy_lists in Prosody 0.10+
+                        "privacy_lists",
                         "blocking",
                         -- ...
 
--- a/mod_client_management/mod_client_management.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_client_management/mod_client_management.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -116,17 +116,22 @@
 	end
 
 	-- Update state
-	local legacy_info = session.client_management_info;
 	client_state.full_jid = session.full_jid;
 	client_state.last_seen = now;
-	client_state.mechanisms[legacy_info.mechanism] = now;
-	if legacy_info.fast_auth then
-		client_state.fast_auth = now;
-	end
 
-	local token_id = legacy_info.token_info and legacy_info.token_info.id;
-	if token_id then
-		client_state.auth_token_id = token_id;
+	local legacy_info = session.client_management_info;
+	if legacy_info then
+		client_state.mechanisms[legacy_info.mechanism] = now;
+		if legacy_info.fast_auth then
+			client_state.fast_auth = now;
+		end
+
+		local token_id = legacy_info.token_info and legacy_info.token_info.id;
+		if token_id then
+			client_state.auth_token_id = token_id;
+		end
+	else
+		session.log("warn", "Missing client management info")
 	end
 
 	-- Store updated state
--- a/mod_compat_roles/mod_compat_roles.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_compat_roles/mod_compat_roles.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -50,8 +50,14 @@
 	if not role_permissions then
 		return false;
 	end
+	if role_permissions[permission] then
+		return true;
+	end
 	local next_role = role_inheritance[role_name];
-	return not not permissions[role_name][permission] or (next_role and role_may(host, next_role, permission));
+	if not next_role then
+		return false;
+	end
+	return role_may(host, next_role, permission);
 end
 
 function moduleapi.may(self, action, context)
--- a/mod_conversejs/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_conversejs/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -158,6 +158,34 @@
 
 The example above uses the `[[` and `]]` syntax simply because it will not conflict with any embedded quotes.
 
+Custimizing the generated PWA options
+-------------------------------------
+
+``` {.lua}
+conversejs_name = "Service name" -- Also used as the web page title
+conversejs_short_name = "Shorter name"
+conversejs_description = "Description of the service"
+conversejs_manifest_icons = {
+	{
+	    src = "https://example.com/logo/512.png",
+    	sizes = "512x512",
+	},
+	{
+    	src = "https://example.com/logo/192.png",
+	    sizes = "192x192",
+	},
+	{
+    	src = "https://example.com/logo/192.svg",
+	    sizes = "192x192",
+	},
+	{
+	    src = "https://example.com/logo/512.svg",
+    	sizes = "512x512",
+	},
+}
+conversejs_pwa_color = "#397491"
+```
+
 Compatibility
 =============
 
--- a/mod_conversejs/mod_conversejs.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_conversejs/mod_conversejs.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -118,6 +118,11 @@
 
 local add_tags = module:get_option_array("conversejs_tags", {});
 
+local service_name = module:get_option_string("name", "Prosody IM and Converse.js");
+local service_short_name = module:get_option_string("short_name", "Converse");
+local service_description = module:get_option_string("description", "Messaging Freedom")
+local pwa_color = module:get_option_string("pwa_color", "#397491")
+
 module:provides("http", {
 	title = "Converse.js";
 	route = {
@@ -126,7 +131,9 @@
 
 			event.response.headers.content_type = "text/html";
 			return render(html_template, {
-					service_name = module:get_option_string("name");
+					service_name = service_name;
+					-- note that using a relative path won’t work as this URL doesn’t end in a /
+					manifest_url = module:http_url().."/manifest.json",
 					header_scripts = { js_url };
 					header_style = { css_url };
 					header_tags = add_tags;
@@ -143,6 +150,42 @@
 			event.response.headers.content_type = "application/javascript";
 			return js_template:format(json_encode(converse_options));
 		end;
+		["GET /manifest.json"] = function (event)
+			-- See manifest.json in the root of Converse.js’s git repository
+			local data = {
+				short_name = service_short_name,
+				name = service_name,
+				description = service_description,
+				categories = {"social"},
+				icons = module:get_option_array("manifest_icons", {
+					{
+						src = cdn_url..version.."/dist/images/logo/conversejs-filled-512.png",
+						sizes = "512x512",
+					},
+					{
+						src = cdn_url..version.."/dist/images/logo/conversejs-filled-192.png",
+						sizes = "192x192",
+					},
+					{
+						src = cdn_url..version.."/dist/images/logo/conversejs-filled-192.svg",
+						sizes = "192x192",
+					},
+					{
+						src = cdn_url..version.."/dist/images/logo/conversejs-filled-512.svg",
+						sizes = "512x512",
+					},
+				}),
+				start_url = module:http_url(),
+				background_color = pwa_color,
+				display = "standalone",
+				scope = module:http_url().."/",
+				theme_color = pwa_color,
+			}
+			return {
+				headers = { content_type = "application/schema+json" },
+				body = json_encode(data),
+			}
+		end;
 		["GET /dist/*"] = serve_dist;
 	}
 });
--- a/mod_conversejs/templates/template.html	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_conversejs/templates/template.html	Sun Feb 16 16:09:03 2025 +0700
@@ -5,9 +5,10 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 {header_style#
 <link rel="stylesheet" type="text/css" media="screen" href="{item}"/>}
+<link rel="manifest" href="{manifest_url}">
 {header_scripts#
 <script charset="utf-8" src="{item}"></script>}
-<title>{service_name?Prosody IM and Converse.js}</title>
+<title>{service_name}</title>
 {header_tags#
 {item!}}
 </head>
--- a/mod_csi_battery_saver/mod_csi_battery_saver.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_csi_battery_saver/mod_csi_battery_saver.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -85,6 +85,17 @@
 end
 
 local function is_important(stanza, session)
+	-- some special handlings
+	if stanza == " " then						-- whitespace keepalive
+		return true;
+	elseif type(stanza) == "string" then		-- raw data
+		return true;
+	elseif not st.is_stanza(stanza) then		-- this should probably never happen
+		return true;
+	end
+	if stanza.attr.xmlns ~= nil then			-- nonzas (stream errors, stream management etc.)
+		return true;
+	end
 	local st_name = stanza and stanza.name or nil;
 	if not st_name then return true; end	-- nonzas are always important
 	if st_name == "presence" then
@@ -104,8 +115,19 @@
 
 		local st_type = stanza.attr.type;
 
-		-- headline message are always not important
-		if st_type == "headline" then return false; end
+		-- errors are always important
+		if st_type == "error" then return true; end;
+
+		-- headline message are always not important, with some exceptions
+		if st_type == "headline" then
+			-- allow headline pushes of mds updates (XEP-0490)
+			if stanza:find("{http://jabber.org/protocol/pubsub#event}event/items@node") == "urn:xmpp:mds:displayed:0" then return true; end;
+			return false
+		end
+
+		-- mediated muc invites
+		if stanza:find("{http://jabber.org/protocol/muc#user}x/invite") then return true; end;
+		if stanza:get_child("x", "jabber:x:conference") then return true; end;
 
 		-- chat markers (XEP-0333) are important, too, because some clients use them to update their notifications
 		if stanza:child_with_ns("urn:xmpp:chat-markers:0") then return true; end;
--- a/mod_debug_traceback/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_debug_traceback/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -22,4 +22,4 @@
 
 # Compatibility
 
-Prosody 0.11 or later.
+Prosody 0.12 or later.
--- a/mod_debug_traceback/mod_debug_traceback.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_debug_traceback/mod_debug_traceback.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -46,9 +46,4 @@
 	count = count + 1;
 end
 
-local mod_posix = module:depends("posix");
-if rawget(mod_posix, "features") and mod_posix.features.signal_events then
-	module:hook("signal/"..signal_name, dump_traceback);
-else
-	require"util.signal".signal(signal_name, dump_traceback);
-end
+module:hook("signal/"..signal_name, dump_traceback);
--- a/mod_file_management/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_file_management/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,6 +1,7 @@
 ---
 description: File management for uploaded files
-labels: 'Stage-Alpha'
+labels:
+- Stage-Alpha
 ---
 
 Introduction
--- a/mod_firewall/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_firewall/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -662,9 +662,9 @@
 
 ### Reporting
 
-  Action                    Description
-  ------------------------  ---------------------------------------------------------------------------------------------------------------------------------------------------------
-  `REPORT=jid reason text`  Forwards the full stanza to `jid` with a XEP-0377 abuse report attached.
+  Action                           Description
+  -------------------------------  ---------------------------------------------------------------------------------------------------------------------------------------------------------
+  `REPORT TO=jid [reason] [text]`  Forwards the full stanza to `jid` with a XEP-0377 abuse report attached.
 
 Only the `jid` is mandatory. The `reason` parameter should be either `abuse`, `spam` or a custom URI. If not specified, it defaults to `abuse`.
 After the reason, some human-readable text may be included to explain the report.
--- a/mod_firewall/actions.lib.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_firewall/actions.lib.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -263,18 +263,18 @@
 	local where, reason, text = spec:match("^%s*(%S+) *(%S*) *(.*)$");
 	if reason == "spam" then
 		reason = "urn:xmpp:reporting:spam";
-	elseif reason == "abuse" or not reason then
+	elseif reason == "abuse" or not reason or reason == "" then
 		reason = "urn:xmpp:reporting:abuse";
 	end
 	local code = [[
-		local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
+		local newstanza = st.stanza("message", { to = %q, from = current_host, id = new_short_id() }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
 		local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza):up();
 		newstanza:tag("report", { xmlns = "urn:xmpp:reporting:1", reason = %q })
 		do local text = %q; if text ~= "" then newstanza:text_tag("text", text); end end
 		newstanza:up();
 		core_post_stanza(session, newstanza);
 	]];
-	return code:format(where, reason, text), { "core_post_stanza", "current_host", "st" };
+	return code:format(where, reason, text), { "core_post_stanza", "current_host", "st", "new_short_id" };
 end
 
 return action_handlers;
--- a/mod_firewall/mod_firewall.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_firewall/mod_firewall.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -306,6 +306,15 @@
 			"iplib"
 		}
 	};
+	new_short_id = {
+		global_code = [[local new_short_id = require "util.id".short;]];
+	};
+	new_medium_id = {
+		global_code = [[local new_medium_id = require "util.id".medium;]];
+	};
+	new_long_id = {
+		global_code = [[local new_long_id = require "util.id".long;]];
+	};
 };
 
 local function include_dep(dependency, code)
@@ -612,7 +621,7 @@
 local function resolve_script_path(script_path)
 	local relative_to = prosody.paths.config;
 	if script_path:match("^module:") then
-		relative_to = module.path:sub(1, -#("/mod_"..module.name..".lua"));
+		relative_to = module:get_directory();
 		script_path = script_path:match("^module:(.+)$");
 	end
 	return resolve_relative_path(relative_to, script_path);
--- a/mod_host_status_check/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_host_status_check/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,5 +1,6 @@
 ---
-labels: Stage-Beta
+labels:
+- Stage-Beta
 summary: Host status check
 ...
 
--- a/mod_host_status_heartbeat/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_host_status_heartbeat/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,5 +1,6 @@
 ---
-labels: Stage-Beta
+labels:
+- Stage-Beta
 summary: Host status heartbeat
 ...
 
--- a/mod_http_admin_api/mod_http_admin_api.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_admin_api/mod_http_admin_api.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -80,7 +80,9 @@
 local function token_info_to_invite_info(token_info)
 	local additional_data = token_info.additional_data;
 	local groups = additional_data and additional_data.groups or nil;
+	local roles = additional_data and additional_data.roles or nil;
 	local source = additional_data and additional_data.source or nil;
+	local note = additional_data and additional_data.note or nil;
 	local reset = not not (additional_data and additional_data.allow_reset or nil);
 	return {
 		id = token_info.token;
@@ -93,8 +95,10 @@
 		created_at = token_info.created_at;
 		expires = token_info.expires;
 		groups = groups;
+		roles = roles;
 		source = source;
 		reset = reset;
+		note = note;
 	};
 end
 
@@ -153,11 +157,15 @@
 		end
 		invite = invites.create_group(options.groups, {
 			source = source;
+			roles = options.roles;
+			note = options.note;
 		}, options.ttl);
 	elseif invite_type == "account" then
 		invite = invites.create_account(options.username, {
 			source = source;
 			groups = options.groups;
+			roles = options.roles;
+			note = options.note;
 		}, options.ttl);
 	else
 		return 400;
@@ -762,6 +770,11 @@
 	result.cpu = maybe_export_plain_counter(families.process_cpu_seconds);
 	result.c2s = maybe_export_summed_gauge(families["prosody_mod_c2s/connections"])
 	result.uploads = maybe_export_summed_gauge(families["prosody_mod_http_file_share/total_storage_bytes"]);
+	result.users = {
+		active_1d = maybe_export_summed_gauge(families["prosody_mod_measure_active_users/active_users_1d"]);
+		active_7d = maybe_export_summed_gauge(families["prosody_mod_measure_active_users/active_users_7d"]);
+		active_30d = maybe_export_summed_gauge(families["prosody_mod_measure_active_users/active_users_30d"]);
+	};
 	return json.encode(result);
 end
 
@@ -790,9 +803,13 @@
 	if body.recipients == "online" then
 		announce.send_to_online(message, host);
 	elseif body.recipients == "all" then
-		for username in usermanager.users(host) do
-			message.attr.to = username .. "@" .. host
-			module:send(st.clone(message))
+		if announce.send_to_all then
+			announce.send_to_all(message, host);
+		else -- COMPAT w/ 0.12 and trunk before e22609460975
+			for username in usermanager.users(host) do
+				message.attr.to = username .. "@" .. host
+				module:send(st.clone(message))
+			end
 		end
 	else
 		for _, addr in ipairs(body.recipients) do
--- a/mod_http_admin_api/openapi.yaml	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_admin_api/openapi.yaml	Sun Feb 16 16:09:03 2025 +0700
@@ -545,6 +545,10 @@
           type: string
           description: HTTPS URL of invite page (use in preference to XMPP URI when available)
           nullable: true
+        note:
+          type: string
+          nullable: true
+          description: Free-form text note/annotation to help identify the invitation
         created_at:
           type: integer
           description: Unix timestamp of invite creation
@@ -557,6 +561,12 @@
           items:
             type: string
             description: Group ID
+        roles:
+          type: array
+          description: Array of role names that accepting users will have (primary first)
+          items:
+            type: string
+            description: Role name
         source:
           type: string
           description: |
@@ -586,6 +596,17 @@
           items:
             type: string
             description: "Group ID"
+        roles:
+          type: array
+          nullable: true
+          description: "List of roles the new account should have (primary role first)"
+          items:
+            type: string
+            description: "Role name"
+        note:
+          type: string
+          nullable: true
+          description: Free-form text note/annotation to help identify the invitation
     NewGroupInvite:
       type: object
       properties:
@@ -601,6 +622,17 @@
           description: "IDs of existing group to add the new accounts to"
         group_options:
           $ref: '#/components/schemas/NewGroup'
+        roles:
+          type: array
+          nullable: true
+          description: "List of roles the new accounts should have (primary role first)"
+          items:
+            type: string
+            description: "Role name"
+        note:
+          type: string
+          nullable: true
+          description: Free-form text note/annotation to help identify the invitation
     NewResetInvite:
       type: object
       properties:
--- a/mod_http_host_status_check/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_host_status_check/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,5 +1,6 @@
 ---
-labels: Stage-Beta
+labels:
+- Stage-Beta
 summary: HTTP Host Status Check
 ...
 
--- a/mod_http_muc_log/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_muc_log/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -13,7 +13,7 @@
 ============
 
 This module provides a built-in web interface to view chatroom logs
-stored by [mod\_mam\_muc].
+stored by [mod\_muc\_mam].
 
 Installation
 ============
@@ -29,7 +29,7 @@
 ``` lua
 Component "conference.example.com" "muc"
 modules_enabled = {
-    "mam_muc";
+    "muc_mam";
     "http_muc_log";
 }
 storage = {
--- a/mod_http_oauth2/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_oauth2/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -102,7 +102,7 @@
 client registration.
 
 Dynamic client registration can be enabled by configuring a JWT key. Algorithm
-defaults to *HS256* lifetime defaults to forever.
+defaults to *HS256*, lifetime defaults to forever.
 
 ```lua
 oauth2_registration_key = "securely generated JWT key here"
@@ -202,7 +202,7 @@
 
 -   Authorization Code grant, optionally with Proof Key for Code Exchange
 -   Device Authorization Grant
--   Resource owner password grant *(likely to be phased out in the future)*
+-   Resource owner password grant *(disabled by default)*
 -   Implicit flow *(disabled by default)*
 -   Refresh Token grants
 
@@ -214,7 +214,7 @@
 allowed_oauth2_grant_types = {
 	"authorization_code"; -- authorization code grant
 	"device_code";
-	"password"; -- resource owner password grant
+	-- "password"; -- resource owner password grant disabled by default
 }
 
 allowed_oauth2_response_types = {
--- a/mod_http_oauth2/mod_http_oauth2.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -1128,7 +1128,7 @@
 		headers = { content_type = "application/json" };
 		body = json.encode {
 			active = true;
-			client_id = credentials.username; -- We don't really know for sure
+			client_id = credentials.username; -- Verified via client hash
 			username = jid.node(token_info.jid);
 			scope = token_info.grant.data.oauth2_scopes;
 			token_type = purpose_map[token_info.purpose];
--- a/mod_http_upload/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_upload/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,6 +1,7 @@
 ---
 description: HTTP File Upload
-labels: 'Stage-Alpha'
+labels:
+- Stage-Alpha
 ---
 
 Introduction
--- a/mod_http_upload_external/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_http_upload_external/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,6 +1,7 @@
 ---
 description: HTTP File Upload (external service)
-labels: 'Stage-Alpha'
+labels:
+- Stage-Alpha
 ---
 
 Introduction
@@ -19,6 +20,7 @@
 * [Python3+Flask implementation](https://github.com/horazont/xmpp-http-upload)
 * [Go implementation, Prosody Filer](https://github.com/ThomasLeister/prosody-filer)
 * [Perl implementation for nginx](https://github.com/weiss/ngx_http_upload)
+* [Rust implementation](https://gitlab.com/nyovaya/xmpp-http-upload)
 
 To implement your own service compatible with this module, check out the implementation notes below
 (and if you publish your implementation - let us know!).
@@ -75,10 +77,10 @@
 ------
 
 You may want to give upload access to additional entities such as components
-by using the `http_upload_access` config option.
+by using the `http_upload_external_access` config option.
 
 ``` {.lua}
-http_upload_access = {"gateway.example.com"};
+http_upload_external_access = {"gateway.example.com"};
 ```
 
 Compatibility
--- a/mod_invites_tracking/mod_invites_tracking.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_invites_tracking/mod_invites_tracking.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -4,6 +4,11 @@
 	local validated_invite = event.validated_invite or (event.session and event.session.validated_invite);
 	local new_username = event.username;
 
+	if not validated_invite then
+		module:log("debug", "No invitation found for registration of %s", new_username);
+		return;
+	end
+
 	local invite_id = nil;
 	local invite_source = nil;
 	if validated_invite then
@@ -11,7 +16,7 @@
 		invite_id = validated_invite.token;
 	end
 
-	tracking_store:set(new_username, {invite_id = validated_invite.token, invite_source = invite_source});
+	tracking_store:set(new_username, {invite_id = invite_id, invite_source = invite_source});
 	module:log("debug", "recorded that invite from %s was used to create %s", invite_source, new_username)
 end);
 
--- a/mod_lastlog2/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_lastlog2/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -34,7 +34,7 @@
 
 You can check a user's last activity by running:
 
-    prosodyctl mod_lastlog username@example.com
+    prosodyctl mod_lastlog2 username@example.com
 
 # Compatibility
 
--- a/mod_lastlog2/mod_lastlog2.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_lastlog2/mod_lastlog2.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -69,7 +69,7 @@
 
 function module.command(arg)
 	if not arg[1] or arg[1] == "--help" then
-		require"util.prosodyctl".show_usage([[mod_lastlog <user@host>]], [[Show when user last logged in or out]]);
+		require"util.prosodyctl".show_usage([[mod_lastlog2 <user@host>]], [[Show when user last logged in or out]]);
 		return 1;
 	end
 	local user, host = jid.prepped_split(table.remove(arg, 1));
--- a/mod_log_ringbuffer/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_log_ringbuffer/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -120,4 +120,4 @@
 
 # Compatibility
 
-0.11 and later.
+0.12 and later.
--- a/mod_log_ringbuffer/mod_log_ringbuffer.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_log_ringbuffer/mod_log_ringbuffer.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -90,6 +90,8 @@
 	return write, dump;
 end
 
+local event_hooks = {};
+
 local function ringbuffer_log_sink_maker(sink_config)
 	local write, dump = new_buffer(sink_config);
 
@@ -106,9 +108,11 @@
 	end
 
 	if sink_config.signal then
-		require "util.signal".signal(sink_config.signal, handler);
+		module:hook_global("signal/"..sink_config.signal, handler);
+		event_hooks[handler] = "signal/"..sink_config.signal;
 	elseif sink_config.event then
 		module:hook_global(sink_config.event, handler);
+		event_hooks[handler] = sink_config.event;
 	end
 
 	return function (name, level, message, ...)
@@ -117,4 +121,11 @@
 	end;
 end
 
+module:hook_global("reopen-log-files", function()
+	for handler, event_name in pairs(event_hooks) do
+		module:unhook_object_event(prosody.events, event_name, handler);
+		event_hooks[handler] = nil;
+	end
+end, 1);
+
 loggingmanager.register_sink_type("ringbuffer", ringbuffer_log_sink_maker);
--- a/mod_mam_archive/mod_mam_archive.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_mam_archive/mod_mam_archive.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -20,12 +20,6 @@
 local resolve_relative_path = require "core.configmanager".resolve_relative_path;
 
 -- Feature discovery
-local xmlns_archive = "urn:xmpp:archive"
-local feature_archive = st.stanza("feature", {xmlns=xmlns_archive}):tag("optional");
-if(global_default_policy) then
-    feature_archive:tag("default");
-end
-module:add_extension(feature_archive);
 module:add_feature("urn:xmpp:archive:auto");
 module:add_feature("urn:xmpp:archive:manage");
 module:add_feature("urn:xmpp:archive:pref");
--- a/mod_measure_active_users/mod_measure_active_users.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_measure_active_users/mod_measure_active_users.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -41,8 +41,10 @@
 	measure_d1(active_d1);
 	measure_d7(active_d7);
 	measure_d30(active_d30);
-
-	return 3600 + (300*math.random());
 end
 
+-- Schedule at startup
 module:add_timer(15, update_calculations);
+
+-- Recalculate hourly
+module:hourly(update_calculations);
--- a/mod_muc_adhoc_bots/mod_muc_adhoc_bots.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_muc_adhoc_bots/mod_muc_adhoc_bots.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -37,7 +37,7 @@
 					for j,item in ipairs(query.tags) do
 						item.attr.node = json.encode({ jid = item.attr.jid, node = item.attr.node })
 						item.attr.jid = event.stanza.attr.to
-						reply:add_child(item):up()
+						reply:add_child(item)
 					end
 				end
 			end
--- a/mod_muc_eventsource/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_muc_eventsource/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,5 +1,6 @@
 ---
-labels: 'Stage-Beta'
+labels:
+- Stage-Beta
 summary: Subscribe to MUC rooms using the HTML5 EventSource API
 ...
 
--- a/mod_muc_moderation/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_muc_moderation/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -16,7 +16,7 @@
 Example [MUC component][doc:chatrooms] configuration:
 
 ``` {.lua}
-VirtualHost "channels.example.com" "muc"
+Component "channels.example.com" "muc"
 modules_enabled = {
     "muc_mam",
     "muc_moderation",
@@ -40,5 +40,4 @@
 
 -   [Conversations](https://codeberg.org/iNPUTmice/Conversations/issues/20)
 -   [Dino](https://github.com/dino/dino/issues/1133)
--   [Poezio](https://lab.louiz.org/poezio/poezio/-/issues/3543)
 -   [Profanity](https://github.com/profanity-im/profanity/issues/1336)
--- a/mod_muc_restrict_avatars/mod_muc_restrict_avatars.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_muc_restrict_avatars/mod_muc_restrict_avatars.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -9,11 +9,46 @@
 	return tag;
 end
 
+-- Function to determine if avatar restriction is enabled
+local function is_avatar_restriction_enabled(room)
+	return room._data.restrict_avatars;
+end
+
+-- Add MUC configuration form option for avatar restriction
+module:hook("muc-config-form", function(event)
+	local room, form = event.room, event.form;
+	table.insert(form, {
+		name = "restrict_avatars",
+		type = "boolean",
+		label = "Restrict avatars to members only",
+		value = is_avatar_restriction_enabled(room)
+	});
+end);
+
+-- Handle MUC configuration form submission
+module:hook("muc-config-submitted", function(event)
+	local room, fields, changed = event.room, event.fields, event.changed;
+	local restrict_avatars = fields["restrict_avatars"];
+
+	if room and restrict_avatars ~= is_avatar_restriction_enabled(room) then
+		-- Update room settings based on the submitted value
+		room._data.restrict_avatars = restrict_avatars;
+		-- Mark the configuration as changed
+		if type(changed) == "table" then
+			changed["restrict_avatars"] = true;
+		else
+			event.changed = true;
+		end
+	end
+end);
+
+-- Handle presence/full events to filter avatar advertisements
 module:hook("presence/full", function(event)
 	local stanza = event.stanza;
 	local room = mod_muc.get_room_from_jid(bare_jid(stanza.attr.to));
-
-	if not room:get_affiliation(stanza.attr.from) then
-		stanza:maptags(filter_avatar_advertisement);
+	if room and not room:get_affiliation(stanza.attr.from) then
+		if is_avatar_restriction_enabled(room) then
+			stanza:maptags(filter_avatar_advertisement);
+		end
 	end
 end, 1);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_restrict_pm/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -0,0 +1,30 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: Limit who may send and recieve MUC PMs
+...
+
+# Introduction
+
+This module adds configurable MUC options that restrict and limit who may send MUC PMs to other users.
+
+If a user does not have permissions to send a MUC PM, the MUC will send a policy violation stanza.
+
+# Setup
+
+```lua
+Component "conference.example.org" "muc"
+
+modules_enabled = {
+	"muc_restrict_pm";
+}
+```
+
+Compatibility
+=============
+
+  ----- -----
+  0.12  Works
+  0.11  Probably does not work
+  ----- -----
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_restrict_pm/mod_muc_restrict_pm.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -0,0 +1,99 @@
+local st = require "util.stanza";
+local muc_util = module:require "muc/util";
+local valid_roles = muc_util.valid_roles;
+
+-- Backported backwards compatibility map (Thanks MattJ)
+local compat_map = {
+	everyone = "visitor";
+	participants = "participant";
+	moderators = "moderator";
+	members = "affiliated";
+};
+
+local function get_allow_pm(room)
+	local val = room._data.allow_pm;
+	return compat_map[val] or val or 'visitor';
+end
+
+local function set_allow_pm(room, val)
+	if get_allow_pm(room) == val then return false; end
+	room._data.allow_pm = val;
+	return true;
+end
+
+local function get_allow_modpm(room)
+	return room._data.allow_modpm or false;
+end
+
+local function set_allow_modpm(room, val)
+	if get_allow_modpm(room) == val then return false; end
+	room._data.allow_modpm = val;
+	return true;
+end
+
+module:hook("muc-config-form", function(event)
+	local pmval = get_allow_pm(event.room);
+	table.insert(event.form, {
+		name = 'muc#allow_pm';
+		type = 'list-single';
+		label = 'Allow PMs from';
+		options = {
+			{ value = 'visitor', label = 'Everyone', default = pmval == 'visitor' },
+			{ value = 'participant', label = 'Participants', default = pmval == 'participant' },
+			{ value = 'affiliated', label = 'Members', default = pmval == 'affiliated' },
+			{ value = 'moderator', label = 'Moderators', default = pmval == 'moderator' },
+			{ value = 'none', label = 'No one', default = pmval == 'none' }
+		}
+	});
+	table.insert(event.form, {
+		name = 'muc#allow_modpm';
+		type = 'boolean';
+		label = 'Allow PMs to moderators';
+		value = get_allow_modpm(event.room)
+	});
+end);
+
+module:hook("muc-config-submitted/muc#allow_pm", function(event)
+	if set_allow_pm(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+module:hook("muc-config-submitted/muc#allow_modpm", function(event)
+	if set_allow_modpm(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+module:hook("muc-private-message", function(event)
+	local stanza, room = event.stanza, event.room;
+	local from_occupant = room:get_occupant_by_nick(stanza.attr.from);
+	local to_occupant = room:get_occupant_by_nick(stanza.attr.to);
+
+	-- To self is always okay
+	if to_occupant.bare_jid == from_occupant.bare_jid then return; end
+
+	if get_allow_modpm(room) then
+		if to_occupant and to_occupant.role == 'moderator'
+		or from_occupant and from_occupant.role == "moderator" then
+			return; -- Allow to/from moderators
+		end
+	end
+
+	local pmval = get_allow_pm(room);
+
+	-- Backported improved handling (Thanks MattJ)
+	if pmval ~= "none" then
+		if pmval == "affiliated" and room:get_affiliation(from_occupant.bare_jid) then
+			return; -- Allow from affiliated users
+		elseif valid_roles[from_occupant.role] >= valid_roles[pmval] then
+			return; -- Allow from a permitted role
+		end
+	end
+
+	room:route_to_occupant(
+		from_occupant,
+		st.error_reply(stanza, "cancel", "policy-violation", "Private messages are restricted", room.jid)
+		);
+	return false;
+end, 1);
--- a/mod_muc_rtbl/mod_muc_rtbl.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_muc_rtbl/mod_muc_rtbl.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -164,7 +164,7 @@
 		module:log("debug", "Blocked private message from user <%s> from room <%s> due to RTBL match", occupant.bare_jid, event.stanza.attr.to);
 		local error_reply = st.error_reply(event.stanza, "cancel", "forbidden", "You are banned from this service", event.room.jid);
 		event.origin.send(error_reply);
-		return true;
+		return false; -- Don't route it
 	end
 end);
 
--- a/mod_nodeinfo2/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_nodeinfo2/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,6 +1,6 @@
 ---
-description: 
-labels: 'Stage-Alpha'
+labels:
+- Stage-Alpha
 ---
 
 Introduction
--- a/mod_pastebin/mod_pastebin.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_pastebin/mod_pastebin.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -100,8 +100,6 @@
 	end
 end
 
-local line_count_pattern = string.rep("[^\n]*\n", line_threshold + 1):sub(1,-2);
-
 function check_message(data)
 	local stanza = data.stanza;
 
@@ -122,7 +120,8 @@
 
 	if ( #body > length_threshold and utf8_length(body) > length_threshold ) or
 		(trigger_string and body:find(trigger_string, 1, true) == 1) or
-		body:find(line_count_pattern) then
+		(select(2, body:gsub("\n", "%0")) >= line_threshold)
+	then
 		if trigger_string and body:sub(1, #trigger_string) == trigger_string then
 			body = body:sub(#trigger_string+1);
 		end
--- a/mod_privacy_lists/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_privacy_lists/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,8 +1,13 @@
 ---
 labels:
-- 'Stage-Beta'
-summary: 'Privacy lists (XEP-0016) support'
-...
+- Stage-Deprecated
+summary: Privacy lists (XEP-0016) support
+---
+
+::: {.alert .alert-warning}
+[XEP-0016 Privacy Lists] and this module has been deprecated, instead
+use [mod_blocklist][doc:modules:mod_blocklist], included with Prosody.
+:::
 
 Introduction
 ------------
@@ -10,7 +15,7 @@
 Privacy lists are a flexible method for blocking communications.
 
 Originally known as mod\_privacy and bundled with Prosody, this module
-is being phased out in favour of the newer simpler blocking (XEP-0191)
+was phased out in favour of the newer simpler blocking (XEP-0191)
 protocol, implemented in [mod\_blocklist][doc:modules:mod_blocklist].
 
 Configuration
--- a/mod_privilege/mod_privilege.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_privilege/mod_privilege.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -69,7 +69,7 @@
     end
     local iq_perm = perms["iq"]
     if iq_perm ~= nil then
-        message:tag("perm", {access="iq"})
+        local perm_el = st.stanza("perm", {access="iq"})
         for namespace, ns_perm in pairs(iq_perm) do
                 local perm_type
                 if ns_perm.set and ns_perm.get then
@@ -81,8 +81,9 @@
                 else
                     perm_type = nil
                 end
-                message:tag("namespace", {ns=namespace, type=perm_type})
+                perm_el:tag("namespace", {ns=namespace, type=perm_type}):up()
         end
+        message:add_child(perm_el)
     end
     session.send(message)
 end
--- a/mod_proxy65_whitelist/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_proxy65_whitelist/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,5 +1,6 @@
 ---
-labels: 'Stage-Alpha'
+labels:
+- Stage-Alpha
 summary: Limit which file transfer users can use
 ...
 
--- a/mod_pubsub_eventsource/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_pubsub_eventsource/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -1,5 +1,6 @@
 ---
-labels: 'Stage-Beta'
+labels:
+- Stage-Beta
 summary: Subscribe to pubsub nodes using the HTML5 EventSource API
 ...
 
--- a/mod_pubsub_mqtt/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_pubsub_mqtt/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -11,35 +11,38 @@
 to embedded devices. This module provides a way for MQTT clients to
 connect to Prosody and publish or subscribe to local pubsub nodes.
 
+The module currently implements MQTT version 3.1.1.
+
 Details
 -------
 
 MQTT has the concept of 'topics' (similar to XMPP's pubsub 'nodes').
 mod\_pubsub\_mqtt maps pubsub nodes to MQTT topics of the form
-`HOST/NODE`, e.g.`pubsub.example.org/mynode`.
+`<HOST>/<TYPE>/<NODE>`, e.g.`pubsub.example.org/json/mynode`.
+
+The 'TYPE' parameter in the topic allows the client to choose the payload
+format it will send/receive. For the supported values of 'TYPE' see the
+'Payloads' section below.
 
 ### Limitations
 
 The current implementation is quite basic, and in particular:
 
 -   Authentication is not supported
--   SSL/TLS is not supported
 -   Only QoS level 0 is supported
 
 ### Payloads
 
 XMPP payloads are always XML, but MQTT does not define a payload format.
-Therefore mod\_pubsub\_mqtt will attempt to convert data of certain
-recognised payload types. Currently supported:
+Therefore mod\_pubsub\_mqtt has some built-in data format translators.
+
+Currently supported data types:
 
--   JSON (see [XEP-0335](http://xmpp.org/extensions/xep-0335.html) for
-    the format)
--   Plain UTF-8 text (wrapped inside
+-   `json`: See [XEP-0335](http://xmpp.org/extensions/xep-0335.html) for
+    the format.
+-   `utf8`: Plain UTF-8 text (wrapped inside
     `<data xmlns="https://prosody.im/protocol/mqtt"/>`)
-
-All other XMPP payload types are sent to the client directly as XML.
-Data published by MQTT clients is currently never translated, and always
-treated as UTF-8 text.
+-   `atom_title`: Returns the title of an Atom entry as UTF-8 data
 
 Configuration
 -------------
@@ -51,16 +54,15 @@
         modules_enabled = { "pubsub_mqtt" }
 
 You may also configure which port(s) mod\_pubsub\_mqtt listens on using
-Prosody's standard config directives, such as `mqtt_ports`. Network
-settings **must** be specified in the global section of the config file,
-not under any particular pubsub component. The default port is 1883
-(MQTT's standard port number).
+Prosody's standard config directives, such as `mqtt_ports` and
+`mqtt_tls_ports`. Network settings **must** be specified in the global section
+of the config file, not under any particular pubsub component. The default
+port is 1883 (MQTT's standard port number) and 8883 for TLS connections.
 
 Compatibility
 -------------
 
   ------- --------------
   trunk   Works
-  0.9     Works
-  0.8     Doesn't work
+  0.12    Works
   ------- --------------
--- a/mod_pubsub_mqtt/mod_pubsub_mqtt.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_pubsub_mqtt/mod_pubsub_mqtt.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -59,6 +59,15 @@
 end
 
 function packet_handlers.connect(session, packet)
+	module:log("info", "MQTT client connected (sending connack)");
+	module:log("debug", "MQTT version: %02x", packet.version);
+	if packet.version ~= 0x04 then -- Version mismatch
+		session.conn:write(mqtt.serialize_packet{
+			type = "connack";
+			data = string.char(0x00, 0x01);
+		});
+		return;
+	end
 	session.conn:write(mqtt.serialize_packet{
 		type = "connack";
 		data = string.char(0x00, 0x00);
@@ -96,27 +105,33 @@
 end
 
 function packet_handlers.subscribe(session, packet)
-	for _, topic in ipairs(packet.topics) do
+	local results = {};
+	for i, topic in ipairs(packet.topics) do
 		module:log("info", "SUBSCRIBE to %s", topic);
 		local host, payload_type, node = topic:match("^([^/]+)/([^/]+)/(.+)$");
 		if not host then
 			module:log("warn", "Invalid topic format - expected: HOST/TYPE/NODE");
-			return;
-		end
-		local pubsub = pubsub_subscribers[host];
-		if not pubsub then
-			module:log("warn", "Unable to locate host/node: %s", topic);
-			return;
+			results[i] = 0x80; -- Failure
+		else
+			local pubsub = pubsub_subscribers[host];
+			if not pubsub then
+				module:log("warn", "Unable to locate host/node: %s", topic);
+				results[i] = 0x80; -- Failure
+			else
+				local node_subs = pubsub[node];
+				if not node_subs then
+					node_subs = {};
+					pubsub[node] = node_subs;
+				end
+				session.subscriptions[topic] = payload_type;
+				node_subs[session] = payload_type;
+				module:log("debug", "Successfully subscribed to %s", topic);
+				results[i] = 0x00; -- Success
+			end
 		end
-		local node_subs = pubsub[node];
-		if not node_subs then
-			node_subs = {};
-			pubsub[node] = node_subs;
-		end
-		session.subscriptions[topic] = payload_type;
-		node_subs[session] = payload_type;
 	end
-
+	local ack = mqtt.serialize_packet{ type = "suback", id = packet.id, results = results };
+	session.conn:write(ack);
 end
 
 function packet_handlers.pingreq(session, packet)
@@ -191,7 +206,7 @@
 						topic = module.host.."/"..payload_type.."/"..event.node;
 						data = data_translators[payload_type].from_item(event.item) or "";
 					};
-					rawset(self, packet);
+					rawset(self, payload_type, packet);
 					return packet;
 				end;
 			});
--- a/mod_pubsub_mqtt/mqtt.lib.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_pubsub_mqtt/mqtt.lib.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -1,4 +1,4 @@
-local bit = require "bit";
+local bit = require "util.bitcompat";
 
 local stream_mt = {};
 stream_mt.__index = stream_mt;
@@ -29,10 +29,25 @@
 	return self:read_bytes(len), len+2;
 end
 
+function stream_mt:read_word()
+	local len1, len2 = self:read_bytes(2):byte(1,2);
+	local result = bit.lshift(len1, 8) + len2;
+	module:log("debug", "read_word(%02x, %02x) = %04x (%d)", len1, len2, result, result);
+	return result;
+end
+
+local function hasbit(byte, n_bit)
+	return bit.band(byte, 2^n_bit) ~= 0;
+end
+
+local function encode_string(str)
+	return string.char(bit.band(#str, 0xff00), bit.band(#str, 0x00ff))..str;
+end
+
 local packet_type_codes = {
 	"connect", "connack",
 	"publish", "puback", "pubrec", "pubrel", "pubcomp",
-	"subscribe", "subak", "unsubscribe", "unsuback",
+	"subscribe", "suback", "unsubscribe", "unsuback",
 	"pingreq", "pingresp",
 	"disconnect"
 };
@@ -59,9 +74,46 @@
 			packet.type = nil; -- Invalid packet
 		else
 			packet.version = self:read_bytes(1):byte();
-			packet.connect_flags = self:read_bytes(1):byte();
-			packet.keepalive_timer = self:read_bytes(1):byte();
+			module:log("debug", "ver: %02x", packet.version);
+			if packet.version ~= 0x04 then
+				module:log("warn", "MQTT version mismatch (got %02x, we support %02x", packet.version, 0x04);
+			end
+			local flags = self:read_bytes(1):byte();
+			module:log("debug", "flags: %02x", flags);
+			packet.keepalive_timer = self:read_bytes(2):byte();
+			module:log("debug", "keepalive: %d", packet.keepalive_timer);
+			packet.connect_flags = {};
 			length = length - 11;
+			packet.connect_flags = {
+				clean_session = hasbit(flags, 1);
+				will = hasbit(flags, 2);
+				will_qos = bit.band(bit.rshift(flags, 2), 0x02);
+				will_retain = hasbit(flags, 5);
+				user_name = hasbit(flags, 7);
+				password = hasbit(flags, 6);
+			};
+			module:log("debug", "%s", require "util.serialization".serialize(packet.connect_flags, "debug"));
+			module:log("debug", "Reading client_id...");
+			packet.client_id = self:read_string();
+			if packet.connect_flags.will then
+				module:log("debug", "Reading will...");
+				packet.will = {
+					topic = self:read_string();
+					message = self:read_string();
+					qos = packet.connect_flags.will_qos;
+					retain = packet.connect_flags.will_retain;
+				};
+			end
+			if packet.connect_flags.user_name then
+				module:log("debug", "Reading username...");
+				packet.username = self:read_string();
+			end
+			if packet.connect_flags.password then
+				module:log("debug", "Reading password...");
+				packet.password = self:read_string();
+			end
+			module:log("debug", "Done parsing connect!");
+			length = 0; -- No payload left
 		end
 	elseif packet.type == "publish" then
 		packet.topic = self:read_string();
@@ -87,6 +139,7 @@
 	if length > 0 then
 		packet.data = self:read_bytes(length);
 	end
+	module:log("debug", "MQTT packet complete!");
 	return packet;
 end
 
@@ -102,7 +155,6 @@
 end
 
 function stream_mt:feed(data)
-	module:log("debug", "Feeding %d bytes", #data);
 	local packets = {};
 	local packet = self.parser(data);
 	while packet do
@@ -135,10 +187,10 @@
 		packet.data = string.char(bit.band(#topic, 0xff00), bit.band(#topic, 0x00ff))..topic..packet.data;
 	elseif packet.type == "suback" then
 		local t = {};
-		for _, topic in ipairs(packet.topics) do
-			table.insert(t, string.char(bit.band(#topic, 0xff00), bit.band(#topic, 0x00ff))..topic.."\000");
+		for i, result_code in ipairs(packet.results) do
+			table.insert(t, string.char(result_code));
 		end
-		packet.data = table.concat(t);
+		packet.data = packet.id..table.concat(t);
 	end
 
 	-- Get length
--- a/mod_pubsub_serverinfo/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_pubsub_serverinfo/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -3,12 +3,16 @@
 - 'Statistics'
 ...
 
-Exposes server information over Pub/Sub per ProtoXEP: PubSub Server Information.
+Exposes server information over Pub/Sub per [XEP-0485: PubSub Server Information](https://xmpp.org/extensions/xep-0485.html).
 
 The module announces support (used to 'opt-in', per the XEP) and publishes the name of the local domain via a Pub/Sub node. The published data
 will contain a 'remote-domain' element for inbound and outgoing s2s connections. These elements will be named only when the remote domain announces
 support ('opts in') too.
 
+**Known issues:**
+
+- [Issue #1841](https://issues.prosody.im/1841): This module conflicts with mod_server_contact_info (both will run, but it may affect the ability of some implementations to read the server/contact information provided).
+
 Installation
 ============
 
--- a/mod_pubsub_serverinfo/mod_pubsub_serverinfo.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_pubsub_serverinfo/mod_pubsub_serverinfo.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -2,18 +2,25 @@
 local json = require "util.json";
 local st = require "util.stanza";
 local new_id = require"util.id".medium;
-local dataform = require "util.dataforms".new;
 
 local local_domain = module:get_host();
-local service = module:get_option(module.name .. "_service") or "pubsub." .. local_domain;
-local node = module:get_option(module.name .. "_node") or "serverinfo";
+local service = module:get_option_string(module.name .. "_service");
+local node = module:get_option_string(module.name .. "_node", "serverinfo");
 local actor = module.host .. "/modules/" .. module.name;
-local publication_interval = module:get_option(module.name .. "_publication_interval") or 300;
-local cache_ttl = module:get_option(module.name .. "_cache_ttl") or 3600;
+local publication_interval = module:get_option_number(module.name .. "_publication_interval", 300);
+local cache_ttl = module:get_option_number(module.name .. "_cache_ttl", 3600);
 local public_providers_url = module:get_option_string(module.name.."_public_providers_url", "https://data.xmpp.net/providers/v2/providers-Ds.json");
 local delete_node_on_unload = module:get_option_boolean(module.name.."_delete_node_on_unload", false);
 local persist_items = module:get_option_boolean(module.name.."_persist_items", true);
 
+if not service and prosody.hosts["pubsub."..module.host] then
+	service = "pubsub."..module.host;
+end
+if not service
+	module:log_status("warn", "No pubsub service specified - module not activated");
+	return;
+end
+
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 
 function module.load()
@@ -29,10 +36,9 @@
 
 	module:add_feature("urn:xmpp:serverinfo:0");
 
-	module:add_extension(dataform {
-		{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/network/serverinfo" },
-		{ name = "serverinfo-pubsub-node", type = "text-single" },
-	}:form({ ["serverinfo-pubsub-node"] = ("xmpp:%s?;node=%s"):format(service, node) }, "result"));
+	module:add_item("server-info-fields", {
+		{ name = "serverinfo-pubsub-node", type = "text-single", value = ("xmpp:%s?;node=%s"):format(service, node) };
+	});
 
 	if cache_ttl < publication_interval then
 		module:log("warn", "It is recommended to have a cache interval higher than the publication interval");
--- a/mod_push2/mod_push2.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_push2/mod_push2.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -536,7 +536,7 @@
 
 	-- only notify if the stanza destination is the mam user we store it for
 	if event.for_user == to then
-		local user_push_services = push2_registrations:get(to)
+		local user_push_services = push2_registrations:get(to) or {}
 
 		-- Urgent stanzas are time-sensitive (e.g. calls) and should
 		-- be pushed immediately to avoid getting stuck in the smacks
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_report_forward/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -0,0 +1,77 @@
+---
+labels:
+- 'Stage-Beta'
+summary: 'Forward spam/abuse reports to a JID'
+---
+
+This module forwards spam/abuse reports (e.g. those submitted by users via
+XEP-0377 via mod_spam_reporting) to one or more JIDs.
+
+## Configuration
+
+Install and enable the module the same as any other:
+
+```lua
+modules_enabled = {
+    ---
+    "report_forward";
+    ---
+}
+```
+
+There are two main options. You can set `report_forward_to` which accepts a
+list of JIDs to send all reports to (default is empty):
+
+```lua
+report_forward_to = { "antispam.example.com" }
+```
+
+You can also control whether the module sends a report to the server from
+which the spam/abuse originated (default is `true`):
+
+```lua
+report_forward_to_origin = false
+```
+
+The module looks up an abuse report address using XEP-0157 (only XMPP
+addresses are accepted). If it fails to find any suitable destination, it will
+log a warning and not send the report.
+
+
+
+## Protocol
+
+This section is intended for developers.
+
+XEP-0377 assumes the report is embedded within another protocol such as
+XEP-0191, and doesn't specify a format for communicating "standalone" reports.
+This module transmits them inside a `<message>` stanza, and adds a `<jid/>`
+element (borrowed from XEP-0268):
+
+```xml
+<message from="prosody.example" to="destination.example">
+    <report xmlns="urn:xmpp:reporting:1" reason="urn:xmpp:reporting:spam">
+        <jid xmlns="urn:xmpp:jid:0">spammer@bad.example</jid>
+        <text>
+          Never came trouble to my house like this.
+        </text>
+    </report>
+</message>
+```
+
+It may also include the reported message, if this has been indicated by the
+user, wrapped in a XEP-0297 `<forwarded/>` element:
+
+```xml
+<message from="prosody.example" to="destination.example">
+  <report reason="urn:xmpp:reporting:spam" xmlns="urn:xmpp:reporting:1">
+    <jid xmlns="urn:xmpp:jid:0">spammer@bad.example</jid>
+    <text>Never came trouble to my house like this.</text>
+  </report>
+  <forwarded xmlns="urn:xmpp:forward:0">
+    <message from="spammer@bad.example" to="victim@prosody.example" type="chat" xmlns="jabber:client">
+      <body>Spam, Spam, Spam, Spam, Spam, Spam, baked beans, Spam, Spam and Spam!</body>
+    </message>
+  </forwarded>
+</message>
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_report_forward/mod_report_forward.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -0,0 +1,152 @@
+local dt = require "util.datetime";
+local jid = require "util.jid";
+local st = require "util.stanza";
+local url = require "socket.url";
+
+local new_id = require "util.id".short;
+local render = require"util.interpolation".new("%b{}", function (s) return s; end);
+
+module:depends("spam_reporting");
+
+local destinations = module:get_option_set("report_forward_to", {});
+
+local archive = module:open_store("archive", "archive");
+
+local cache_size = module:get_option_number("report_forward_contact_cache_size", 256);
+local report_to_origin = module:get_option_boolean("report_forward_to_origin", true);
+local contact_lookup_timeout = module:get_option_number("report_forward_contact_lookup_timeout", 180);
+
+local body_template = module:get_option_string("report_forward_body_template", [[
+SPAM/ABUSE REPORT
+-----------------
+
+Reported JID: {reported_jid}
+
+A user on our service has reported a message originating from the above JID on
+your server.
+
+{reported_message_time&The reported message was sent at: {reported_message_time}}
+
+--
+This message contains also machine-readable payloads, including XEP-0377, in case
+you want to automate handling of these reports. You can receive these reports
+to a different address by setting 'spam-report-addresses' in your server
+contact info configuration. For more information, see https://xmppbl.org/reports/
+]]):gsub("^%s+", ""):gsub("(%S)\n(%S)", "%1 %2");
+
+local report_addresses = require "util.cache".new(cache_size);
+
+local function get_address(form, ...)
+	for i = 1, select("#", ...) do
+		local field_var = select(i, ...);
+		local field = form:get_child_with_attr("field", nil, "var", field_var);
+		if field then
+			for value in field:childtags("value") do
+				local parsed = url.parse(value:get_text());
+				if parsed.scheme == "xmpp" and parsed.path and not parsed.query then
+					return parsed.path;
+				end
+			end
+		else
+			module:log("debug", "No field '%s'", field_var);
+		end
+	end
+end
+
+local function get_origin_report_address(reported_jid)
+	local host = jid.host(reported_jid);
+	local address = report_addresses:get(host);
+	if address then return address; end
+
+	local contact_query = st.iq({ type = "get", to = host, from = module.host, id = new_id() })
+		:query("http://jabber.org/protocol/disco#info");
+
+	return module:send_iq(contact_query, prosody.hosts[module.host], contact_lookup_timeout)
+		:next(function (result)
+			module:log("debug", "Processing contact form...");
+			local response = result.stanza;
+			if response.attr.type ~= "result" then
+				module:log("warn", "Failed to query contact addresses of %s: %s", host, response);
+				return;
+			end
+
+			for form in response.tags[1]:childtags("x", "jabber:x:data") do
+				local form_type = form:get_child_with_attr("field", nil, "var", "FORM_TYPE");
+				if form_type and form_type:get_child_text("value") == "http://jabber.org/network/serverinfo" then
+					address = get_address(form, "spam-report-addresses", "abuse-addresses");
+					break;
+				end
+			end
+			return address;
+		end);
+end
+
+local function send_report(to, message)
+	local m = st.clone(message);
+	m.attr.to = to;
+	module:send(m);
+end
+
+function forward_report(event)
+	local reporter_username = event.origin.username;
+	local reporter_jid = jid.join(reporter_username, module.host);
+	local reported_jid = event.jid;
+
+	local report = st.clone(event.report);
+	report:text_tag("jid", reported_jid, { xmlns = "urn:xmpp:jid:0" });
+
+	local reported_message_el = report:get_child_with_attr(
+		"stanza-id",
+		"urn:xmpp:sid:0",
+		"by",
+		reported_jid,
+		jid.prep
+	);
+
+	local reported_message, reported_message_time, reported_message_with;
+	if reported_message_el then
+		reported_message, reported_message_time, reported_message_with = archive:get(reporter_username, reported_message_el.attr.id);
+		if jid.bare(reported_message_with) ~= event.jid then
+			reported_message = nil;
+		end
+	end
+
+	local body_text = render(body_template, {
+		reporter_jid = reporter_jid;
+		reported_jid = event.jid;
+		reported_message_time = dt.datetime(reported_message_time);
+	});
+
+	local message = st.message({ from = module.host, id = new_id() })
+		:text_tag("body", body_text)
+		:add_child(report);
+
+	if reported_message then
+		reported_message.attr.xmlns = "jabber:client";
+		local fwd = st.stanza("forwarded", { xmlns = "urn:xmpp:forward:0" })
+			:tag("delay", { xmlns = "urn:xmpp:delay", stamp = dt.datetime(reported_message_time) }):up()
+			:add_child(reported_message);
+		message:add_child(fwd);
+	end
+
+	for destination in destinations do
+		send_report(destination, message);
+	end
+
+	if report_to_origin then
+		module:log("debug", "Sending report to origin server...");
+		get_origin_report_address(event.jid):next(function (origin_report_address)
+			if not origin_report_address then
+				module:log("warn", "Couldn't report to origin: no contact address found for %s", jid.host(event.jid));
+				return;
+			end
+			send_report(origin_report_address, message);
+		end):catch(function (e)
+			module:log("error", "Failed to report to origin server: %s", e);
+		end);
+	end
+end
+
+module:hook("spam_reporting/abuse-report", forward_report, -1);
+module:hook("spam_reporting/spam-report", forward_report, -1);
+module:hook("spam_reporting/unknown-report", forward_report, -1);
--- a/mod_rest/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_rest/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -21,21 +21,55 @@
 
 # Usage
 
+You make a choice: install via VirtualHosts or as a Component. User authentication can
+be used when installed via VirtualHost, and OAuth2 can be used for either.
+
 ## On VirtualHosts
 
+This enables rest on the VirtualHost domain, enabling user authentication to secure
+the endpoint. Make sure that the modules_enabled section is immediately below the
+VirtualHost entry so that it's not under any Component sections. EG:
+
 ```lua
-VirtualHost "example.com"
+VirtualHost "chat.example.com"
 modules_enabled = {"rest"}
 ```
 
 ## As a Component
 
+If you install this as a component, you won't be able to use user authentication above,
+and must use OAuth2 authentication outlined below.
+
 ``` {.lua}
-Component "rest.example.net" "rest"
+Component "chat.example.com" "rest"
 component_secret = "dmVyeSBzZWNyZXQgdG9rZW4K"
 modules_enabled = {"http_oauth2"}
 ```
 
+## User authentication
+
+To enable user authentication, edit the "admins = { }" section in prosody.cfg.lua, EG:
+
+```lua
+admins = { "admin@chat.example.com" }
+```
+
+To set up the admin user account:
+
+```lua
+prosodyctl adduser admin@chat.example.com
+```
+
+and lastly, drop the "@host" from the username in your http queries, EG:
+
+```lua
+curl \
+  https://chat.example.com:5281/rest/version/chat.example.com \
+  -k \
+  --user admin \
+  -H 'Accept: application/json'
+```
+
 ## OAuth2
 
 [mod_http_oauth2] can be used to grant bearer tokens which are accepted
@@ -45,7 +79,7 @@
 ## Sending stanzas
 
 The API endpoint becomes available at the path `/rest`, so the full URL
-will be something like `https://your-prosody.example:5281/rest`.
+will be something like `https://conference.chat.example.com:5281/rest`.
 
 To try it, simply `curl` an XML stanza payload:
 
--- a/mod_rest/res/schema-xmpp.json	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_rest/res/schema-xmpp.json	Sun Feb 16 16:09:03 2025 +0700
@@ -109,6 +109,9 @@
       },
       "delay" : {
          "description" : "Timestamp of when a stanza was delayed, in ISO 8601 / XEP-0082 format.",
+         "examples" : [
+            "2002-09-10T23:08:25Z"
+         ],
          "format" : "date-time",
          "title" : "XEP-0203: Delayed Delivery",
          "type" : "string",
@@ -120,7 +123,9 @@
       },
       "from" : {
          "description" : "the sender of the stanza",
-         "example" : "bob@example.net",
+         "examples" : [
+            "bob@example.net"
+         ],
          "format" : "xmpp-jid",
          "type" : "string",
          "xml" : {
@@ -129,6 +134,10 @@
       },
       "id" : {
          "description" : "Reasonably unique id. mod_rest generates one if left out.",
+         "examples" : [
+            "c6d02db2-5c1f-4e00-9014-4dd3e21309b0",
+            "EBRthZvxaAEXoTJ77w692pQW"
+         ],
          "type" : "string",
          "xml" : {
             "attribute" : true
@@ -136,7 +145,14 @@
       },
       "lang" : {
          "description" : "Language code",
-         "example" : "en",
+         "examples" : [
+            "de",
+            "en",
+            "en-UK",
+            "en-US",
+            "fr",
+            "sv-SE"
+         ],
          "type" : "string",
          "xml" : {
             "attribute" : true,
@@ -144,6 +160,9 @@
          }
       },
       "nick" : {
+         "examples" : [
+            "CallMeIshmael"
+         ],
          "type" : "string",
          "xml" : {
             "name" : "nick",
@@ -206,6 +225,9 @@
       "to" : {
          "description" : "the intended recipient for the stanza",
          "example" : "alice@example.com",
+         "examples" : [
+            "alice@example.com"
+         ],
          "format" : "xmpp-jid",
          "type" : "string",
          "xml" : {
@@ -342,7 +364,7 @@
             "gateway" : {
                "properties" : {
                   "desc" : {
-                     "type" : "text"
+                     "type" : "string"
                   },
                   "jid" : {
                      "type" : "string"
@@ -662,14 +684,23 @@
                "properties" : {
                   "name" : {
                      "example" : "My Software",
+                     "examples" : [
+                        "My Software"
+                     ],
                      "type" : "string"
                   },
                   "os" : {
                      "example" : "Linux",
+                     "examples" : [
+                        "Linux"
+                     ],
                      "type" : "string"
                   },
                   "version" : {
                      "example" : "1.0.0",
+                     "examples" : [
+                        "1.0.0"
+                     ],
                      "type" : "string"
                   }
                },
@@ -730,6 +761,9 @@
             "body" : {
                "description" : "Human-readable chat message",
                "example" : "Hello, World!",
+               "examples" : [
+                  "Hello, World!"
+               ],
                "type" : "string"
             },
             "dataform" : {
@@ -864,6 +898,9 @@
                   "url" : {
                      "description" : "The URL of the attached media file",
                      "example" : "https://media.example.net/thisfile.jpg",
+                     "examples" : [
+                        "https://media.example.net/thisfile.jpg"
+                     ],
                      "format" : "uri",
                      "type" : "string"
                   }
@@ -1031,6 +1068,10 @@
             "subject" : {
                "description" : "Subject of message or group chat",
                "example" : "Talking about stuff",
+               "examples" : [
+                  "I implore you!",
+                  "Talking about stuff"
+               ],
                "type" : "string"
             },
             "thread" : {
--- a/mod_sasl2/mod_sasl2.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_sasl2/mod_sasl2.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -65,6 +65,8 @@
 					log("debug", "Channel binding 'tls-exporter' supported");
 					sasl_handler:add_cb_handler("tls-exporter", sasl_tls_exporter);
 					channel_bindings:add("tls-exporter");
+				else
+					log("debug", "Channel binding 'tls-exporter' not supported");
 				end
 			elseif origin.conn.ssl_peerfinished and origin.conn:ssl_peerfinished() then
 				log("debug", "Channel binding 'tls-unique' supported");
--- a/mod_sasl2_fast/README.md	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_sasl2_fast/README.md	Sun Feb 16 16:09:03 2025 +0700
@@ -7,12 +7,8 @@
   - mod_sasl2
 ---
 
-This module implements a mechanism via which clients can exchange a password
-for a secure token, improving security and streamlining future reconnections.
-
-At the time of writing, the XEP that describes the FAST protocol is still
-working its way through the XSF standards process. You can [view the FAST XEP
-proposal here](https://xmpp.org/extensions/inbox/xep-fast.html).
+This module implements a mechanism described in [XEP-0484: Fast Authentication Streamlining Tokens] via which clients can exchange a
+password for a secure token, improving security and streamlining future reconnections.
 
 This module depends on [mod_sasl2].
 
--- a/mod_sasl2_fast/mod_sasl2_fast.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_sasl2_fast/mod_sasl2_fast.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -196,6 +196,13 @@
 		if not authc_username then
 			return "failure", "malformed-request";
 		end
+		if not sasl_handler.profile.cb then
+			module:log("warn", "Attempt to use channel binding %s with SASL profile that does not support any channel binding (FAST: %s)", cb_name, sasl_handler.fast);
+			return "failure", "malformed-request";
+		elseif not sasl_handler.profile.cb[cb_name] then
+			module:log("warn", "SASL profile does not support %s channel binding (FAST: %s)", cb_name, sasl_handler.fast);
+			return "failure", "malformed-request";
+		end
 		local cb_data = cb_name and sasl_handler.profile.cb[cb_name](sasl_handler) or "";
 		local ok, authz_username, response, rotation_needed = backend(
 			mechanism_name,
--- a/mod_sasl_ssdp/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_sasl_ssdp/README.markdown	Sun Feb 16 16:09:03 2025 +0700
@@ -14,8 +14,8 @@
 
 **Note:** This module implements version 0.3.0 of XEP-0474. As of 2023-12-05,
 this version is not yet published on xmpp.org. Version 0.3.0 of the XEP is
-implemented in Monal 6.0.1. No other clients are currently known to implement
-the XEP at the time of writing.
+implemented in Monal 6.0.1 and go-sendxmpp 0.8.0. No other clients are currently
+known to implement the XEP at the time of writing.
 
 # Configuration
 
--- a/mod_server_info/README.md	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_server_info/README.md	Sun Feb 16 16:09:03 2025 +0700
@@ -14,37 +14,52 @@
 
 Everything configured here is publicly visible to other XMPP entities.
 
+**Note:** This module was rewritten in February 2024, the configuration is not
+compatible with the previous version of the module.
+
 ## Configuration
 
-The `server_info` option accepts a list of dataforms. A dataform is an array
-of fields. A field has three required properties:
+The `server_info_extensions` option accepts a list of custom fields to include
+in the server info form.
+
+A field has three required properties:
 
 - `type` - usually `text-single` or `list-multi`
-- `var` - the field name
+- `var` - the field name (see below)
 - `value` the field value
 
 Example configuration:
 
 ``` lua
 server_info = {
-
-	-- Our custom form
-	{
-		-- Conventionally XMPP dataforms have a 'FORM_TYPE' field to
-		-- indicate what type of form it is
-		{ type = "hidden", var = "FORM_TYPE", value = "urn:example:foo" };
+	-- Advertise that our maximum speed is 88 mph
+	{ type = "text-single", var = "speed", value = "88" };
 
-		-- Advertise that our maximum speed is 88 mph
-		{ type = "text-single", var = "speed", value = "88" };
-
-		-- Advertise that the time is 1:20 AM and zero seconds
-		{ type = "text-single", var = "time", value = "01:21:00" };
-	};
-
+	-- Advertise that the time is 1:20 AM and zero seconds
+	{ type = "text-single", var = "time", value = "01:21:00" };
 }
 ```
 
+The `var` attribute is used to uniquely identify fields. Every `var` should be
+registered with the XSF [form registry](https://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo),
+or prefixed with a custom namespace using Clark notation, e.g. `{https://example.com}my-field-name`. This is to prevent
+collisions.
+
+## Developers
+
+Developers of other modules can add fields to the form at runtime:
+
+```lua
+module:depends("server_info");
+
+module:add_item("server-info-fields", {
+	{ type = "text-single", var = "speed", value = "88" };
+	{ type = "text-single", var = "time", value = "01:21:00" };
+});
+```
+
+Prosody will ensure they are removed if your module is unloaded.
+
 ## Compatibility
 
-This module should be compatible with Prosody 0.12, and possibly earlier
-versions.
+This module should be compatible with Prosody 0.12 and later.
--- a/mod_server_info/mod_server_info.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_server_info/mod_server_info.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -1,18 +1,60 @@
--- XEP-0128: Service Discovery Extensions (manual config)
---
--- Copyright (C) 2023 Matthew Wild
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
+-- mod_server_info imported from Prosody commit 1ce18cb3e6cc for the benefit
+-- of 0.12 deployments. This community version of the module will not load in
+-- newer Prosody versions, which include their own copy of the module.
+--% conflicts: mod_server_info
+
+local dataforms = require "prosody.util.dataforms";
+
+local server_info_config = module:get_option("server_info", {});
+local server_info_custom_fields = module:get_option_array("server_info_extensions");
 
-local dataforms = require "util.dataforms";
-
-local config = module:get_option("server_info");
+-- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo
+local form_layout = dataforms.new({
+	{ var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo" };
+});
 
-if not config or next(config) == nil then return; end -- Nothing to do
-
-for _, form in ipairs(config) do
-	module:add_extension(dataforms.new(form):form({}, "result"));
+if server_info_custom_fields then
+	for _, field in ipairs(server_info_custom_fields) do
+		table.insert(form_layout, field);
+	end
 end
 
+local generated_form;
+
+function update_form()
+	local new_form = form_layout:form(server_info_config, "result");
+	if generated_form then
+		module:remove_item("extension", generated_form);
+	end
+	generated_form = new_form;
+	module:add_item("extension", generated_form);
+end
+
+function add_fields(event)
+	local fields = event.item;
+	for _, field in ipairs(fields) do
+		table.insert(form_layout, field);
+	end
+	update_form();
+end
+
+function remove_fields(event)
+	local removed_fields = event.item;
+	for _, removed_field in ipairs(removed_fields) do
+		local removed_var = removed_field.var or removed_field.name;
+		for i, field in ipairs(form_layout) do
+			local var = field.var or field.name
+			if var == removed_var then
+				table.remove(form_layout, i);
+				break;
+			end
+		end
+	end
+	update_form();
+end
+
+module:handle_items("server-info-fields", add_fields, remove_fields);
+
+function module.load()
+	update_form();
+end
--- a/mod_spam_report_forwarder/README.markdown	Tue Feb 06 18:32:01 2024 +0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,48 +0,0 @@
----
-labels:
-- 'Stage-Beta'
-summary: 'Forward spam/abuse reports to a JID'
----
-
-This module forwards spam/abuse reports (e.g. those submitted by users via
-XEP-0377 via mod_spam_reporting) to one or more JIDs.
-
-## Configuration
-
-Install and enable the module the same as any other.
-
-There is a single option, `spam_report_destinations` which accepts a list of
-JIDs to send reports to.
-
-For example:
-
-```lua
-modules_enabled = {
-    ---
-    "spam_reporting";
-    "spam_report_forwarder";
-    ---
-}
-
-spam_report_destinations = { "antispam.example.com" }
-```
-
-## Protocol
-
-This section is intended for developers.
-
-XEP-0377 assumes the report is embedded within another protocol such as
-XEP-0191, and doesn't specify a format for communicating "standalone" reports.
-This module transmits them inside a `<message>` stanza, and adds a `<jid/>`
-element (borrowed from XEP-0268):
-
-```xml
-<message from="prosody.example" to="destination.example">
-    <report xmlns="urn:xmpp:reporting:1" reason="urn:xmpp:reporting:spam">
-        <jid xmlns="urn:xmpp:jid:0">spammer@bad.example</jid>
-        <text>
-          Never came trouble to my house like this.
-        </text>
-    </report>
-</message>
-```
--- a/mod_spam_report_forwarder/mod_spam_report_forwarder.lua	Tue Feb 06 18:32:01 2024 +0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,21 +0,0 @@
-local st = require "util.stanza";
-
-local destinations = module:get_option_set("spam_report_destinations", {});
-
-function forward_report(event)
-	local report = st.clone(event.report);
-	report:text_tag("jid", event.jid, { xmlns = "urn:xmpp:jid:0" });
-
-	local message = st.message({ from = module.host })
-		:add_child(report);
-
-	for destination in destinations do
-		local m = st.clone(message);
-		m.attr.to = destination;
-		module:send(m);
-	end
-end
-
-module:hook("spam_reporting/abuse-report", forward_report, -1);
-module:hook("spam_reporting/spam-report", forward_report, -1);
-module:hook("spam_reporting/unknown-report", forward_report, -1);
--- a/mod_traceback/mod_traceback.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_traceback/mod_traceback.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -2,9 +2,10 @@
 
 local traceback = require "util.debug".traceback;
 
-require"util.signal".signal(module:get_option_string(module.name, "SIGUSR1"), function ()
-	module:log("info", "Received SIGUSR1, writing traceback");
-	local f = io.open(prosody.paths.data.."/traceback.txt", "a+");
+local signal = module:get_option_string(module.name, "SIGUSR1");
+module:hook("signal/" .. signal, function()
+	module:log("info", "Received %s, writing traceback", signal);
+	local f = io.open(prosody.paths.data .. "/traceback.txt", "a+");
 	f:write(traceback(), "\n");
 	f:close();
 end);
--- a/mod_vcard_muc/mod_vcard_muc.lua	Tue Feb 06 18:32:01 2024 +0700
+++ b/mod_vcard_muc/mod_vcard_muc.lua	Sun Feb 16 16:09:03 2025 +0700
@@ -103,10 +103,10 @@
 	event.reply:tag("feature", { var = "vcard-temp" }):up();
 
 	table.insert(event.form, {
-			name = "{http://modules.prosody.im/mod_vcard_muc}avatar#sha1",
-			type = "text-single",
+			name = "muc#roominfo_avatarhash",
+			type = "text-multi",
 		});
-	event.formdata["{http://modules.prosody.im/mod_vcard_muc}avatar#sha1"] = get_photo_hash(event.room);
+	event.formdata["muc#roominfo_avatarhash"] = get_photo_hash(event.room);
 end);
 
 module:hook("muc-occupant-session-new", function(event)