Changeset

6208:e20901443eae draft

Merge
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Mon, 17 Mar 2025 23:42:11 +0700
parents 5945:805515dd2960
children 6209:d611ed13df7e
files .luacheckrc misc/lnav/prosody.json mod_anti_spam/mod_anti_spam.lua mod_anti_spam/trie.lib.lua mod_audit/README.md mod_auth_oauth_external/README.md mod_auth_oauth_external/mod_auth_oauth_external.lua mod_client_certs/mod_client_certs.lua mod_cloud_notify/mod_cloud_notify.lua mod_cloud_notify_encrypted/README.md mod_cloud_notify_encrypted/mod_cloud_notify_encrypted.lua mod_cloud_notify_filters/README.md mod_cloud_notify_priority_tag/README.md mod_conversejs/mod_conversejs.lua mod_csi_muc_priorities/mod_csi_muc_priorities.lua mod_firewall/conditions.lib.lua mod_groups_internal/mod_groups_internal.lua mod_http_index/mod_http_index.lua mod_http_oauth2/html/consent.html mod_http_oauth2/mod_http_oauth2.lua mod_invites/mod_invites.lua mod_lastlog2/mod_lastlog2.lua mod_log_json/mod_log_json.lua mod_measure_message_e2ee/mod_measure_message_e2ee.lua
diffstat 24 files changed, 285 insertions(+), 108 deletions(-) [+]
line wrap: on
line diff
--- a/.luacheckrc	Wed Feb 26 19:36:35 2025 +0700
+++ b/.luacheckrc	Mon Mar 17 23:42:11 2025 +0700
@@ -29,6 +29,7 @@
 	"module.hourly",
 	"module.broadcast",
 	"module.context",
+	"module.could",
 	"module.default_permission",
 	"module.default_permissions",
 	"module.depends",
@@ -83,6 +84,7 @@
 }
 globals = {
 	-- Methods that can be set on module API
+	"module.ready",
 	"module.unload",
 	"module.add_host",
 	"module.load",
--- a/misc/lnav/prosody.json	Wed Feb 26 19:36:35 2025 +0700
+++ b/misc/lnav/prosody.json	Mon Mar 17 23:42:11 2025 +0700
@@ -33,7 +33,7 @@
             "identifier" : true,
             "kind" : "string"
          },
-         "payload" : {
+         "message" : {
             "kind" : "xml"
          }
       }
--- a/mod_anti_spam/mod_anti_spam.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_anti_spam/mod_anti_spam.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -4,7 +4,7 @@
 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 rm = require "core.rostermanager";
 local full_sessions = prosody.full_sessions;
 
 local user_exists = require "core.usermanager".user_exists;
@@ -15,10 +15,30 @@
 local spam_source_domains = set.new();
 local spam_source_ips = trie.new();
 local spam_source_jids = set.new();
+local default_spam_action = module:get_option("anti_spam_default_action", "bounce");
+local custom_spam_actions = module:get_option("anti_spam_actions", {});
+
+local spam_actions = setmetatable({}, {
+	__index = function (t, reason)
+		local action = rawget(custom_spam_actions, reason) or default_spam_action;
+		rawset(t, reason, action);
+		return action;
+	end;
+});
 
 local count_spam_blocked = module:metric("counter", "anti_spam_blocked", "stanzas", "Stanzas blocked as spam", {"reason"});
 
+local hosts = prosody.hosts;
+
+local reason_messages = {
+	default = "Rejected as spam";
+	["known-spam-source"] = "Rejected as spam. Your server is listed as a known source of spam. Please contact your server operator.";
+};
+
 function block_spam(event, reason, action)
+	if not action then
+		action = spam_actions[reason];
+	end
 	event.spam_reason = reason;
 	event.spam_action = action;
 	if module:fire_event("spam-blocked", event) == false then
@@ -30,7 +50,7 @@
 
 	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"));
+		event.origin.send(st.error_reply(event.stanza, "cancel", "policy-violation", reason_messages[reason] or reason_messages.default));
 	else
 		module:log("debug", "Discarding likely spam %s from %s (%s)", event.stanza.name, event.stanza.attr.from, reason);
 	end
@@ -47,13 +67,25 @@
 	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
+	if not (
+		rm.is_contact_subscribed(to_user, to_host, from_jid) or
+		rm.is_user_subscribed(to_user, to_host, from_jid) or
+		rm.is_contact_pending_out(to_user, to_host, from_jid) or
+		rm.is_contact_preapproved(to_user, to_host, from_jid)
+	) then
+		local from_user, from_host = jid_split(from_jid);
+
 		-- Allow all messages from your own jid
-		if from_jid == to_user.."@"..to_host then
+		if from_user == to_user and from_host == to_host then
 			return false; -- Pass through
 		end
 		if to_resource and stanza.attr.type == "groupchat" then
-			return false; -- Pass through
+			return false; -- Pass through group chat messages
+		end
+		if rm.is_contact_subscribed(to_user, to_host, from_host) then
+			-- If you have the sending domain in your roster,
+			-- allow through (probably a gateway)
+			return false;
 		end
 		return true; -- Stranger danger
 	end
@@ -63,8 +95,11 @@
 	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
+	local raw_ip = session.ip;
+	local parsed_ip = raw_ip and ip.new_ip(session.ip);
+	-- Not every session has an ip - for example, stanzas sent from a
+	-- local host session
+	if parsed_ip and spam_source_ips:contains_ip(parsed_ip) then
 		return true;
 	end
 end
@@ -82,6 +117,8 @@
 	if not (spammy_strings or spammy_patterns) then return; end
 
 	local body = stanza:get_child_text("body");
+	if not body then return; end
+
 	if spammy_strings then
 		for _, s in ipairs(spammy_strings) do
 			if body:find(s, 1, true) then
@@ -100,7 +137,7 @@
 
 -- Set up RTBLs
 
-local anti_spam_services = module:get_option_array("anti_spam_services");
+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", {
@@ -113,10 +150,18 @@
 	});
 	new_rtbl_subscription(rtbl_service_jid, "spam_source_ips", {
 		added = function (item)
-			spam_source_ips:add_subnet(ip.parse_cidr(item));
+			local subnet_ip, subnet_bits = ip.parse_cidr(item);
+			if not subnet_ip then
+				return;
+			end
+			spam_source_ips:add_subnet(subnet_ip, subnet_bits);
 		end;
 		removed = function (item)
-			spam_source_ips:remove_subnet(ip.parse_cidr(item));
+			local subnet_ip, subnet_bits = ip.parse_cidr(item);
+			if not subnet_ip then
+				return;
+			end
+			spam_source_ips:remove_subnet(subnet_ip, subnet_bits);
 		end;
 	});
 	new_rtbl_subscription(rtbl_service_jid, "spam_source_jids_sha256", {
@@ -130,36 +175,65 @@
 end
 
 module:hook("message/bare", function (event)
-	local to_bare = jid_bare(event.stanza.attr.to);
+	local to_user, to_host = jid_split(event.stanza.attr.to);
 
-	if not user_exists(to_bare) then return; end
+	if not hosts[to_host] then
+		module:log("warn", "Skipping filtering of message to unknown host <%s>", to_host);
+		return;
+	end
 
 	local from_bare = jid_bare(event.stanza.attr.from);
-	if not is_from_stranger(from_bare, event) then return; end
+	if user_exists(to_user, to_host) then
+		if not is_from_stranger(from_bare, event) then
+			return;
+		end
+	end
+
+	module:log("debug", "Processing message from stranger...");
 
 	if is_spammy_server(event.origin) then
-		return block_spam(event, "known-spam-source", "drop");
+		return block_spam(event, "known-spam-source");
 	end
 
 	if is_spammy_sender(from_bare) then
-		return block_spam(event, "known-spam-jid", "drop");
+		return block_spam(event, "known-spam-jid");
 	end
 
 	if is_spammy_content(event.stanza) then
-		return block_spam(event, "spam-content", "drop");
+		return block_spam(event, "spam-content");
 	end
+
+	module:log("debug", "Allowing message through");
 end, 500);
 
 module:hook("presence/bare", function (event)
-	if event.stanza.type ~= "subscribe" then
+	if event.stanza.attr.type ~= "subscribe" then
 		return;
 	end
 
-	if is_spammy_server(event.origin) then
-		return block_spam(event, "known-spam-source", "drop");
+
+	local to_user, to_host = jid_split(event.stanza.attr.to);
+	local from_bare = jid_bare(event.stanza.attr.from);
+
+	if user_exists(to_user, to_host) then
+		if not is_from_stranger(from_bare, event) then
+			return;
+		end
 	end
 
-	if is_spammy_sender(event.stanza) then
-		return block_spam(event, "known-spam-jid", "drop");
+	module:log("debug", "Processing subscription request from stranger...");
+
+	if is_spammy_server(event.origin) then
+		return block_spam(event, "known-spam-source");
 	end
+
+	module:log("debug", "Not from known spam source server");
+
+	if is_spammy_sender(jid_bare(event.stanza.attr.from)) then
+		return block_spam(event, "known-spam-jid");
+	end
+
+	module:log("debug", "Not from known spam source JID");
+
+	module:log("debug", "Allowing subscription request through");
 end, 500);
--- a/mod_anti_spam/trie.lib.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_anti_spam/trie.lib.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -1,4 +1,4 @@
-local bit = require "prosody.util.bitcompat";
+local bit = require "util.bitcompat";
 
 local trie_methods = {};
 local trie_mt = { __index = trie_methods };
@@ -120,7 +120,7 @@
 	end
 end
 
-function trie_methods:has_ip(item)
+function trie_methods:contains_ip(item)
 	item = item.packed;
 	local node = self.root;
 	local len = #item;
--- a/mod_audit/README.md	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_audit/README.md	Mon Mar 17 23:42:11 2025 +0700
@@ -49,3 +49,9 @@
 ```shell
 prosodyctl mod_audit user@example.com
 ```
+
+# Compatibilty
+
+Requires Prosody **trunk** as of 2025-02-11.
+
+Does not work with Prosody 0.12 or earlier.
--- a/mod_auth_oauth_external/README.md	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_auth_oauth_external/README.md	Mon Mar 17 23:42:11 2025 +0700
@@ -11,12 +11,14 @@
 
 # How it works
 
-Clients retrieve tokens somehow, then show them to Prosody, which asks
-the Authorization server to validate them, returning info about the user
-back to Prosody.
+Using OAuth 2.0 in XMPP is explained in [XEP-0493: OAuth Client Login].
+Clients pass tokens from the Authorization Server to Prosody, which
+attempts to validate the tokens using the configured validation
+endpoint.
 
-Alternatively for legacy clients, Prosody receives the users username
-and password and retrieves a token itself, then proceeds as above.
+Legacy clients have to use SASL PLAIN, where Prosody receives the users
+username and password and attempts to validate this using the OAuth 2
+resource owner password grant.
 
 # Configuration
 
--- a/mod_auth_oauth_external/mod_auth_oauth_external.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_auth_oauth_external/mod_auth_oauth_external.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -58,7 +58,7 @@
 
 function provider.get_sasl_handler()
 	local profile = {};
-	profile.http_client = http.new({ connection_pooling = true }); -- TODO configurable
+	profile.http_client = http.default:new({ connection_pooling = true }); -- TODO configurable
 	local extra = { oidc_discovery_url = oidc_discovery_url };
 	if token_endpoint and allow_plain then
 		local map_username = function (username, _realm) return username; end; --jid.join; -- TODO configurable
--- a/mod_client_certs/mod_client_certs.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_client_certs/mod_client_certs.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -10,7 +10,7 @@
 local dm_load = require "util.datamanager".load;
 local dm_store = require "util.datamanager".store;
 local dm_table = "client_certs";
-local ssl_x509 = require "ssl.x509";
+local ssl = require "ssl";
 local util_x509 = require "util.x509";
 local id_on_xmppAddr = "1.3.6.1.5.5.7.8.5";
 local id_ce_subjectAltName = "2.5.29.17";
@@ -141,7 +141,7 @@
 	local can_manage = append:get_child("no-cert-management", xmlns_saslcert) ~= nil;
 	x509cert = x509cert:gsub("^%s*(.-)%s*$", "%1");
 
-	local cert = ssl_x509.load(util_x509.der2pem(base64.decode(x509cert)));
+	local cert = ssl.loadcertificate(util_x509.der2pem(base64.decode(x509cert)));
 
 	if not cert then
 		origin.send(st.error_reply(stanza, "modify", "not-acceptable", "Could not parse X.509 certificate"));
@@ -206,8 +206,8 @@
 	instructions = "What action do you want to perform?";
 
 	{ name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/certs#subcmd" };
-	{ name = "subcmd", type = "list-single", label = "Actions", required = true,
-		value = { {label = "Add certificate", value = "add"},
+	{ name = "subcmd", type = "list-single", label = "Actions", required = false,
+		options = { {label = "Add certificate", value = "add"},
 			  {label = "List certificates", value = "list"},
 			  {label = "Disable certificate", value = "disable"},
 			  {label = "Revoke certificate", value = "revoke"},
@@ -292,7 +292,7 @@
 		local name = fields.name;
 		local x509cert = fields.cert:gsub("^%s*(.-)%s*$", "%1");
 
-		local cert = ssl_x509.load(util_x509.der2pem(base64.decode(x509cert)));
+		local cert = ssl.loadcertificate(x509cert);
 
 		if not cert then
 			return { status = "completed", error = { message = "Could not parse X.509 certificate" } };
@@ -327,7 +327,7 @@
 	end
 end
 
-local cmd_desc = adhoc_new("Manage certificates", "http://prosody.im/protocol/certs", adhoc_handler, "user");
+local cmd_desc = adhoc_new("Manage certificates", "http://prosody.im/protocol/certs", adhoc_handler, "any");
 module:provides("adhoc", cmd_desc);
 
 -- Here comes the SASL EXTERNAL stuff
--- a/mod_cloud_notify/mod_cloud_notify.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_cloud_notify/mod_cloud_notify.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -4,6 +4,9 @@
 --
 -- This file is MIT/X11 licensed.
 
+-- This module is only for 0.12, later versions have mod_cloud_notify bundled
+--% conflicts: mod_cloud_notify
+
 local os_time = os.time;
 local st = require"util.stanza";
 local jid = require"util.jid";
--- a/mod_cloud_notify_encrypted/README.md	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_cloud_notify_encrypted/README.md	Mon Mar 17 23:42:11 2025 +0700
@@ -2,9 +2,6 @@
 labels:
 - 'Stage-Alpha'
 summary: 'Support for encrypted payloads in push notifications'
-rockspec:
-  dependencies:
-  - mod_cloud_notify
 ...
 
 Introduction
--- a/mod_cloud_notify_encrypted/mod_cloud_notify_encrypted.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_cloud_notify_encrypted/mod_cloud_notify_encrypted.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -144,5 +144,6 @@
 	module:log("debug", "Encrypted '%s' push notification using %s", push_payload.type, encryption.algorithm);
 end
 
+module:depends("cloud_notify");
 module:hook("cloud_notify/registration", handle_register);
 module:hook("cloud_notify/push", handle_push, 1);
--- a/mod_cloud_notify_filters/README.md	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_cloud_notify_filters/README.md	Mon Mar 17 23:42:11 2025 +0700
@@ -2,9 +2,6 @@
 labels:
 - 'Stage-Alpha'
 summary: 'Support for push notification filtering rules'
-rockspec:
-  dependencies:
-  - mod_cloud_notify
 ...
 
 Introduction
--- a/mod_cloud_notify_priority_tag/README.md	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_cloud_notify_priority_tag/README.md	Mon Mar 17 23:42:11 2025 +0700
@@ -2,9 +2,6 @@
 labels:
 - 'Stage-Alpha'
 summary: 'Support for indicating importance to push notification servers'
-rockspec:
-  dependencies:
-  - mod_cloud_notify
 ...
 
 Introduction
--- a/mod_conversejs/mod_conversejs.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_conversejs/mod_conversejs.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -126,7 +126,7 @@
 module:provides("http", {
 	title = "Converse.js";
 	route = {
-		GET = function (event)
+		["GET /"] = function (event)
 			local converse_options = get_converse_options();
 
 			event.response.headers.content_type = "text/html";
@@ -175,7 +175,7 @@
 						sizes = "512x512",
 					},
 				}),
-				start_url = module:http_url(),
+				start_url = module:http_url().."/",
 				background_color = pwa_color,
 				display = "standalone",
 				scope = module:http_url().."/",
--- a/mod_csi_muc_priorities/mod_csi_muc_priorities.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_csi_muc_priorities/mod_csi_muc_priorities.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -12,14 +12,6 @@
 			local username = session.username;
 			local priorities = user_sessions[username].csi_muc_priorities;
 
-			if priorities then
-				local priority = priorities[room_jid];
-				if priority ~= nil then
-					event.reason = "muc priority";
-					return priority;
-				end
-			end
-
 			-- Look for mention
 			local rooms = session.rooms_joined;
 			if rooms then
@@ -33,12 +25,33 @@
 					end
 					-- Your own messages
 					if stanza.attr.from == (room_jid .. "/" .. room_nick) then
-					event.reason = "muc own message";
+						event.reason = "muc own message";
 						return true;
 					end
 				end
 			end
 
+			-- No mentions found, check other logic:
+			--			deflaultlow=f or nil	defaultlow=t
+			--	in high prio	nil			nil
+			--	in low prio	false			false
+			--	not in either	nil			false
+			--
+			--	true	means:	important (always send immediately)
+			--	nil	means:	normal (respect other mods for stuff like grace period/reactions/etc)
+			--	false	means:	unimportant (delay sending)
+			if priorities then
+				local priority = priorities[room_jid];
+				if priority == false then  -- low priority
+					event.reason = "muc priority";
+					return false;
+				end
+				if priorities[false] and priorities[false]["defaultlow"] and not priority then -- defaultlow is false or nil or not high priority
+					event.reason = "muc user default low";
+					return false;
+				end
+			end
+
 			-- Standard importance and no mention, leave to other modules to decide for now
 			return nil;
 		end
@@ -74,6 +87,12 @@
 		label = "Lower priority";
 		desc = "E.g. large noisy public channels";
 	};
+	{
+		type = "boolean";
+		name = "defaultlow";
+		label = "Default to lower priority";
+		desc = "Mark all channels lower priority as default";
+	};
 }
 
 local store = module:open_store();
@@ -87,20 +106,29 @@
 	local prioritized_jids = user_sessions[username].csi_muc_priorities or store:get(username);
 	local important = {};
 	local unimportant = {};
+	local defaultlow = false; -- Default to high priority
 	if prioritized_jids then
 		for jid, priority in pairs(prioritized_jids) do
-			if priority then
-				table.insert(important, jid);
-			else
-				table.insert(unimportant, jid);
+			if jid then
+				if priority then
+					table.insert(important, jid);
+				else
+					table.insert(unimportant, jid);
+				end
 			end
 		end
 		table.sort(important);
 		table.sort(unimportant);
+
+		if prioritized_jids[false] then
+			defaultlow = prioritized_jids[false]["defaultlow"];
+		end
 	end
+
 	return {
 		important = important;
 		unimportant = unimportant;
+		defaultlow = defaultlow
 	};
 end, function(fields, form_err, data)
 	if form_err then
@@ -108,17 +136,18 @@
 	end
 	local prioritized_jids = {};
 	if fields.unimportant then
-		if fields.unimportant then
-			for _, jid in ipairs(fields.unimportant) do
-				prioritized_jids[jid] = false;
-			end
-		end
-		if fields.important then
-			for _, jid in ipairs(fields.important) do
-				prioritized_jids[jid] = true;
-			end
+		for _, jid in ipairs(fields.unimportant) do
+			prioritized_jids[jid] = false;
 		end
 	end
+	if fields.important then
+		for _, jid in ipairs(fields.important) do
+			prioritized_jids[jid] = true;
+		end
+	end
+
+	local misc_data = {defaultlow = fields.defaultlow};
+	prioritized_jids[false] = misc_data;
 
 	local username = jid_split(data.from);
 	local ok, err = store:set(username, prioritized_jids);
--- a/mod_firewall/conditions.lib.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_firewall/conditions.lib.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -123,7 +123,7 @@
 end
 
 function condition_handlers.SUBSCRIBED()
-	return "(bare_to == bare_from or to_node and rostermanager.is_contact_subscribed(to_node, to_host, bare_from))",
+	return "(bare_to == bare_from or to_node and rostermanager.is_user_subscribed(to_node, to_host, bare_from))",
 	       { "rostermanager", "split_to", "bare_to", "bare_from" };
 end
 
--- a/mod_groups_internal/mod_groups_internal.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_groups_internal/mod_groups_internal.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -11,7 +11,7 @@
 local group_members_store = module:open_store("groups");
 local group_memberships = module:open_store("groups", "map");
 
-local muc_host_name = module:get_option("groups_muc_host", "groups."..host);
+local muc_host_name = module:get_option("groups_muc_host");
 local muc_host = nil;
 
 local is_contact_subscribed = rostermanager.is_contact_subscribed;
@@ -31,6 +31,7 @@
 	if group_name then
 		local user_roster = rostermanager.load_roster(user, host);
 		user_roster[contact_jid].groups[group_name] = true;
+		rostermanager.save_roster(user, host, user_roster, contact_jid);
 	end
 
 	-- Push updates to both rosters
@@ -207,10 +208,18 @@
 function delete(group_id)
 	if group_members_store:set(group_id, nil) then
 		local group_info = get_info(group_id);
-		if group_info and group_info.muc_jid then
-			local room = muc_host.get_room_from_jid(group_info.muc_jid)
-			if room then
-				room:destroy()
+		if group_info then
+			if group_info.muc_jid then
+				local room = muc_host.get_room_from_jid(group_info.muc_jid)
+				if room then
+					room:destroy()
+				end
+			end
+			for _, muc_jid in ipairs(group_info.mucs) do
+				local room = muc_host.get_room_from_jid(muc_jid)
+				if room then
+					room:destroy()
+				end
 			end
 		end
 		return group_info_store:set(group_id, nil);
--- a/mod_http_index/mod_http_index.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_http_index/mod_http_index.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -7,8 +7,8 @@
 
 local base_template;
 do
-	local template_file = module:get_option_string(module.name .. "_template", module.name .. ".html");
-	template_file = assert(module:load_resource(template_file));
+	local template_file = module:get_option_path(module.name .. "_template", "html/" .. module.name .. ".html");
+	template_file = assert(io.open(template_file));
 	base_template = template_file:read("*a");
 	template_file:close();
 end
--- a/mod_http_oauth2/html/consent.html	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_http_oauth2/html/consent.html	Mon Mar 17 23:42:11 2025 +0700
@@ -45,7 +45,7 @@
 	select 'Allow'. Otherwise, select 'Deny'.
 	</p>
 
-	<input type="hidden" name="user_token" value="{state.user.token}">
+	<input type="hidden" name="user_token" value="{state.user.token}" />
 	<button type="submit" name="consent" value="denied">Deny</button>
 	<button type="submit" name="consent" value="granted">Allow</button>
 	</form>
--- a/mod_http_oauth2/mod_http_oauth2.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -28,6 +28,27 @@
 	end
 end
 
+local function strict_url_parse(urlstr)
+	local url_parts = url.parse(urlstr);
+	if not url_parts then return url_parts; end
+	if url_parts.userinfo then return false; end
+	if url_parts.port then
+		local port = tonumber(url_parts.port);
+		if not port then return false; end
+		if port <= 0 or port > 0xffff then return false; end
+		if port ~= math.floor(port) then return false; end
+	end
+	if url_parts.host then
+		if encodings.stringprep.nameprep(url_parts.host) ~= url_parts.host then
+			return false;
+		end
+		if not encodings.idna.to_ascii(url_parts.host) then
+			return false;
+		end
+	end
+	return url_parts;
+end
+
 local function strict_formdecode(query)
 	if not query then
 		return nil;
@@ -697,8 +718,13 @@
 	if not request.headers.authorization then return; end
 
 	local auth_type, auth_data = string.match(request.headers.authorization, "^(%S+)%s(.+)$");
+	if not auth_type then return nil; end
 
-	if auth_type == "Basic" then
+	-- As described in Section 2.3 of [RFC5234], the string Bearer is case-insensitive.
+	-- https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-5.1.1
+	auth_type = auth_type:lower();
+
+	if auth_type == "basic" then
 		local creds = base64.decode(auth_data);
 		if not creds then return; end
 		local username, password = string.match(creds, "^([^:]+):(.*)$");
@@ -708,7 +734,7 @@
 			username = username;
 			password = password;
 		};
-	elseif auth_type == "Bearer" then
+	elseif auth_type == "bearer" then
 		return {
 			type = "bearer";
 			bearer_token = auth_data;
@@ -1356,7 +1382,7 @@
 end
 
 local function redirect_uri_allowed(redirect_uri, client_uri, app_type)
-	local uri = url.parse(redirect_uri);
+	local uri = strict_url_parse(redirect_uri);
 	if not uri then
 		return false;
 	end
@@ -1391,8 +1417,8 @@
 		});
 	end
 
-	local client_uri = url.parse(client_metadata.client_uri);
-	if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then
+	local client_uri = strict_url_parse(client_metadata.client_uri);
+	if not client_uri or client_uri.scheme ~= "https" or not client_uri.host or loopbacks:contains(client_uri.host) then
 		return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri");
 	end
 
@@ -1558,6 +1584,7 @@
 		-- This is the normal 'authorization_code' flow.
 
 		-- Step 1. Create OAuth client
+		["GET /register"] = { headers = { content_type = "application/schema+json" }; body = json.encode(registration_schema) };
 		["POST /register"] = handle_register_request;
 
 		-- Device flow
@@ -1569,24 +1596,6 @@
 		["POST /authorize"] = handle_authorization_request;
 		["OPTIONS /authorize"] = { status_code = 403; body = "" };
 
-		-- Step 3. User is redirected to the 'redirect_uri' along with an
-		-- authorization code.  In the insecure 'implicit' flow, the access token
-		-- is delivered here.
-
-		-- Step 4. Retrieve access token using the code.
-		["POST /token"] = handle_token_grant;
-
-		-- Step 4 is later repeated using the refresh token to get new access tokens.
-
-		-- Step 5. Revoke token (access or refresh)
-		["POST /revoke"] = handle_revocation_request;
-
-		-- Get info about a token
-		["POST /introspect"] = handle_introspection_request;
-
-		-- OpenID
-		["GET /userinfo"] = handle_userinfo_request;
-
 		-- Optional static content for templates
 		["GET /style.css"] = templates.css and {
 			headers = {
@@ -1601,11 +1610,26 @@
 			body = templates.js;
 		} or nil;
 
-		-- Some convenient fallback handlers
-		["GET /register"] = { headers = { content_type = "application/schema+json" }; body = json.encode(registration_schema) };
+		-- Step 3. User is redirected to the 'redirect_uri' along with an
+		-- authorization code.  In the insecure 'implicit' flow, the access token
+		-- is delivered here.
+
+		-- Step 4. Retrieve access token using the code.
+		["POST /token"] = handle_token_grant;
 		["GET /token"] = function() return 405; end;
+
+		-- Step 4 is later repeated using the refresh token to get new access tokens.
+
+		-- Get info about a token
+		["POST /introspect"] = handle_introspection_request;
+		["GET /introspect"] = function() return 405; end;
+
+		-- Get info about the user, used for OpenID Connect
+		["GET /userinfo"] = handle_userinfo_request;
+
+		-- Step 5. Revoke token (access or refresh)
+		["POST /revoke"] = handle_revocation_request;
 		["GET /revoke"] = function() return 405; end;
-		["GET /introspect"] = function() return 405; end;
 	};
 });
 
@@ -1652,7 +1676,7 @@
 		ui_locales_supported = allowed_locales[1] and allowed_locales;
 
 		-- OpenID
-		userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil;
+		userinfo_endpoint = handle_userinfo_request and module:http_url() .. "/userinfo" or nil;
 		jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata
 		id_token_signing_alg_values_supported = { "HS256" }; -- The algorithm RS256 MUST be included, but we use HS256 and client_secret as shared key.
 	}
--- a/mod_invites/mod_invites.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_invites/mod_invites.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -191,7 +191,7 @@
 		type = token_info and token_info.type or "roster";
 		uri = token_info and token_info.uri or get_uri("roster", username.."@"..module.host, token);
 		additional_data = token_info and token_info.additional_data or nil;
-		reusable = token_info.reusable;
+		reusable = token_info and token_info.reusable or false;
 	}, valid_invite_mt);
 end
 
--- a/mod_lastlog2/mod_lastlog2.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_lastlog2/mod_lastlog2.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -47,7 +47,7 @@
 	end);
 end
 
-do
+if module.host ~= "*" then
 	local user_sessions = prosody.hosts[module.host].sessions;
 	local kv_store = module:open_store();
 	function get_last_active(username) --luacheck: ignore 131/get_last_active
@@ -67,6 +67,31 @@
 	end
 end
 
+module:add_item("shell-command", {
+	section = "lastlog";
+	section_desc = "View and manage user activity data";
+	name = "show";
+	desc = "View recorded user activity for user";
+	args = { { name = "jid"; type = "string" } };
+	host_selector = "jid";
+	handler = function(self, userjid)
+		local kv_store = module:open_store();
+		local username = jid.prepped_split(userjid);
+		local lastlog, err = kv_store:get(username);
+		if err then return false, err; end
+		if not lastlog then return true, "No record found"; end
+		local print = self.session.print;
+		for event, data in pairs(lastlog) do
+			print(("Last %s: %s"):format(event,
+				data.timestamp and os.date("%Y-%m-%d %H:%M:%S", data.timestamp) or "<unknown>"));
+			if data.ip then
+				print("IP address: "..data.ip);
+			end
+		end
+		return true, "Record shown"
+	end;
+});
+
 function module.command(arg)
 	if not arg[1] or arg[1] == "--help" then
 		require"util.prosodyctl".show_usage([[mod_lastlog2 <user@host>]], [[Show when user last logged in or out]]);
--- a/mod_log_json/mod_log_json.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_log_json/mod_log_json.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -9,7 +9,12 @@
 local function sink_maker(config)
 	local send = function () end
 	if config.filename then
-		local logfile = io.open(config.filename, "a+");
+		local logfile;
+		if config.filename == "/dev/stdout" then
+			logfile = io.stdout;
+		else
+			logfile = io.open(config.filename, "a+");
+		end
 		logfile:setvbuf("no");
 		function send(payload)
 			logfile:write(payload, "\n");
--- a/mod_measure_message_e2ee/mod_measure_message_e2ee.lua	Wed Feb 26 19:36:35 2025 +0700
+++ b/mod_measure_message_e2ee/mod_measure_message_e2ee.lua	Mon Mar 17 23:42:11 2025 +0700
@@ -3,6 +3,7 @@
 local count_openpgp = module:measure("openpgp", "rate");
 local count_otr = module:measure("otr", "rate");
 local count_ox = module:measure("ox", "rate");
+local count_omemo2 = module:measure("omemo2", "rate");
 local count_omemo = module:measure("omemo", "rate");
 local count_encrypted = module:measure("encrypted", "rate");
 
@@ -22,6 +23,11 @@
 		return;
 	end
 
+	if stanza:get_child("encrypted", "urn:xmpp:omemo:2") then
+		count_omemo2();
+		return;
+	end
+
 	if stanza:get_child("encrypted", "eu.siacs.conversations.axolotl") then
 		count_omemo();
 		return;