Files
ansible-roles/roles/jitsi/files/prosody/modules/mod_visitors_component.lua
2025-07-28 13:00:23 +02:00

754 lines
30 KiB
Lua

module:log('info', 'Starting visitors_component at %s', module.host);
local array = require "util.array";
local http = require 'net.http';
local jid = require 'util.jid';
local st = require 'util.stanza';
local util = module:require 'util';
local is_admin = util.is_admin;
local is_healthcheck_room = util.is_healthcheck_room;
local is_sip_jigasi = util.is_sip_jigasi;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local get_room_from_jid = util.get_room_from_jid;
local get_focus_occupant = util.get_focus_occupant;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local table_find = util.table_find;
local is_vpaas = util.is_vpaas;
local is_sip_jibri_join = util.is_sip_jibri_join;
local process_host_module = util.process_host_module;
local respond_iq_result = util.respond_iq_result;
local split_string = util.split_string;
local new_id = require 'util.id'.medium;
local json = require 'cjson.safe';
local inspect = require 'inspect';
-- Debug flag
local DEBUG = false;
-- will be initialized once the main virtual host module is initialized
local token_util;
local MUC_NS = 'http://jabber.org/protocol/muc';
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
if not muc_domain_base then
module:log('warn', 'No muc_domain_base option set.');
return;
end
-- A list of domains which to be ignored for visitors. The config is set under the main virtual host
local ignore_list = module:context(muc_domain_base):get_option_set('visitors_ignore_list', {});
local auto_allow_promotion = module:get_option_boolean('auto_allow_visitor_promotion', false);
-- whether to always advertise that visitors feature is enabled for rooms
-- can be set to off and being controlled by another module, turning it on and off for rooms
local always_visitors_enabled = module:get_option_boolean('always_visitors_enabled', true);
local visitors_queue_service = module:get_option_string('visitors_queue_service');
local http_headers = {
["User-Agent"] = "Prosody (" .. prosody.version .. "; " .. prosody.platform .. ")",
["Content-Type"] = "application/json",
["Accept"] = "application/json"
};
-- This is a map to keep data for room and the jids that were allowed to join after visitor mode is enabled
-- automatically allowed or allowed by a moderator
local visitors_promotion_map = {};
-- A map with key room jid. The content is a map with key jid from which the request is received
-- and the value is a table that has the json message that needs to be sent to any future moderator that joins
-- and the vnode from which the request is received and where the response will be sent
local visitors_promotion_requests = {};
local cache = require 'util.cache';
local sent_iq_cache = cache.new(200);
-- Function to get visitors room metadata
local function get_visitors_room_metadata(room)
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
if not room.jitsiMetadata.visitors then
room.jitsiMetadata.visitors = {};
end
return room.jitsiMetadata.visitors;
end
-- Sends a json-message to the destination jid
-- @param to_jid the destination jid
-- @param json_message the message content to send
function send_json_message(to_jid, json_message)
local stanza = st.message({ from = module.host; to = to_jid; })
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_message):up();
module:send(stanza);
end
local function request_promotion_received(room, from_jid, from_vnode, nick, time, user_id, group_id, force_promote_requested)
if DEBUG then
module:log('debug', 'Received promotion request from %s for room %s, nick: %s, time: %s, user_id: %s, group_id: %s, force_promote_requested: %s',
from_jid, room.jid, nick, time, user_id, group_id, force_promote_requested);
end
-- if visitors is enabled for the room
if visitors_promotion_map[room.jid] then
local force_promote = auto_allow_promotion or get_visitors_room_metadata(room).autoPromote;
if not force_promote and force_promote_requested == 'true' then
-- Let's do the force_promote checks if requested
-- if it is vpaas meeting we trust the moderator computation from visitor node (value of force_promote_requested)
-- if it is not vpaas we need to check further settings only if they exist
if is_vpaas(room) or (not room._data.moderator_id and not room._data.moderators)
-- _data.moderator_id can be used from external modules to set single moderator for a meeting
-- or a whole group of moderators
or (room._data.moderator_id
and room._data.moderator_id == user_id or room._data.moderator_id == group_id)
-- all moderators are allowed to auto promote, the fact that user_id and force_promote_requested are set
-- means that the user has token and is moderator on visitor node side
or room._data.allModerators
-- can be used by external modules to set multiple moderator ids (table of values)
or table_find(room._data.moderators, user_id)
then
force_promote = true;
end
end
-- only for raise hand, ignore lowering the hand
if time and time > 0 and force_promote then
-- we are in auto-allow mode, let's reply with accept
-- we store where the request is coming from so we can send back the response
local username = new_id():lower();
visitors_promotion_map[room.jid][username] = {
from = from_vnode;
jid = from_jid;
};
local req_from = visitors_promotion_map[room.jid][username].from;
local req_jid = visitors_promotion_map[room.jid][username].jid;
local focus_occupant = get_focus_occupant(room);
local focus_jid = focus_occupant and focus_occupant.bare_jid or nil;
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local node = jid.node(room.jid);
module:send(st.iq({
type='set', to = req_from, from = module.host, id = iq_id })
:tag('visitors', {
xmlns='jitsi:visitors',
room = jid.join(node, muc_domain_prefix..'.'..req_from),
focusjid = focus_jid })
:tag('promotion-response', {
xmlns='jitsi:visitors',
jid = req_jid,
username = username ,
allow = 'true' }):up());
return true;
else
-- send promotion request to all moderators
local body_json = {};
body_json.type = 'visitors';
body_json.room = internal_room_jid_match_rewrite(room.jid);
body_json.action = 'promotion-request';
body_json.nick = nick;
body_json.from = from_jid;
if time and time > 0 then
-- raise hand
body_json.on = true;
else
-- lower hand, we want to inform interested parties that
-- the visitor is no longer interested in joining the main call
body_json.on = false;
end
local msg_to_send, error = json.encode(body_json);
if not msg_to_send then
module:log('error', 'Error encoding msg room:%s error:%s', room.jid, error)
return true;
end
if visitors_promotion_requests[room.jid] then
visitors_promotion_requests[room.jid][from_jid] = {
msg = msg_to_send;
from = from_vnode;
};
else
module:log('warn', 'Received promotion request for room %s with visitors not enabled. %s',
room.jid, msg_to_send);
end
-- let's send a notification to every moderator
for _, occupant in room:each_occupant() do
if occupant.role == 'moderator' and not is_admin(occupant.bare_jid) then
send_json_message(occupant.jid, msg_to_send);
end
end
return true;
end
end
module:log('warn', 'Received promotion request from %s for room %s without active visitors', from, room.jid);
end
local function connect_vnode_received(room, vnode)
module:context(muc_domain_base):fire_event('jitsi-connect-vnode', { room = room; vnode = vnode; });
if not visitors_promotion_map[room.jid] then
-- visitors is enabled
visitors_promotion_map[room.jid] = {};
visitors_promotion_requests[room.jid] = {};
room._connected_vnodes = cache.new(16); -- we up to 16 vnodes for this prosody
end
room._connected_vnodes:set(vnode..'.meet.jitsi', {});
end
local function disconnect_vnode_received(room, vnode)
module:context(muc_domain_base):fire_event('jitsi-disconnect-vnode', { room = room; vnode = vnode; });
room._connected_vnodes:set(vnode..'.meet.jitsi', nil);
if room._connected_vnodes:count() == 0 then
visitors_promotion_map[room.jid] = nil;
visitors_promotion_requests[room.jid] = nil;
room._connected_vnodes = nil;
end
end
-- returns the accumulated data for visitors nodes, count all visitors requesting transcriptions
-- and accumulated languages requested
-- @returns count, languages
function get_visitors_languages(room)
if not room._connected_vnodes then
return;
end
local count = 0;
local languages = array();
-- iterate over visitor nodes we are connected to and accumulate data if we have it
for k, v in room._connected_vnodes:items() do
if v.count then
count = count + v.count;
end
if v.langs then
for k in pairs(v.langs) do
local val = v.langs[k]
if not languages[val] then
languages:push(val);
end
end
end
end
return count, languages:sort():concat(',');
end
-- listens for iq request for promotion and forward it to moderators in the meeting for approval
-- or auto-allow it if such the config is set enabling it
local function stanza_handler(event)
local origin, stanza = event.origin, event.stanza;
if stanza.name ~= 'iq' then
return;
end
if DEBUG then
module:log('debug', 'Received stanza %s from %s', stanza, origin.full_jid);
end
if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then
sent_iq_cache:set(stanza.attr.id, nil);
return true;
end
if stanza.attr.type ~= 'set' and stanza.attr.type ~= 'get' then
return; -- We do not want to reply to these, so leave.
end
local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors');
if not visitors_iq then
return;
end
-- set stanzas are coming from s2s connection
if stanza.attr.type == 'set' and origin.type ~= 's2sin' then
module:log('warn', 'not from s2s session, ignore! %s', stanza);
return true;
end
local room_jid = visitors_iq.attr.room;
local room = get_room_from_jid(room_jid_match_rewrite(room_jid));
if not room then
-- this maybe as we receive the iq from jicofo after the room is already destroyed
module:log('debug', 'No room found %s', room_jid);
return true;
end
local from_vnode;
if room._connected_vnodes then
from_vnode = room._connected_vnodes:get(stanza.attr.from);
end
local processed;
-- promotion request is coming from visitors and is a set and is over the s2s connection
local request_promotion = visitors_iq:get_child('promotion-request');
if request_promotion then
if not from_vnode then
module:log('warn', 'Received forged request_promotion message: %s %s',stanza, inspect(room._connected_vnodes));
return true; -- stop processing
end
local display_name = visitors_iq:get_child_text('nick', 'http://jabber.org/protocol/nick');
processed = request_promotion_received(
room,
request_promotion.attr.jid,
stanza.attr.from,
display_name,
tonumber(request_promotion.attr.time),
request_promotion.attr.userId,
request_promotion.attr.groupId,
request_promotion.attr.forcePromote
);
end
-- connect and disconnect are only received from jicofo
if is_admin(jid.bare(stanza.attr.from)) then
for item in visitors_iq:childtags('connect-vnode') do
connect_vnode_received(room, item.attr.vnode);
processed = true;
end
for item in visitors_iq:childtags('disconnect-vnode') do
disconnect_vnode_received(room, item.attr.vnode);
processed = true;
end
end
-- request to update metadata service for jigasi languages
local transcription_languages = visitors_iq:get_child('transcription-languages');
if transcription_languages
and (transcription_languages.attr.langs or transcription_languages.attr.count) then
if not from_vnode then
module:log('warn', 'Received forged transcription_languages message: %s %s',stanza, inspect(room._connected_vnodes));
return true; -- stop processing
end
local metadata = get_visitors_room_metadata(room);
-- we keep the split by languages array to optimize accumulating languages
from_vnode.langs = split_string(transcription_languages.attr.langs, ',');
from_vnode.count = transcription_languages.attr.count;
local count, languages = get_visitors_languages(room);
if metadata.transcribingLanguages ~= languages then
metadata.transcribingLanguages = languages;
processed = true;
end
if metadata.transcribingCount ~= count then
metadata.transcribingCount = count;
processed = true;
end
if processed then
module:context(muc_domain_prefix..'.'..muc_domain_base)
:fire_event('room-metadata-changed', { room = room; });
end
end
if not processed then
module:log('warn', 'Unknown iq received for %s: %s', module.host, stanza);
end
respond_iq_result(origin, stanza);
return processed;
end
local function process_promotion_response(room, id, approved)
if not approved then
module:log('debug', 'promotion not approved %s, %s', room.jid, id);
return;
end
if DEBUG then
module:log('debug', 'Processing promotion response for room %s, id %s, approved %s',
room.jid, id, approved);
end
-- lets reply to participant that requested promotion
local username = new_id():lower();
visitors_promotion_map[room.jid][username] = {
from = visitors_promotion_requests[room.jid][id].from;
jid = id;
};
local req_from = visitors_promotion_map[room.jid][username].from;
local req_jid = visitors_promotion_map[room.jid][username].jid;
local focus_occupant = get_focus_occupant(room);
local focus_jid = focus_occupant and focus_occupant.bare_jid or nil;
local iq_id = new_id();
sent_iq_cache:set(iq_id, socket.gettime());
local node = jid.node(room.jid);
module:send(st.iq({
type='set', to = req_from, from = module.host, id = iq_id })
:tag('visitors', {
xmlns='jitsi:visitors',
room = jid.join(node, muc_domain_prefix..'.'..req_from),
focusjid = focus_jid })
:tag('promotion-response', {
xmlns='jitsi:visitors',
jid = req_jid,
username = username,
allow = approved }):up());
end
-- if room metadata does not have visitors.live set to `true` and there are no occupants in the meeting
-- it will skip calling goLive endpoint
local function go_live(room)
if DEBUG then
module:log('debug', 'Checking if room %s is live', room.jid);
end
if room._jitsi_go_live_sent then
if DEBUG then
module:log('debug', 'Room %s already sent go live request, skipping', room.jid);
end
return;
end
-- if missing we assume room is live, only skip if it is marked explicitly as false
if room.jitsiMetadata and room.jitsiMetadata.visitors
and room.jitsiMetadata.visitors.live ~= nil and room.jitsiMetadata.visitors.live == false then
if DEBUG then
module:log('debug', 'Room %s is not live, skipping go live request', room.jid);
end
return;
end
local has_occupant = false;
for _, occupant in room:each_occupant() do
if not is_admin(occupant.bare_jid) then
has_occupant = true;
break;
end
end
-- when there is an occupant then go live
if not has_occupant then
if DEBUG then
module:log('debug', 'Room %s has no occupants, skipping go live request', room.jid);
end
return;
end
-- let's inform the queue service
local function cb(content_, code_, response_, request_)
local room = room;
if code_ ~= 200 then
module:log('warn', 'External call to visitors_queue_service/golive failed. Code %s, Content %s',
code_, content_)
end
end
local headers = http_headers or {};
headers['Authorization'] = token_util:generateAsapToken();
local ev = {
conference = internal_room_jid_match_rewrite(room.jid)
};
room._jitsi_go_live_sent = true;
http.request(visitors_queue_service..'/golive', {
headers = headers,
method = 'POST',
body = json.encode(ev);
}, cb);
end
module:hook('iq/host', stanza_handler, 10);
process_host_module(muc_domain_base, function(host_module, host)
token_util = module:require "token/util".new(host_module);
end);
process_host_module(muc_domain_prefix..'.'..muc_domain_base, function(host_module, host)
-- if visitor mode is started, then you are not allowed to join without request/response exchange of iqs -> deny access
-- check list of allowed jids for the room
host_module:hook('muc-occupant-pre-join', function (event)
local room, stanza, occupant, session = event.room, event.stanza, event.occupant, event.origin;
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
if DEBUG then
module:log('debug', 'Skipping visitor checks for healthcheck room %s or admin %s',
room.jid, occupant.bare_jid);
end
return;
end
-- visitors were already in the room one way or another they have access
-- skip password challenge
local join = stanza:get_child('x', MUC_NS);
if join and room:get_password() and
visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][jid.node(stanza.attr.from)] then
join:tag('password', { xmlns = MUC_NS }):text(room:get_password());
end
local is_live = get_visitors_room_metadata(room).live;
-- we skip any checks when auto-allow is enabled and room is live
if (auto_allow_promotion or get_visitors_room_metadata(room).autoPromote and (is_live or is_live == nil))
or ignore_list:contains(jid.host(stanza.attr.from)) -- jibri or other domains to ignore
or is_sip_jigasi(stanza)
or is_sip_jibri_join(stanza)
or table_find(room._data.moderators, session.jitsi_meet_context_user and session.jitsi_meet_context_user.id)
or (room._data.moderator_id and room._data.moderator_id == (session.jitsi_meet_context_user and session.jitsi_meet_context_user.id))
or table_find(room._data.participants, session.jitsi_meet_context_user and session.jitsi_meet_context_user.id) then
if DEBUG then
module:log('debug', 'Auto-allowing visitor %s in room %s', stanza.attr.from, room.jid);
end
return;
end
if visitors_promotion_map[room.jid] then
local in_ignore_list = ignore_list:contains(jid.host(stanza.attr.from));
-- now let's check for jid
if visitors_promotion_map[room.jid][jid.node(stanza.attr.from)] -- promotion was approved
or in_ignore_list then -- jibri or other domains to ignore
-- allow join
if not in_ignore_list then
-- let's update metadata
local metadata = get_visitors_room_metadata(room);
if not metadata.promoted then
metadata.promoted = {};
end
metadata.promoted[jid.resource(occupant.nick)] = true;
module:context(muc_domain_prefix..'.'..muc_domain_base)
:fire_event('room-metadata-changed', { room = room; });
end
return;
end
module:log('error', 'Visitor needs to be allowed by a moderator %s', stanza.attr.from);
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Visitor needs to be allowed by a moderator')
:tag('promotion-not-allowed', { xmlns = 'jitsi:visitors' }));
return true;
elseif is_vpaas(room) then
-- special case for vpaas where if someone with a visitor token tries to join a room, where
-- there are no visitors yet, we deny access
if session.jitsi_meet_context_user and session.jitsi_meet_context_user.role == 'visitor' then
session.log('warn', 'Deny user join as visitor in the main meeting, not approved');
session.send(st.error_reply(
stanza, 'cancel', 'not-allowed', 'Visitor tried to join the main room without approval')
:tag('no-main-participants', { xmlns = 'jitsi:visitors' }));
return true;
end
elseif room._data.participants then
-- This is non jaas room which has a list of participants allowed to participate in the main room
-- but this occupant is not one of them and the room is either not live or has no participants joined
session.log('warn',
'Deny user join in the main not live meeting, not in the list of main participants');
session.send(st.error_reply(
stanza, 'cancel', 'not-allowed',
'Tried to join the main (not live or without main participants) room')
:tag('not-live-room', { xmlns = 'jitsi:visitors' }));
return true;
end
end, 7); -- after muc_meeting_id, the logic for not joining before jicofo
host_module:hook('muc-room-destroyed', function (event)
visitors_promotion_map[event.room.jid] = nil;
visitors_promotion_requests[event.room.jid] = nil;
end);
host_module:hook('muc-occupant-joined', function (event)
local room, occupant = event.room, event.occupant;
if DEBUG then
module:log('debug', 'Occupant %s joined room %s', occupant.jid, room.jid);
end
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) or occupant.role ~= 'moderator' -- luacheck: ignore
or not visitors_promotion_requests[event.room.jid] then
if DEBUG then
module:log('debug', 'Skipping visitor checks for healthcheck room %s or admin %s or not moderator %s',
room.jid, occupant.bare_jid, occupant.role);
end
return;
end
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
send_json_message(occupant.jid, value.msg);
end
end);
host_module:hook('muc-set-affiliation', function (event)
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
-- as we will handle it in occupant_joined
local actor, affiliation, jid, room = event.actor, event.affiliation, event.jid, event.room;
if is_admin(jid) or is_healthcheck_room(room.jid) or not actor or not affiliation == 'owner' -- luacheck: ignore
or not visitors_promotion_requests[event.room.jid] then
return;
end
-- event.jid is the bare jid of participant
for _, occupant in room:each_occupant() do
if occupant.bare_jid == event.jid then
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
send_json_message(occupant.jid, value.msg);
end
end
end
end);
host_module:hook('jitsi-endpoint-message-received', function(event)
local data, error, occupant, room, stanza
= event.message, event.error, event.occupant, event.room, event.stanza;
if not data or data.type ~= 'visitors'
or (data.action ~= "promotion-response" and data.action ~= "demote-request") then
if error then
module:log('error', 'Error decoding error:%s', error);
end
return;
end
if occupant.role ~= 'moderator' then
module:log('error', 'Occupant %s sending response message but not moderator in room %s',
occupant.jid, room.jid);
return false;
end
if data.action == "demote-request" then
if occupant.nick ~= room.jid..'/'..data.actor then
module:log('error', 'Bad actor in demote request %s', stanza);
event.origin.send(st.error_reply(stanza, "cancel", "bad-request"));
return true;
end
-- when demoting we want to send message to the demoted participant and to moderators
local target_jid = room.jid..'/'..data.id;
stanza.attr.type = 'chat'; -- it is safe as we are not using this stanza instance anymore
stanza.attr.from = module.host;
for _, room_occupant in room:each_occupant() do
-- do not send it to jicofo or back to the sender
if room_occupant.jid ~= occupant.jid and not is_admin(room_occupant.bare_jid) then
if room_occupant.role == 'moderator'
or room_occupant.nick == target_jid then
stanza.attr.to = room_occupant.jid;
room:route_stanza(stanza);
end
end
end
else
if data.id then
process_promotion_response(room, data.id, data.approved and 'true' or 'false');
else
-- we are in the case with admit all, we need to read data.ids
for _,value in pairs(data.ids) do
process_promotion_response(room, value, data.approved and 'true' or 'false');
end
end
end
return true; -- halt processing, but return true that we handled it
end);
if visitors_queue_service then
host_module:hook('muc-room-created', function (event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
go_live(room);
end, -2); -- metadata hook on -1
host_module:hook('jitsi-metadata-updated', function (event)
if event.key == 'visitors' then
go_live(event.room);
end
end);
-- when metadata changed internally from another module
host_module:hook('room-metadata-changed', function (event)
go_live(event.room);
end);
host_module:hook('muc-occupant-joined', function (event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
go_live(room);
end);
end
if always_visitors_enabled then
local visitorsEnabledField = {
name = "muc#roominfo_visitorsEnabled";
type = "boolean";
label = "Whether visitors are enabled.";
value = 1;
};
-- Append "visitors enabled" to the MUC config form.
host_module:context(host):hook("muc-disco#info", function(event)
table.insert(event.form, visitorsEnabledField);
end);
host_module:context(host):hook("muc-config-form", function(event)
table.insert(event.form, visitorsEnabledField);
end);
end
end);
prosody.events.add_handler('pre-jitsi-authentication', function(session)
if not session.customusername or not session.jitsi_web_query_room then
return nil;
end
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
return nil;
end
if visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][session.customusername] then
-- user was previously allowed to join, let him use the requested jid
return session.customusername;
end
end);
-- when occupant is leaving breakout to join the main room and visitors are enabled
-- make sure we will allow that participant to join as it is already part of the main room
function handle_occupant_leaving_breakout(event)
local main_room, occupant, stanza = event.main_room, event.occupant, event.stanza;
local presence_status = stanza:get_child_text('status');
if presence_status ~= 'switch_room' or not visitors_promotion_map[main_room.jid] then
return;
end
local node = jid.node(occupant.bare_jid);
visitors_promotion_map[main_room.jid][node] = {
from = 'none';
jid = occupant.bare_jid;
};
end
module:hook_global('jitsi-breakout-occupant-leaving', handle_occupant_leaving_breakout);