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,591 @@
-- mod_room_metadata_component.lua
--
-- Prosody component that provides a generic key-value metadata store for MUC
-- rooms. Metadata is held in room.jitsiMetadata (a Lua table) and broadcast
-- to all occupants as a JSON <json-message> stanza whenever it changes.
--
-- ── Metadata updates (client → component) ────────────────────────────────────
-- Clients send a <message> to this component containing a <room_metadata
-- xmlns='http://jitsi.org/jitmeet'> child whose text is JSON:
--
-- { "key": "<field-name>", "data": <any JSON value> }
--
-- Authorization is a three-step process (on_message):
-- 1. The sender must be an occupant of the room identified by the session's
-- jitsi_web_query_room field (set from the WebSocket ?room= URL param).
-- 2. The 'jitsi-metadata-allow-moderation' event is fired on the main virtual
-- host so that other modules can override the default access rules:
-- · returns false → deny the update unconditionally
-- · returns non-nil → allow the update and use the returned value as data
-- · returns nil → fall through to the default moderator-only check
-- 3. Default: the occupant must have role='moderator'.
--
-- If authorized and the value changed, room.jitsiMetadata[key] is updated and
-- the full metadata table is broadcast to every occupant. A
-- 'jitsi-metadata-updated' event is also fired on the main MUC module so that
-- other modules can react to specific key changes.
--
-- ── Metadata delivery (component → client) ────────────────────────────────────
-- Initial delivery: a stanza filter intercepts the self-presence (status 110)
-- about to be sent to a newly-joining occupant and, before it goes out, pushes
-- the current metadata to that client. TURN credentials (from external_services)
-- are included in this initial push. room.sent_initial_metadata[bare_jid] is
-- set to prevent double-delivery on admin reconnects.
--
-- Broadcast: broadcastMetadata() / send_metadata() send a <json-message> to
-- every occupant. Admins (jicofo) additionally receive the room's participant
-- and moderator lists from room._data, plus the transcription HTTP headers.
-- Those headers may carry secrets (e.g. a custom OpenAI API key), so they are
-- stripped from the payload sent to regular occupants (and redacted in logs).
--
-- ── Internal metadata changes ─────────────────────────────────────────────────
-- Other modules update room.jitsiMetadata directly and then fire
-- 'room-metadata-changed' on the MUC host to trigger a broadcast.
--
-- ── Legacy startMuted shim ────────────────────────────────────────────────────
-- When a moderator sends a presence containing <startmuted> (old client API),
-- the values are copied into room.jitsiMetadata.startMuted and a
-- 'room-metadata-changed' broadcast is triggered. This preserves backward
-- compatibility until all clients switch to the metadata API.
--
-- ── TURN / extdisco gating (optional) ────────────────────────────────────────
-- When extdisco_occpuant_check=true, extdisco IQ-get requests are intercepted
-- at priority 100 so that only room occupants can obtain TURN credentials.
--
-- Component "metadata.jitmeet.example.com" "room_metadata_component"
-- muc_component = "conference.jitmeet.example.com"
-- breakout_rooms_component = "breakout.jitmeet.example.com"
local array = require 'util.array';
local filters = require 'util.filters';
local jid_node = require 'util.jid'.node;
local json = require 'util.json';
local st = require 'util.stanza';
local jid = require 'util.jid';
local util = module:require 'util';
local is_admin = util.is_admin;
local is_healthcheck_room = util.is_healthcheck_room;
local get_room_from_jid = util.get_room_from_jid;
local room_jid_match_rewrite = util.room_jid_match_rewrite;
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
local process_host_module = util.process_host_module;
local table_shallow_copy = util.table_shallow_copy;
local table_add = util.table_add;
local table_equals = util.table_equals;
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
local dt = require "util.datetime";
local ext_services = module:depends("external_services");
local get_services = ext_services.get_services;
local MUC_NS = 'http://jabber.org/protocol/muc';
local COMPONENT_IDENTITY_TYPE = 'room_metadata';
local FORM_KEY = 'muc#roominfo_jitsimetadata';
local muc_component_host = module:get_option_string('muc_component');
if muc_component_host == nil then
module:log('error', 'No muc_component specified. No muc to operate on!');
return;
end
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
if not main_virtual_host then
module:log('warn', 'No muc_mapper_domain_base option set.');
return;
end
local breakout_rooms_component_host = module:get_option_string('breakout_rooms_component');
-- TODO: flip default once mobile clients update to latest ljm that supports transition of turn data in metadata
local extdisco_occpuant_check = module:get_option_boolean('extdisco_occpuant_check', false);
-- Keys that clients (including moderators) are not allowed to set via the
-- metadata message API. These are either server-controlled fields or keys whose
-- values are assembled and injected by the server at broadcast time.
local blocked_metadata_keys = module:get_option_set('room_metadata_blocked_keys', {
'allownersEnabled',
'asyncTranscription',
'audioTranslationAvailable',
'audioTranslationListenerCounts',
'audioTranslationRequests',
'conferencePresetsServiceEnabled',
'dialinEnabled',
'moderators',
'participants',
'participantsSoftLimit',
'services',
'transcriberType',
'transcription',
'visitorsEnabled',
});
module:log("info", "Starting room metadata for %s", muc_component_host);
local main_muc_module;
-- Utility functions
-- Returns json string with the metadata for the room.
-- @param room The room object.
-- @param metadata Optional metadata to use instead of the room's jitsiMetadata.
function getMetadataJSON(room, metadata)
local res, error = json.encode({
type = COMPONENT_IDENTITY_TYPE,
metadata = metadata or room.jitsiMetadata or {}
});
if not res then
module:log('error', 'Error encoding data room:%s', room.jid, error);
end
return res;
end
-- Returns a shallow copy of the room metadata with jicofo-only fields removed,
-- suitable for sending to regular (non-admin) occupants. Currently this strips
-- transcription.httpHeaders, which may carry secrets (e.g. a custom OpenAI API
-- key) meant only for jicofo and the transcriber.
function get_client_metadata(room)
local metadata = table_shallow_copy(room.jitsiMetadata) or {};
if metadata.transcription and metadata.transcription.httpHeaders then
local transcription_copy = table_shallow_copy(metadata.transcription);
transcription_copy.httpHeaders = nil;
metadata.transcription = transcription_copy;
end
return metadata;
end
function broadcastMetadata(room)
-- Build the client-safe payload once and reuse it for every non-admin
-- occupant; admins get a freshly assembled payload in send_metadata.
local client_json = getMetadataJSON(room, get_client_metadata(room));
if not client_json then
return;
end
for _, occupant in room:each_occupant() do
send_metadata(occupant, room, client_json)
end
end
function send_metadata(occupant, room, json_msg, include_services)
if not json_msg or is_admin(occupant.bare_jid) then
local metadata_to_send;
if is_admin(occupant.bare_jid) then
-- jicofo gets the full metadata (including transcription httpHeaders)
-- plus the main meeting participant and moderator lists.
metadata_to_send = table_shallow_copy(room.jitsiMetadata) or {};
local participants;
local moderators = array();
if room._data.participants then
participants = array();
participants:append(room._data.participants);
end
if room._data.moderators then
moderators:append(room._data.moderators);
end
metadata_to_send.participants = participants;
metadata_to_send.moderators = moderators;
-- The aggregated live-translation request map is for jicofo only.
-- It is kept on room._data (never in jitsiMetadata) so it is never
-- broadcast to regular clients.
metadata_to_send.audioTranslationRequests = room._data.audioTranslationRequests;
-- Neutral, default-open extension point: an external module may contribute
-- additional jicofo-only metadata fields by returning a table from this
-- event -- for instance per-room translator connect headers. Fired inside
-- the admin branch, so injected fields reach only jicofo and are never
-- broadcast to client occupants. No handler means nothing is added; this
-- module holds no token/entitlement logic of its own.
local admin_extra = main_muc_module and main_muc_module:fire_event(
'jitsi-room-metadata-admin-extra', { room = room; });
if type(admin_extra) == 'table' then
for k, v in pairs(admin_extra) do
metadata_to_send[k] = v;
end
end
module:log('info', 'Sending metadata to jicofo room=%s,meeting_id=%s', room.jid, room._data.meetingId);
else
-- regular occupants get a sanitized copy (jicofo-only fields removed)
metadata_to_send = get_client_metadata(room);
if include_services then
metadata_to_send.services = {};
for _, srv in ipairs(get_services()) do
table.insert(metadata_to_send.services, {
type = srv.type;
transport = srv.transport;
host = srv.host;
port = srv.port and string.format('%d', srv.port) or nil;
username = srv.username;
password = srv.password;
expires = srv.expires and dt.datetime(srv.expires) or nil;
restricted = srv.restricted and '1' or nil;
});
end
end
end
json_msg = getMetadataJSON(room, metadata_to_send);
end
local stanza = st.message({ from = module.host; to = occupant.jid; })
:tag('json-message', {
xmlns = 'http://jitsi.org/jitmeet',
room = internal_room_jid_match_rewrite(room.jid)
}):text(json_msg):up();
module:send(stanza);
end
-- Handling events
function room_created(event)
local room = event.room;
if is_healthcheck_room(room.jid) then
return;
end
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
room.sent_initial_metadata = {};
end
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 message = event.stanza:get_child(COMPONENT_IDENTITY_TYPE, 'http://jitsi.org/jitmeet');
if not message then
return true;
end
local messageText = message:get_text();
if not messageText or messageText == '' then
return true;
end
local roomJid = message.attr.room;
if not roomJid then
return true;
end
local room = get_room_from_jid(room_jid_match_rewrite(roomJid));
if not room then
module:log('warn', 'No room 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
module:log('warn', 'No occupant %s found for %s', from, room.jid);
return false;
end
local jsonData, error = json.decode(messageText);
if jsonData == nil then -- invalid JSON
module:log("error", "Invalid JSON message: %s error:%s", messageText, error);
return false;
end
if type(jsonData.key) ~= 'string' then
module:log('error', 'Invalid JSON payload, key is not a string: %s', messageText);
return true;
end
if jsonData.key == nil or jsonData.data == nil or jsonData.data == json.null then
module:log("error", "Invalid JSON payload, key or data are missing: %s", messageText);
return false;
end
-- Fire 'jitsi-metadata-allow-moderation' so other modules can override the
-- default moderator-only access control on a per-key basis.
-- false → deny the update unconditionally (e.g. key restricted to server)
-- non-nil → allow the update; the returned value replaces jsonData.data
-- (lets a hook sanitise or filter the payload before it is stored)
-- nil → no opinion; fall through to the default moderator-only check
local res = module:context(main_virtual_host):fire_event('jitsi-metadata-allow-moderation',
{ room = room; actor = occupant; key = jsonData.key ; data = jsonData.data; session = session; });
if res == false then
module:log('warn', 'Occupant %s features do not allow this operation(%s) for %s', from, jsonData.key, room.jid);
return false;
elseif res ~= nil then
jsonData.data = res;
else
if occupant.role ~= 'moderator' then
module:log('warn', 'Occupant %s is not moderator and not allowed this operation(%s) for %s',
from, jsonData.key, room.jid);
return false;
end
end
if blocked_metadata_keys:contains(jsonData.key) then
module:log('warn', 'Occupant %s attempted to set blocked metadata key "%s" in room:%s',
from, jsonData.key, room.jid);
return false;
end
local old_value = room.jitsiMetadata[jsonData.key];
if not table_equals(old_value, jsonData.data) then
room.jitsiMetadata[jsonData.key] = jsonData.data;
module:log('info', 'Metadata key "%s" updated by %s in room:%s,meeting_id:%s', jsonData.key, from, room.jid, room._data.meetingId);
broadcastMetadata(room);
-- fire and event for the change
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
end
return true;
end
-- Module operations
-- handle messages to this component
module:hook("message/host", on_message);
-- operates on already loaded main muc module
function process_main_muc_loaded(main_muc, host_module)
main_muc_module = host_module;
module:log('debug', 'Main muc loaded');
module:log("info", "Hook to muc events on %s", muc_component_host);
host_module:hook("muc-room-created", room_created, -1);
-- The room metadata was updated internally (from another module).
host_module:hook("room-metadata-changed", function(event)
local room = event.room;
local json_msg = getMetadataJSON(room);
local log_json = json_msg;
if room.jitsiMetadata and room.jitsiMetadata.transcription
and room.jitsiMetadata.transcription.httpHeaders then
local metadata_copy = table_shallow_copy(room.jitsiMetadata);
local transcription_copy = table_shallow_copy(metadata_copy.transcription);
local headers_redacted = {};
for k, _ in pairs(transcription_copy.httpHeaders) do
headers_redacted[k] = '***';
end
transcription_copy.httpHeaders = headers_redacted;
metadata_copy.transcription = transcription_copy;
log_json = getMetadataJSON(room, metadata_copy) or log_json;
end
module:log('info', 'Metadata changed internally in room:%s,meeting_id:%s - broadcasting data:%s', room.jid, room._data.meetingId, log_json);
broadcastMetadata(room);
end);
-- TODO: Once clients update to read/write metadata for startMuted policy we can drop this
-- this is to convert presence settings from old clients to metadata
host_module:hook('muc-broadcast-presence', function (event)
local actor, occupant, room, stanza, x = event.actor, event.occupant, event.room, event.stanza, event.x;
if is_healthcheck_room(room.jid) or occupant.role ~= 'moderator' then
return;
end
local startMuted = stanza:get_child('startmuted', 'http://jitsi.org/jitmeet/start-muted');
if not startMuted then
return;
end
if not room.jitsiMetadata then
room.jitsiMetadata = {};
end
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
local audioNewValue = startMuted.attr.audio == 'true';
local videoNewValue = startMuted.attr.video == 'true';
local send_update = false;
if startMutedMetadata.audio ~= audioNewValue then
startMutedMetadata.audio = audioNewValue;
send_update = true;
end
if startMutedMetadata.video ~= videoNewValue then
startMutedMetadata.video = videoNewValue;
send_update = true;
end
if send_update then
room.jitsiMetadata.startMuted = startMutedMetadata;
host_module:fire_event('room-metadata-changed', { room = room; });
end
end);
-- The the connection jid for authenticated users (like jicofo) stays the same,
-- so leaving and re-joining will result not sending metatadata again.
-- Make sure we clear the sent_initial_metadata entry for the occupant on leave.
host_module:hook("muc-occupant-left", function(event)
local room, occupant = event.room, event.occupant;
if room.sent_initial_metadata then
room.sent_initial_metadata[jid.bare(event.occupant.jid)] = nil;
end
end);
end
-- process or waits to process the main muc component
process_host_module(muc_component_host, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_main_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
-- breakout rooms support
function process_breakout_muc_loaded(breakout_muc, host_module)
module:log('debug', 'Breakout rooms muc loaded');
module:log("info", "Hook to muc events on %s", breakout_rooms_component_host);
host_module:hook("muc-room-created", room_created, -1);
end
if breakout_rooms_component_host then
process_host_module(breakout_rooms_component_host, function(host_module, host)
local muc_module = prosody.hosts[host].modules.muc;
if muc_module then
process_breakout_muc_loaded(muc_module, host_module);
else
module:log('debug', 'Will wait for muc to be available');
prosody.hosts[host].events.add_handler('module-loaded', function(event)
if (event.module == 'muc') then
process_breakout_muc_loaded(prosody.hosts[host].modules.muc, host_module);
end
end);
end
end);
end
-- checks whether the event is from an occupant of the room specified in the session,
-- if not, it returns an error and stops the processing of the event
local function check_occupant(event)
local origin, stanza = event.origin, event.stanza;
local room_name = origin.jitsi_web_query_room;
if not room_name then
module:log('warn', 'No room in session: %s', origin.full_jid);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
local subdomain = origin.jitsi_web_query_prefix or '';
local room = get_room_by_name_and_subdomain(room_name, subdomain);
if not room then
module:log('warn', 'Room not found (%s/%s) for %s', subdomain, room_name, origin.full_jid);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
local from = stanza.attr.from or origin.full_jid;
local occupant = get_occupant_by_real_jid(room, from);
if not occupant then
module:log('warn', '%s not an occupant of %s/%s', from, subdomain, room_name);
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
return true;
end
end
-- Send a message update for metadata before sending the first self presence
function filter_stanza(stanza, session)
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence' or stanza.attr.type == 'unavailable' then
return stanza;
end
local bare_to = jid.bare(stanza.attr.to);
local muc_x = stanza:get_child('x', MUC_NS..'#user');
if not muc_x or not presence_check_status(muc_x, '110') then
return stanza;
end
local room = get_room_from_jid(room_jid_match_rewrite(jid.bare(stanza.attr.from)));
if not room or not room.sent_initial_metadata or is_healthcheck_room(room.jid) then
return stanza;
end
if room.sent_initial_metadata[bare_to] then
return stanza;
end
local occupant;
for _, o in room:each_occupant() do
if o.bare_jid == bare_to then
occupant = o;
end
end
if not occupant then
module:log('warn', 'No occupant %s found for %s', bare_to, room.jid);
return stanza;
end
room.sent_initial_metadata[bare_to] = true;
send_metadata(occupant, room, nil, true);
return stanza;
end
function filter_session(session)
-- domain mapper is filtering on default priority 0
-- allowners is -1 and we need it after that, permissions is -2
filters.add_filter(session, 'stanzas/out', filter_stanza, -3);
end
-- enable filtering presences
filters.add_filter_hook(filter_session);
process_host_module(main_virtual_host, function(host_module)
local main_host_module = module:context(host_module.host);
main_host_module:fire_event('jitsi-add-identity', {
name = 'room_metadata'; host = module.host;
});
if extdisco_occpuant_check then
-- Hook at priority 100 so we run before mod_external_services (default priority 0) for both XEP-0215 v2 and legacy v1.
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:2:services', check_occupant, 100);
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:2:service', check_occupant, 100);
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:1:services', check_occupant, 100);
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:1:service', check_occupant, 100);
end
end);