feat: A/V moderation (prosody module) (#9106)

* feat(prosody-modules): Moves a function for getting room to util.

* feat: Audio/Video moderation.

* squash: Fix docs.

* squash: Changes a field name in the message for adding jid to whitelist.

* squash: Moves to boolean from boolean string.

* squash: Only moderators get whitelist on join.

* squash: Check whether in room and moderator.

* squash: Send to participants only message about approval.

Skips sending the whole list.

* feat: Separates enable/disable by media type.

Adds actor to the messages to inform who enabled it.

* squash: Fixes reporting disable of the feature.

* squash: Fixes init of av_moderation_actors.

* squash: Fixes av_moderation_actor jid to be room jid.

* squash: Fixes comments.

* squash: Fixes warning about shadowing definition.

* squash: Updates ljm.

* fix: Fixes auto-granting from jicofo.

* squash: Further simplify...
This commit is contained in:
Дамян Минков 2021-05-12 16:36:02 -05:00 committed by GitHub
parent 6d15bcc719
commit 5c08b1ec5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 311 additions and 31 deletions

View File

@ -35,6 +35,7 @@ VirtualHost "jitmeet.example.com"
key = "/etc/prosody/certs/jitmeet.example.com.key";
certificate = "/etc/prosody/certs/jitmeet.example.com.crt";
}
av_moderation_component = "avmoderation.jitmeet.example.com"
speakerstats_component = "speakerstats.jitmeet.example.com"
conference_duration_component = "conferenceduration.jitmeet.example.com"
-- we need bosh
@ -46,6 +47,7 @@ VirtualHost "jitmeet.example.com"
"external_services";
"conference_duration";
"muc_lobby_rooms";
"av_moderation";
}
c2s_require_encryption = false
lobby_muc = "lobby.jitmeet.example.com"
@ -86,6 +88,9 @@ Component "speakerstats.jitmeet.example.com" "speakerstats_component"
Component "conferenceduration.jitmeet.example.com" "conference_duration_component"
muc_component = "conference.jitmeet.example.com"
Component "avmoderation.jitmeet.example.com" "av_moderation_component"
muc_component = "conference.jitmeet.example.com"
Component "lobby.jitmeet.example.com" "muc"
storage = "memory"
restrict_room_creation = true

4
package-lock.json generated
View File

@ -11058,8 +11058,8 @@
}
},
"lib-jitsi-meet": {
"version": "github:jitsi/lib-jitsi-meet#923aa449c4e6e46823dc990b19041898e09263ca",
"from": "github:jitsi/lib-jitsi-meet#923aa449c4e6e46823dc990b19041898e09263ca",
"version": "github:jitsi/lib-jitsi-meet#88560a8a5ed1ce6e7b829d7fc8e460b4c963f119",
"from": "github:jitsi/lib-jitsi-meet#88560a8a5ed1ce6e7b829d7fc8e460b4c963f119",
"requires": {
"@jitsi/js-utils": "1.0.2",
"@jitsi/sdp-interop": "1.0.3",

View File

@ -54,7 +54,7 @@
"jquery-i18next": "1.2.1",
"js-md5": "0.6.1",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#923aa449c4e6e46823dc990b19041898e09263ca",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#88560a8a5ed1ce6e7b829d7fc8e460b4c963f119",
"libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d",
"lodash": "4.17.21",
"moment": "2.29.1",

View File

@ -0,0 +1,26 @@
local formdecode = require 'util.http'.formdecode;
local avmoderation_component = module:get_option_string('av_moderation_component', 'avmoderation'..module.host);
-- Advertise AV Moderation so client can pick up the address and use it
module:add_identity('component', 'av_moderation', avmoderation_component);
-- Extract 'room' param from URL when session is created
function update_session(event)
local session = event.session;
if session.jitsi_web_query_room then
-- no need for an update
return;
end
local query = event.request.url.query;
if query ~= nil then
local params = formdecode(query);
-- The room name and optional prefix from the web query
session.jitsi_web_query_room = params.room;
session.jitsi_web_query_prefix = params.prefix or '';
end
end
module:hook_global('bosh-session', update_session);
module:hook_global('websocket-session', update_session);

View File

@ -0,0 +1,250 @@
local get_room_by_name_and_subdomain = module:require 'util'.get_room_by_name_and_subdomain;
local is_healthcheck_room = module:require 'util'.is_healthcheck_room;
local json = require 'util.json';
local st = require 'util.stanza';
local muc_component_host = module:get_option_string('muc_component');
if muc_component_host == nil then
log('error', 'No muc_component specified. No muc to operate on!');
return;
end
module:log('info', 'Starting av_moderation for %s', muc_component_host);
-- 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
-- Notifies that av moderation has been enabled or disabled
-- @param jid the jid to notify, if missing will notify all occupants
-- @param enable whether it is enabled or disabled
-- @param room the room
-- @param actorJid the jid that is performing the enable/disable operation (the muc jid)
-- @param mediaType the media type for the moderation
function notify_occupants_enable(jid, enable, room, actorJid, mediaType)
local body_json = {};
body_json.type = 'av_moderation';
body_json.enabled = enable;
body_json.room = room.jid;
body_json.actor = actorJid;
body_json.mediaType = mediaType;
local body_json_str = json.encode(body_json);
if jid then
send_json_message(jid, body_json_str)
else
for _, occupant in room:each_occupant() do
send_json_message(occupant.jid, body_json_str)
end
end
end
-- Notifies about a jid added to the whitelist. Notifies all moderators and admin and the jid itself
-- @param jid the jid to notify about the change
-- @param moderators whether to notify all moderators in the room
-- @param room the room where to send it
-- @param mediaType used only when a participant is approved (not sent to moderators)
function notify_whitelist_change(jid, moderators, room, mediaType)
local body_json = {};
body_json.type = 'av_moderation';
body_json.room = room.jid;
body_json.whitelists = room.av_moderation;
local moderators_body_json_str = json.encode(body_json);
body_json.whitelists = nil;
body_json.approved = true; -- we want to send to participants only that they were approved to unmute
body_json.mediaType = mediaType;
local participant_body_json_str = json.encode(body_json);
for _, occupant in room:each_occupant() do
if moderators and occupant.role == 'moderator' then
send_json_message(occupant.jid, moderators_body_json_str);
elseif occupant.jid == jid then
send_json_message(occupant.jid, participant_body_json_str);
end
end
end
-- receives messages from clients to the component sending A/V moderation enable/disable commands or adding
-- jids to the whitelist
function on_message(event)
local session = event.origin;
-- Check the type of the incoming stanza to avoid loops:
if event.stanza.attr.type == 'error' then
return; -- We do not want to reply to these, so leave.
end
if not session or not session.jitsi_web_query_room then
return false;
end
local moderation_command = event.stanza:get_child('av_moderation');
if moderation_command then
-- get room name with tenant and find room
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
if not room then
module:log('warn', 'No room found found for %s/%s',
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
return false;
end
-- check that the participant requesting is a moderator and is an occupant in the room
local from = event.stanza.attr.from;
local occupant = room:get_occupant_by_real_jid(from);
if not occupant then
log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
if occupant.role ~= 'moderator' then
log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
return false;
end
local mediaType = moderation_command.attr.mediaType;
if mediaType then
if mediaType ~= 'audio' and mediaType ~= 'video' then
module:log('warn', 'Wrong mediaType %s for %s', mediaType, room.jid);
return false;
end
else
module:log('warn', 'Missing mediaType for %s', room.jid);
return false;
end
if moderation_command.attr.enable ~= nil then
local enabled;
if moderation_command.attr.enable == 'true' then
enabled = true;
if room.av_moderation and room.av_moderation[mediaType] then
module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
return true;
else
room.av_moderation = {};
room.av_moderation_actors = {};
room.av_moderation[mediaType] = {};
room.av_moderation_actors[mediaType] = occupant.nick;
end
else
enabled = false;
if not room.av_moderation or not room.av_moderation[mediaType] then
module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
return true;
else
room.av_moderation[mediaType] = nil;
room.av_moderation_actors[mediaType] = nil;
-- clears room.av_moderation if empty
local is_empty = false;
for key,_ in pairs(room.av_moderation) do
if room.av_moderation[key] then
is_empty = true;
end
end
if is_empty then
room.av_moderation = nil;
end
end
end
-- send message to all occupants
notify_occupants_enable(nil, enabled, room, occupant.nick, mediaType);
return true;
elseif moderation_command.attr.jidToWhitelist and room.av_moderation then
local occupant_jid = moderation_command.attr.jidToWhitelist;
-- check if jid is in the room, if so add it to whitelist
-- inform all moderators and admins and the jid
local occupant_to_add = room:get_occupant_by_nick(occupant_jid);
if not occupant_to_add then
module:log('warn', 'No occupant %s found for %s', occupant_jid, room.jid);
return false;
end
local whitelist = room.av_moderation[mediaType];
if not whitelist then
whitelist = {};
room.av_moderation[mediaType] = whitelist;
end
table.insert(whitelist, occupant_jid);
notify_whitelist_change(occupant_to_add.jid, true, room, mediaType);
return true;
end
end
-- return error
return false
end
-- handles new occupants to inform them about the state enabled/disabled, new moderators also get and the whitelist
function occupant_joined(event)
local room, occupant = event.room, event.occupant;
if is_healthcheck_room(room.jid) then
return;
end
if room.av_moderation then
for _,mediaType in pairs({'audio', 'video'}) do
if room.av_moderation[mediaType] then
notify_occupants_enable(
occupant.jid, true, room, room.av_moderation_actors[mediaType], mediaType);
end
end
-- NOTE for some reason event.occupant.role is not reflecting the actual occupant role (when changed
-- from allowners module) but iterating over room occupants returns the correct role
for _, room_occupant in room:each_occupant() do
-- if moderator send the whitelist
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
notify_whitelist_change(room_occupant.jid, false, room);
end
end
end
end
-- when a occupant was granted moderator we need to update him with the whitelist
function occupant_affiliation_changed(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
if event.actor and event.affiliation == 'owner' and event.room.av_moderation then
local room = event.room;
-- event.jid is the bare jid of participant
for _, occupant in room:each_occupant() do
if occupant.bare_jid == event.jid then
notify_whitelist_change(occupant.jid, false, room);
end
end
end
end
-- we will receive messages from the clients
module:hook('message/host', on_message);
-- executed on every host added internally in prosody, including components
function process_host(host)
if host == muc_component_host then -- the conference muc component
module:log('info','Hook to muc events on %s', host);
local muc_module = module:context(host);
muc_module:hook('muc-occupant-joined', occupant_joined, -2); -- make sure it runs after allowners or similar
muc_module:hook('muc-set-affiliation', occupant_affiliation_changed, -1);
end
end
if prosody.hosts[muc_component_host] == nil then
module:log('info', 'No muc component found, will listen for it: %s', muc_component_host);
-- when a host or component is added
prosody.events.add_handler('host-activated', process_host);
else
process_host(muc_component_host);
end

View File

@ -1,5 +1,5 @@
local bare = require "util.jid".bare;
local get_room_from_jid = module:require "util".get_room_from_jid;
local get_room_by_name_and_subdomain = module:require "util".get_room_by_name_and_subdomain;
local jid = require "util.jid";
local neturl = require "net.url";
local parse = neturl.parseQuery;
@ -39,21 +39,6 @@ local disableTokenVerification
-- poltergaist management functions
-- Returns the room if available, work and in multidomain mode
-- @param room_name the name of the room
-- @param group name of the group (optional)
-- @return returns room if found or nil
function get_room(room_name, group)
local room_address = jid.join(room_name, module:get_host());
-- if there is a group we are in multidomain mode and that group is not
-- our parent host
if group and group ~= "" and group ~= parentHostName then
room_address = "["..group.."]"..room_address;
end
return get_room_from_jid(room_address);
end
--- Verifies room name, domain name with the values in the token
-- @param token the token we received
-- @param room_name the room name
@ -105,7 +90,7 @@ end
prosody.events.add_handler("pre-jitsi-authentication", function(session)
if (session.jitsi_meet_context_user) then
local room = get_room(
local room = get_room_by_name_and_subdomain(
session.jitsi_web_query_room,
session.jitsi_web_query_prefix);
@ -194,7 +179,7 @@ function handle_create_poltergeist (event)
-- If the provided room conference doesn't exist then we
-- can't add a poltergeist to it.
local room = get_room(room_name, group);
local room = get_room_by_name_and_subdomain(room_name, group);
if (not room) then
log("error", "no room found %s", room_name);
return { status_code = 404; };
@ -257,7 +242,7 @@ function handle_update_poltergeist (event)
return { status_code = 403; };
end
local room = get_room(room_name, group);
local room = get_room_by_name_and_subdomain(room_name, group);
if (not room) then
log("error", "no room found %s", room_name);
return { status_code = 404; };
@ -299,7 +284,7 @@ function handle_remove_poltergeist (event)
return { status_code = 403; };
end
local room = get_room(room_name, group);
local room = get_room_by_name_and_subdomain(room_name, group);
if (not room) then
log("error", "no room found %s", room_name);
return { status_code = 404; };

View File

@ -8,23 +8,19 @@ local http_headers = {
["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")"
};
local muc_domain_prefix
= module:get_option_string("muc_mapper_domain_prefix", "conference");
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
-- defaults to module.host, the module that uses the utility
local muc_domain_base
= module:get_option_string("muc_mapper_domain_base", module.host);
local muc_domain_base = module:get_option_string("muc_mapper_domain_base", module.host);
-- The "real" MUC domain that we are proxying to
local muc_domain = module:get_option_string(
"muc_mapper_domain", muc_domain_prefix.."."..muc_domain_base);
local muc_domain = module:get_option_string("muc_mapper_domain", muc_domain_prefix.."."..muc_domain_base);
local escaped_muc_domain_base = muc_domain_base:gsub("%p", "%%%1");
local escaped_muc_domain_prefix = muc_domain_prefix:gsub("%p", "%%%1");
-- The pattern used to extract the target subdomain
-- (e.g. extract 'foo' from 'conference.foo.example.com')
local target_subdomain_pattern
= "^"..escaped_muc_domain_prefix..".([^%.]+)%."..escaped_muc_domain_base;
local target_subdomain_pattern = "^"..escaped_muc_domain_prefix..".([^%.]+)%."..escaped_muc_domain_base;
-- table to store all incoming iqs without roomname in it, like discoinfo to the muc compoent
local roomless_iqs = {};
@ -120,6 +116,23 @@ function get_room_from_jid(room_jid)
end
end
-- Returns the room if available, work and in multidomain mode
-- @param room_name the name of the room
-- @param group name of the group (optional)
-- @return returns room if found or nil
function get_room_by_name_and_subdomain(room_name, subdomain)
local room_address;
-- if there is a subdomain we are in multidomain mode and that subdomain is not our main host
if subdomain and subdomain ~= "" and subdomain ~= muc_domain_base then
room_address = "["..subdomain.."]"..room_address;
else
room_address = jid.join(room_name, muc_domain);
end
return get_room_from_jid(room_address);
end
function async_handler_wrapper(event, handler)
if not have_async then
module:log("error", "requires a version of Prosody with util.async");
@ -347,6 +360,7 @@ return {
is_feature_allowed = is_feature_allowed;
is_healthcheck_room = is_healthcheck_room;
get_room_from_jid = get_room_from_jid;
get_room_by_name_and_subdomain = get_room_by_name_and_subdomain;
async_handler_wrapper = async_handler_wrapper;
presence_check_status = presence_check_status;
room_jid_match_rewrite = room_jid_match_rewrite;