File

mod_muc_moderation/mod_muc_moderation.lua @ 6199:fe8222112cf4

mod_conversejs: Serve base app at / This makes things slightly less awkward for the browser to figure out which URLs belong to a PWA. The app's "start URL" was previously without the '/' and therefore was not considered within the scope of the PWA. Now the canonical app URL will always have a '/'. Prosody/mod_http should take care of redirecting existing links without the trailing / to the new URL. If you have an installation at https://prosody/conversejs then it is now at https://prosody/conversejs/ (the first URL will now redirect to the second URL if you use it). The alternative would be to make the PWA scope include the parent, i.e. the whole of https://prosody/ in this case. This might get messy if other PWAs are provided by the same site or Prosody installation, however.
author Matthew Wild <mwild1@gmail.com>
date Tue, 11 Feb 2025 13:18:38 +0000
parent 6162:a58fb6a05412
line wrap: on
line source

-- mod_muc_moderation
--
-- Copyright (C) 2015-2021 Kim Alvefur
--
-- This file is MIT licensed.
--
-- Implements: XEP-0425: Message Moderation
--
-- Imports
local dt = require "util.datetime";
local id = require "util.id";
local jid = require "util.jid";
local st = require "util.stanza";

-- Plugin dependencies
local mod_muc = module:depends "muc";

local muc_util = module:require "muc/util";
local valid_roles = muc_util.valid_roles;

local muc_log_archive = module:open_store("muc_log", "archive");

if not muc_log_archive.set then
	module:log("warn", "Selected archive storage module does not support message replacement, no tombstones will be saved");
end

-- Namespaces
local xmlns_fasten = "urn:xmpp:fasten:0";
local xmlns_moderate = "urn:xmpp:message-moderate:0";
local xmlns_moderate_1 = "urn:xmpp:message-moderate:1";
local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
local xmlns_retract = "urn:xmpp:message-retract:0";
local xmlns_retract_1 = "urn:xmpp:message-retract:1";

-- Discovering support
module:hook("muc-disco#info", function (event)
	event.reply:tag("feature", { var = xmlns_moderate }):up();
	event.reply:tag("feature", { var = xmlns_moderate_1 }):up();
end);

-- TODO error registry, requires Prosody 0.12+

-- moderate : function (string, string, string, boolean, string) : boolean, enum, enum, string
local function moderate(actor, room_jid, stanza_id, retract, reason)
	local room_node = jid.split(room_jid);
	local room = mod_muc.get_room_from_jid(room_jid);

	-- Permissions is based on role, which is a property of a current occupant,
	-- so check if the actor is an occupant, otherwise if they have a reserved
	-- nickname that can be used to retrieve the role.
	local actor_nick = room:get_occupant_jid(actor);
	if not actor_nick then
		local reserved_nickname = room:get_affiliation_data(jid.bare(actor), "reserved_nickname");
		if reserved_nickname then
			actor_nick = room.jid .. "/" .. reserved_nickname;
		end
	end

	-- Retrieve their current role, iff they are in the room, otherwise what they
	-- would have based on affiliation.
	local affiliation = room:get_affiliation(actor);
	local role = room:get_role(actor_nick) or room:get_default_role(affiliation);
	if valid_roles[role or "none"] < valid_roles.moderator then
		return false, "auth", "forbidden", "You need a role of at least 'moderator'";
	end

	-- Original stanza to base tombstone on
	local original, err;
	if muc_log_archive.get then
		original, err = muc_log_archive:get(room_node, stanza_id);
	else
		-- COMPAT missing :get API
		err = "item-not-found";
		for i, item in muc_log_archive:find(room_node, { key = stanza_id, limit = 1 }) do
			if i == stanza_id then
				original, err = item, nil;
			end
		end
	end

	if not original then
		if err == "item-not-found" then
			return false, "modify", "item-not-found";
		else
			return false, "wait", "internal-server-error";
		end
	end

	local actor_occupant = room:get_occupant_by_real_jid(actor) or room:new_occupant(jid.bare(actor), actor_nick);

	local announcement = st.message({ from = room_jid, type = "groupchat", id = id.medium(), })
		:tag("apply-to", { xmlns = xmlns_fasten, id = stanza_id })
			:tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })

	if room.get_occupant_id then
		-- This isn't a regular broadcast message going through the events occupant_id.lib hooks so we do this here
		announcement:add_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }));
	end

	if retract then
		announcement:tag("retract", { xmlns = xmlns_retract }):up();
	end

	if reason then
		announcement:text_tag("reason", reason);
	end

	local moderated_occupant_id = original:get_child("occupant-id", xmlns_occupant_id);
	if room.get_occupant_id and moderated_occupant_id then
		announcement:add_direct_child(moderated_occupant_id);
	end

	-- XEP 0425 v0.3.0

	announcement:reset();

	if retract then
		announcement:tag("retract", { xmlns = xmlns_retract_1; id = stanza_id })
			:tag("moderated", { xmlns = xmlns_moderate_1 })
			:tag("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) });
		if reason then
			announcement:up():up():text_tag("reason", reason);
		end
	end


	local tombstone = nil;
	if muc_log_archive.set and retract then
		tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id })
			:tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
				:tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up();

		if reason then
			tombstone:text_tag("reason", reason);
		end

		if room.get_occupant_id then
			if actor_occupant then
				tombstone:add_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }));
			end

			if moderated_occupant_id then
				-- Copy occupant id from moderated message
				tombstone:add_direct_child(moderated_occupant_id);
			end
		end
		tombstone:reset();
	end

	-- fire an event, that can be used to cancel the moderation, or modify stanzas.
	local event = {
		room = room;
		announcement = announcement;
		tombstone = tombstone;
		stanza_id = stanza_id;
		retract = retract;
		reason = reason;
		actor = actor;
		actor_nick = actor_nick;
	};
	if module:fire_event("muc-moderate-message", event) then
		-- TODO: allow to change the error message?
		return false, "wait", "internal-server-error";
	end

	if tombstone then
		local was_replaced = muc_log_archive:set(room_node, stanza_id, tombstone);
		if not was_replaced then
			return false, "wait", "internal-server-error";
		end
	end

	-- Done, tell people about it
	module:log("info", "Message with id '%s' in room %s moderated by %s, reason: %s", stanza_id, room_jid, actor, reason);
	room:broadcast_message(announcement);

	return true;
end

-- Main handling
module:hook("iq-set/bare/" .. xmlns_fasten .. ":apply-to", function (event)
	local stanza, origin = event.stanza, event.origin;

	local actor = stanza.attr.from;
	local room_jid = stanza.attr.to;

	-- Collect info we need
	local apply_to = stanza.tags[1];
	local moderate_tag = apply_to:get_child("moderate", xmlns_moderate);
	if not moderate_tag then return end -- some other kind of fastening?

	local reason = moderate_tag:get_child_text("reason");
	local retract = moderate_tag:get_child("retract", xmlns_retract);

	local stanza_id = apply_to.attr.id;

	local ok, error_type, error_condition, error_text = moderate(actor, room_jid, stanza_id, retract, reason);
	if not ok then
		origin.send(st.error_reply(stanza, error_type, error_condition, error_text));
		return true;
	end

	origin.send(st.reply(stanza));
	return true;
end);

module:hook("iq-set/bare/" .. xmlns_moderate_1 .. ":moderate", function (event)
	local stanza, origin = event.stanza, event.origin;

	local actor = stanza.attr.from;
	local room_jid = stanza.attr.to;

	local moderate_tag = stanza:get_child("moderate", xmlns_moderate_1)
	local retract_tag = moderate_tag:get_child("retract", xmlns_retract_1)

	if not retract_tag then return end -- other kind of moderation?

	local reason = moderate_tag:get_child_text("reason");
	local stanza_id = moderate_tag.attr.id

	local ok, error_type, error_condition, error_text = moderate(
		actor,
		room_jid,
		stanza_id,
		retract_tag,
		reason
	);
	if not ok then
		origin.send(st.error_reply(stanza, error_type, error_condition, error_text));
		return true;
	end

	origin.send(st.reply(stanza));
	return true;
end);

module:hook("muc-message-is-historic", function (event)
	-- Ensure moderation messages are stored
	if event.stanza.attr.from == event.room.jid then
		return event.stanza:get_child("apply-to", xmlns_fasten);
	end
end, 1);