snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,460 @@
-- Server side handling of chat message moderation (XEP-0425) and message
-- correction (XEP-0308) for Jitsi MUC rooms.
--
-- Both operations used to be synchronised client to client over json messages,
-- and late joiners were brought up to date by every participant replaying the
-- current state to them on join. Handling both here keeps the room history
-- itself correct, so a joining client needs no follow up at all.
--
-- 1. Moderation (XEP-0425). An occupant sends:
--
-- <message type='groupchat'>
-- <apply-to xmlns='urn:xmpp:fasten:0' id='ORIG'>
-- <moderated xmlns='urn:xmpp:message-moderate:1'>
-- <retract xmlns='urn:xmpp:message-retract:1'/>
-- <reason>spam</reason>
-- </moderated>
-- </apply-to>
-- </message>
--
-- The request is honoured only when the sending occupant's MUC role is
-- 'moderator'. The room history entry for ORIG is replaced with a tombstone
-- (body stripped, <moderated by='...'><retracted/></moderated> added) so late
-- joiners never receive the moderated text at all, and the moderation is
-- broadcast from the room JID. The occupant's own request is not relayed, the
-- room emits its own stanza and takes 'by' from the sender's occupant JID.
-- A message that has aged out of the history window is still moderated for
-- everyone in the room, there is just no entry left to turn into a tombstone.
-- 'jitsi-message-moderated' is fired alongside the broadcast so mod_visitors
-- can forward it to the visitor nodes, which the room's own broadcast does
-- not reach.
--
-- 2. Correction (XEP-0308). An occupant sends a new body carrying:
--
-- <replace xmlns='urn:xmpp:message-correct:0' id='ORIG'/>
--
-- The request is honoured only when ORIG was authored by the same occupant
-- and has not been moderated; anything else is dropped and reaches nobody.
-- An honoured correction is relayed untouched: it carries the new body and
-- asks to be stored, so the room archives it and replays it after the message
-- it corrects, and a client joining later applies it the same way one in the
-- room does. Nothing here rewrites the history entry.
--
-- 3. Retraction (XEP-0424). The author retracts their own message with a
-- top level:
--
-- <retract xmlns='urn:xmpp:message-retract:1' id='ORIG'/>
--
-- The request is honoured only when ORIG was authored by the same occupant.
-- The history entry is removed outright rather than tombstoned, so a joining
-- client never receives the message at all, and the retraction is not stored
-- either since there is no longer anything for it to pair with. The retraction
-- stanza itself is what reaches the occupants already in the room.
--
-- The moderation the room broadcasts and the retraction are kept out of history:
-- the first is already reflected in the tombstone, and the second removed the
-- entry it referred to.
--
-- Rooms advertise 'muc#roominfo_messageModerationEnabled' so a client can tell
-- whether the server is here to apply these operations, and offer the moderate
-- and edit actions only when it is.
--
-- Needs to be activated under the muc component where message moderation and
-- editing should be handled. Without it the client hides both actions, as the
-- room stops advertising them.
--
-- Load it on a visitor prosody's muc component too. There it keeps the local
-- history in step for the operations that are not replayed on their own, and
-- advertises the room info field so visitors see the same actions. It does not
-- check anything that reached it from the main prosody, which is where the room
-- the occupants act in lives and where every request is checked. It does check
-- the author of a visitor's own request, since that has not been past the main
-- prosody yet at the point this node routes it.
--
-- Component "conference.meet.jitsi" "muc"
-- modules_enabled = { "muc_message_moderation" }
--
-- Copyright (C) 2026-present 8x8, Inc.
local st = require 'util.stanza';
local id = require 'util.id';
local jid = require 'util.jid';
local datetime = require 'util.datetime';
local ends_with = module:require 'util'.ends_with;
-- only the visitor prosody has a main_domain setting
local main_domain = module:get_option_string('main_domain');
local local_domain = module:get_option_string('muc_mapper_domain_base');
local FASTEN_NS = 'urn:xmpp:fasten:0';
local MODERATE_NS = 'urn:xmpp:message-moderate:1';
local RETRACT_NS = 'urn:xmpp:message-retract:1';
local CORRECT_NS = 'urn:xmpp:message-correct:0';
local HINTS_NS = 'urn:xmpp:hints';
-- Returns the id of the message the stanza asks to moderate and the optional
-- reason, or nil when the stanza is not a moderation request.
function parse_moderation_request(stanza)
local apply_to = stanza:get_child('apply-to', FASTEN_NS);
if not apply_to or not apply_to.attr.id then
return nil;
end
local moderated = apply_to:get_child('moderated', MODERATE_NS);
if not moderated or not moderated:get_child('retract', RETRACT_NS) then
return nil;
end
-- 'by' is only meaningful on a stanza a room has sent, where the server
-- stamped it. On an occupant's own request it is whatever they typed.
return apply_to.attr.id, moderated:get_child_text('reason'), moderated.attr.by;
end
-- Returns the id of the message the stanza corrects and the new body, or nil
-- when the stanza is not a correction.
function parse_correction(stanza)
local replace = stanza:get_child('replace', CORRECT_NS);
if not replace or not replace.attr.id then
return nil;
end
local body = stanza:get_child_text('body');
if not body then
return nil;
end
return replace.attr.id, body;
end
-- Returns the id of the message the stanza retracts, or nil when the stanza is
-- not a retraction. A moderation request also carries a <retract/>, but nested
-- inside <apply-to><moderated/>, so it is not matched here.
function parse_retraction(stanza)
local retract = stanza:get_child('retract', RETRACT_NS);
if not retract or not retract.attr.id then
return nil;
end
return retract.attr.id;
end
-- Finds the room history entry for a message id. Searches newest first, as
-- edits and moderation overwhelmingly target recent messages.
function find_history_entry(room, message_id)
local history = room._history;
if not history or not message_id then
return nil;
end
for i = #history, 1, -1 do
local entry = history[i];
if entry.stanza and entry.stanza.attr.id == message_id then
return entry, i;
end
end
return nil;
end
function is_entry_moderated(entry)
return entry.stanza:get_child('moderated', MODERATE_NS) ~= nil;
end
-- Replaces a history entry with a XEP-0425 tombstone. The body is dropped so
-- the moderated text is never sent to anyone joining later.
function tombstone_entry(entry, by, reason, stamp)
local tombstone = st.clone(entry.stanza);
tombstone:remove_children('body');
tombstone:remove_children('replace', CORRECT_NS);
-- never nest two moderation markers if the same message is moderated twice
tombstone:remove_children('moderated', MODERATE_NS);
local moderated = st.stanza('moderated', { xmlns = MODERATE_NS, by = by })
:tag('retracted', { xmlns = RETRACT_NS, stamp = stamp }):up();
if reason and reason ~= '' then
moderated:tag('reason'):text(reason):up();
end
tombstone:add_child(moderated);
entry.stanza = tombstone;
return entry;
end
-- The stanza the room sends to announce a moderation. It carries no body, so a
-- client that does not understand it simply ignores it.
function build_moderation_broadcast(room, target_id, by, reason)
local moderated = st.stanza('moderated', { xmlns = MODERATE_NS, by = by })
:tag('retract', { xmlns = RETRACT_NS }):up();
if reason and reason ~= '' then
moderated:tag('reason'):text(reason):up();
end
local broadcast = st.message({ from = room.jid, type = 'groupchat', id = id.medium() })
:tag('apply-to', { xmlns = FASTEN_NS, id = target_id });
broadcast:add_child(moderated);
broadcast:up();
broadcast:add_child(st.stanza('no-store', { xmlns = HINTS_NS }));
return broadcast;
end
-- 'occupant' comes from the hook rather than from stanza.attr.from, which still
-- holds the sender's real jid at this point; the room rewrites it to the
-- occupant nick only after the event has run.
function handle_moderation(origin, room, stanza, occupant, target_id, reason)
if not occupant or occupant.role ~= 'moderator' then
module:log('warn', 'Rejected moderation of %s from non-moderator %s', target_id, stanza.attr.from);
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only moderators can moderate messages'));
return true;
end
local entry = find_history_entry(room, target_id);
-- The message may have aged out of the room's history window. The moderator's
-- decision still stands for everyone in the room, there is simply no entry
-- left to turn into a tombstone.
if entry then
tombstone_entry(entry, occupant.nick, reason, datetime.datetime());
end
local broadcast = build_moderation_broadcast(room, target_id, occupant.nick, reason);
room:broadcast_message(broadcast);
-- broadcast_message only reaches occupants of this room. Visitor nodes are
-- separate prosodies, and the hook that forwards occupant messages to them
-- never sees this stanza because the room sent it, so announce it for
-- mod_visitors to fan out.
module:fire_event('jitsi-message-moderated', { room = room; stanza = broadcast; });
-- the room has emitted its own stanza, don't relay the occupant's request
return true;
end
function handle_correction(origin, room, stanza, occupant, target_id, body)
local entry = find_history_entry(room, target_id);
-- With no entry the message has aged out of the room's history window, so
-- there is nothing to rewrite and no author to check it against. Relay it and
-- let the receiving clients apply it to their own copy.
if entry then
if not occupant or entry.stanza.attr.from ~= occupant.nick then
module:log('warn', 'Rejected correction of %s from %s, authored by %s',
target_id, occupant and occupant.nick, entry.stanza.attr.from);
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only the author can correct a message'));
return true;
end
if is_entry_moderated(entry) then
module:log('debug', 'Rejected correction of moderated message %s', target_id);
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Message has been moderated'));
return true;
end
end
module:log('debug', 'Relaying correction of %s from %s', target_id, occupant and occupant.nick);
-- fall through. The correction carries the new body and asks to be stored, so
-- the room archives it and replays it to anyone joining later, in order after
-- the message it corrects. Nothing here has to rewrite the entry.
end
-- The author retracting their own message. The history entry is dropped, so the
-- message is simply absent for anyone joining later.
function handle_retraction(origin, room, stanza, occupant, target_id)
local entry, index = find_history_entry(room, target_id);
-- With no entry the message has aged out of the room's history window, so
-- there is nothing to remove and no author to check it against.
if entry then
if not occupant or entry.stanza.attr.from ~= occupant.nick then
module:log('warn', 'Rejected retraction of %s from %s, authored by %s',
target_id, occupant and occupant.nick, entry.stanza.attr.from);
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only the author can retract a message'));
return true;
end
table.remove(room._history, index);
end
-- The message is gone from history, so a stored retraction has nothing left to
-- pair with. The client asks for it to be stored, override that.
stanza:remove_children('store', HINTS_NS);
stanza:add_child(st.stanza('no-store', { xmlns = HINTS_NS }));
-- fall through, the retraction the client sent is what reaches the occupants
-- in the room
end
function handle_groupchat(event)
local origin, room, stanza, occupant = event.origin, event.room, event.stanza, event.occupant;
if stanza.attr.type ~= 'groupchat' then
return;
end
local moderate_id, reason = parse_moderation_request(stanza);
if moderate_id then
return handle_moderation(origin, room, stanza, occupant, moderate_id, reason);
end
local correct_id, body = parse_correction(stanza);
if correct_id then
return handle_correction(origin, room, stanza, occupant, correct_id, body);
end
local retract_id = parse_retraction(stanza);
if retract_id then
return handle_retraction(origin, room, stanza, occupant, retract_id);
end
end
-- Whether this node may apply an operation to its own history entry. A stanza
-- from an occupant of this node is a visitor's own request that the main prosody
-- has not seen yet, so the author is checked against the entry. Anything else
-- arrived from the main prosody, which has already checked it.
function may_apply_locally(entry, occupant)
if not occupant or jid.host(occupant.bare_jid) ~= local_domain then
return true;
end
return entry.stanza.attr.from == occupant.nick;
end
-- On a visitor node this module only keeps the local history in step. The room
-- that occupants act in lives on the main prosody, which has already checked the
-- role or the authorship, so nothing is checked again here and no reply is sent.
-- Runs above mod_fmuc's hook and falls through, so fmuc still routes the stanza
-- to the local occupants exactly as before.
function handle_main_groupchat(event)
local room, stanza, occupant = event.room, event.stanza, event.occupant;
if stanza.attr.type ~= 'groupchat' then
return;
end
-- Editing and retraction already reach the occupants of this node through the
-- normal forwarding, so only the local history entry needs bringing in line.
local retract_id = parse_retraction(stanza);
if retract_id then
local entry, index = find_history_entry(room, retract_id);
if entry and may_apply_locally(entry, occupant) then
module:log('debug', 'Removing retracted %s from %s history', retract_id, room.jid);
table.remove(room._history, index);
end
return;
end
-- A moderation is sent by the main room itself, so it never arrives as an
-- occupant's stanza and nothing else routes it to the occupants here.
if occupant then
return;
end
local from_host = jid.host(stanza.attr.from or '');
if not from_host or not ends_with(from_host, main_domain) then
if parse_moderation_request(stanza) then
module:log('debug', 'Ignoring moderation from %s, not under %s', stanza.attr.from, main_domain);
end
return;
end
local moderate_id, reason, by = parse_moderation_request(stanza);
if moderate_id then
local entry = find_history_entry(room, moderate_id);
if entry then
tombstone_entry(entry, by, reason, datetime.datetime());
end
-- Announce it from this room rather than relaying what arrived. The
-- forwarded stanza was sent by the main room, which is not an occupant
-- here, so the from it ends up with depends on how the hop rewrites it.
-- Building it locally means only the addressing had to survive the hop.
module:log('debug', 'Announcing moderation of %s on %s', moderate_id, room.jid);
local broadcast = build_moderation_broadcast(room, moderate_id, by, reason);
-- Route it to the occupants of this node only. The main prosody's
-- participants are occupants here as well and it has already told them;
-- this node has no route to them and would only log a routing error.
for _, o in room:each_occupant() do
if jid.host(o.bare_jid) == local_domain then
room:route_to_occupant(o, broadcast);
end
end
return true;
end
end
-- Belt and braces next to the no-store hint: moderation broadcasts, corrections
-- and retractions are already reflected in the entry for the original message, or
-- in its removal, so they must not be appended to history as messages of their own.
function skip_history(event)
local stanza = event.stanza;
if stanza:get_child('apply-to', FASTEN_NS)
or stanza:get_child('retract', RETRACT_NS) then
return true;
end
end
-- Advertised so clients only offer moderating and editing where the server is
-- here to apply them and keep the room history in step.
local message_moderation_field = {
name = 'muc#roominfo_messageModerationEnabled';
type = 'boolean';
label = 'Whether message moderation and editing are handled by the server.';
value = 1;
};
function add_room_info(event)
table.insert(event.form, message_moderation_field);
end
module:hook('muc-add-history', skip_history, 10);
module:hook('muc-disco#info', add_room_info);
module:hook('muc-config-form', add_room_info);
if main_domain then
-- A visitor node only mirrors what the main prosody applied. The checking
-- handlers are deliberately not registered here: they compare an author
-- against the local occupant nick, and on a visitor node the history entry
-- came from the main prosody's snapshot, so the comparison would refuse a
-- visitor's own edit rather than apply it.
--
-- mod_fmuc handles visitor chat at priority 55 and stops there, so run above
-- it and fall through once the local history has been brought in line.
module:hook('muc-occupant-groupchat', handle_main_groupchat, 60);
else
module:hook('muc-occupant-groupchat', handle_groupchat, 10);
end
module:log('info', 'Loaded MUC message moderation and correction for %s', module.host);