diff --git a/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example b/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example index 7af04711f5..a98abdfcb7 100644 --- a/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example +++ b/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example @@ -148,6 +148,9 @@ Component "endconference.jitmeet.example.com" "end_conference" Component "avmoderation.jitmeet.example.com" "av_moderation_component" muc_component = "conference.jitmeet.example.com" +Component "filesharing.jitmeet.example.com" "filesharing_component" + muc_component = "conference.jitmeet.example.com" + Component "lobby.jitmeet.example.com" "muc" storage = "memory" restrict_room_creation = true diff --git a/react/features/base/conference/reducer.ts b/react/features/base/conference/reducer.ts index c30c824643..2513c77318 100644 --- a/react/features/base/conference/reducer.ts +++ b/react/features/base/conference/reducer.ts @@ -98,6 +98,7 @@ export interface IJitsiConference { end: Function; getBreakoutRooms: Function; getConnection: Function; + getFileSharing: Function; getLocalParticipantProperty: Function; getLocalTracks: Function; getMeetingUniqueId: Function; diff --git a/react/features/file-sharing/actionTypes.ts b/react/features/file-sharing/actionTypes.ts index 0db6f8f814..7432a99191 100644 --- a/react/features/file-sharing/actionTypes.ts +++ b/react/features/file-sharing/actionTypes.ts @@ -30,7 +30,27 @@ export const UPLOAD_FILES = 'UPLOAD_FILES'; export const ADD_FILE = 'ADD_FILE'; /** - * The type of redux action to remove a file from the state. + * The type of redux action to add files to the state. + * + * { + * type: _FILE_LIST_RECEIVED, + * files: Array + * } + */ +export const _FILE_LIST_RECEIVED = '_FILE_LIST_RECEIVED'; + +/** + * The type of redux action to remove a file from the state as it was removed from the backend. + * + * { + * type: _FILE_REMOVED, + * fileId: string + * } + */ +export const _FILE_REMOVED = '_FILE_REMOVED'; + +/** + * The type of redux action to remove a file from the backend. * * { * type: REMOVE_FILE, diff --git a/react/features/file-sharing/actions.ts b/react/features/file-sharing/actions.ts index becffbc5bb..d2af3e75b9 100644 --- a/react/features/file-sharing/actions.ts +++ b/react/features/file-sharing/actions.ts @@ -49,7 +49,7 @@ export function addFile(file: IFileMetadata) { } /** - * Remove a file. + * Remove a file from the backend. * * @param {string} fileId - The ID of the file to remove. * @returns {Object} diff --git a/react/features/file-sharing/constants.ts b/react/features/file-sharing/constants.ts index b9f83a547b..61b030d28e 100644 --- a/react/features/file-sharing/constants.ts +++ b/react/features/file-sharing/constants.ts @@ -1,14 +1,3 @@ -/** - * The key prefix for file sharing metadata in the conference. - */ -export const FILE_SHARING_PREFIX = 'files'; - - -/** - * Used for requesting file sharing service short-term credentials. - */ -export const FILE_SHARING_SERVICE = 'file-sharing'; - /** * The max file size we accept for upload. */ diff --git a/react/features/file-sharing/middleware.web.ts b/react/features/file-sharing/middleware.web.ts index f5ab79d063..01260f0eb9 100644 --- a/react/features/file-sharing/middleware.web.ts +++ b/react/features/file-sharing/middleware.web.ts @@ -2,18 +2,47 @@ import { v4 as uuidv4 } from 'uuid'; import { IStore } from '../app/types'; import { getCurrentConference } from '../base/conference/functions'; +import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; import { getLocalParticipant, getParticipantDisplayName } from '../base/participants/functions'; import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; +import StateListenerRegistry from '../base/redux/StateListenerRegistry'; import { showErrorNotification } from '../notifications/actions'; import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants'; -import { DOWNLOAD_FILE, REMOVE_FILE, UPLOAD_FILES } from './actionTypes'; +import { DOWNLOAD_FILE, REMOVE_FILE, UPLOAD_FILES, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes'; import { addFile, removeFile, updateFileProgress } from './actions'; -import { FILE_SHARING_PREFIX, FILE_SHARING_SERVICE } from './constants'; import { getFileExtension } from './functions.any'; import logger from './logger'; import { IFileMetadata } from './types'; + +/** + * Registers a change handler for state['features/base/conference'].conference to + * set the event listeners needed for the file sharing feature to operate. + */ +StateListenerRegistry.register( + state => state['features/base/conference'].conference, + (conference, { dispatch }, previousConference) => { + if (conference && !previousConference) { + conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_ADDED, (file: IFileMetadata) => { + dispatch(addFile(file)); + }); + conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_REMOVED, (fileId: string) => { + dispatch({ + type: _FILE_REMOVED, + fileId + }); + }); + + conference.on(JitsiConferenceEvents.FILE_SHARING_FILES_RECEIVED, (files: object) => { + dispatch({ + type: _FILE_LIST_RECEIVED, + files + }); + }); + } + }); + /** * Middleware that handles file sharing actions. * @@ -26,7 +55,7 @@ MiddlewareRegistry.register(store => next => action => { const state = store.getState(); const conference = getCurrentConference(state); - conference?.getShortTermCredentials(FILE_SHARING_SERVICE).then((token: string) => { + conference?.getShortTermCredentials(conference?.getFileSharing()?.getIdentityType()).then((token: string) => { for (const file of action.files) { uploadFile(file, store, token); } @@ -38,29 +67,30 @@ MiddlewareRegistry.register(store => next => action => { case REMOVE_FILE: { const state = store.getState(); const conference = getCurrentConference(state); - const { fileSharing } = state['features/base/config']; - const sessionId = conference?.getMeetingUniqueId(); - let doDelete = false; + const { files } = state['features/file-sharing']; + const fileId = action.fileId; + const existingMetadata = files.get(fileId); - // First remove the file metadata so others won't attempt to download it anymore. - const metadataHandler = conference?.getMetadataHandler(); - - if (metadataHandler) { - const metadataId = `${FILE_SHARING_PREFIX}.${action.fileId}`; - const existingMetadata = metadataHandler.getMetadata()[metadataId] ?? {}; - - doDelete = (existingMetadata?.process ?? 100) === 100; - - metadataHandler.setMetadata(metadataId, {}); - } - - if (!doDelete) { + // ignore remove a file till the file is actually uploaded + if (!conference || (existingMetadata?.progress ?? 100) !== 100) { return next(action); } + // First, remove the file metadata so others won't attempt to download it anymore. + conference.getFileSharing().removeFile(fileId); + + // remove it from local state + store.dispatch({ + type: _FILE_REMOVED, + fileId + }); + + const { fileSharing } = state['features/base/config']; + const sessionId = conference.getMeetingUniqueId(); + // Now delete it from the server. - conference?.getShortTermCredentials(FILE_SHARING_SERVICE) - .then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, { + conference.getShortTermCredentials(conference.getFileSharing().getIdentityType()) + .then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${fileId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` @@ -84,7 +114,7 @@ MiddlewareRegistry.register(store => next => action => { const conference = getCurrentConference(state); const sessionId = conference?.getMeetingUniqueId(); - conference?.getShortTermCredentials(FILE_SHARING_SERVICE) + conference?.getShortTermCredentials(conference?.getFileSharing()?.getIdentityType()) .then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, { method: 'GET', headers: { @@ -195,9 +225,9 @@ function uploadFile(file: File, store: IStore, token: string): void { if (xhr.status >= 200 && xhr.status < 300) { store.dispatch(updateFileProgress(fileId, 100)); - const metadataHandler = conference?.getMetadataHandler(); + const fileSharingHandler = conference?.getFileSharing(); - metadataHandler?.setMetadata(`${FILE_SHARING_PREFIX}.${fileId}`, fileMetadata); + fileSharingHandler.addFile(fileMetadata); } else { handleError(); } diff --git a/react/features/file-sharing/reducer.ts b/react/features/file-sharing/reducer.ts index f12f22623d..6a2265685f 100644 --- a/react/features/file-sharing/reducer.ts +++ b/react/features/file-sharing/reducer.ts @@ -1,14 +1,6 @@ -import { isEqual } from 'lodash-es'; - -import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes'; import ReducerRegistry from '../base/redux/ReducerRegistry'; -import { - ADD_FILE, - REMOVE_FILE, - UPDATE_FILE_UPLOAD_PROGRESS -} from './actionTypes'; -import { FILE_SHARING_PREFIX } from './constants'; +import { ADD_FILE, UPDATE_FILE_UPLOAD_PROGRESS, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes'; import { IFileMetadata } from './types'; export interface IFileSharingState { @@ -32,7 +24,7 @@ ReducerRegistry.register('features/file-sharing', }; } - case REMOVE_FILE: { + case _FILE_REMOVED: { const newFiles = new Map(state.files); newFiles.delete(action.fileId); @@ -55,44 +47,9 @@ ReducerRegistry.register('features/file-sharing', }; } - case UPDATE_CONFERENCE_METADATA: { - const { metadata } = action; - - if (!metadata) { - return state; - } - - const files = new Map(); - - for (const [ key, value ] of Object.entries(metadata)) { - if (key.startsWith(FILE_SHARING_PREFIX)) { - const fileId = key.substring(FILE_SHARING_PREFIX.length + 1); - - files.set(fileId, value); - } - } - - if (files.size === 0) { - return state; - } - - const newFiles: Map = new Map(state.files); - - for (const [ key, value ] of files) { - // Deleted files will not have fileId. - if (!value.fileId) { - newFiles.delete(key); - } else { - newFiles.set(key, value); - } - } - - if (isEqual(newFiles, state.files)) { - return state; - } - + case _FILE_LIST_RECEIVED: { return { - files: newFiles + files: new Map(Object.entries(action.files)) }; } diff --git a/resources/prosody-plugins/mod_filesharing_component.lua b/resources/prosody-plugins/mod_filesharing_component.lua new file mode 100644 index 0000000000..ab0360b0d4 --- /dev/null +++ b/resources/prosody-plugins/mod_filesharing_component.lua @@ -0,0 +1,204 @@ +local json = require 'cjson.safe'; +local jid = require 'util.jid'; +local st = require 'util.stanza'; + +local util = module:require 'util'; +local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite; +local is_admin = util.is_admin; +local process_host_module = util.process_host_module; + +local FILE_SHARING_IDENTITY_TYPE = 'file-sharing'; +local JSON_TYPE_ADD_FILE = 'add'; +local JSON_TYPE_REMOVE_FILE = 'remove'; +local JSON_TYPE_LIST_FILES = 'list'; +local NICK_NS = 'http://jabber.org/protocol/nick'; + +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 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, disabling file sharing component."); + return ; +end + +-- receives messages from clients to the component sending file sharing commands for adding or removing files +function on_message(event) + local session, stanza = event.origin, event.stanza; + + -- Check the type of the incoming stanza to avoid loops: + if 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 = stanza:get_child(FILE_SHARING_IDENTITY_TYPE, 'http://jitsi.org/jitmeet'); + + if not message then + return false; + end + + -- 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 for %s/%s', session.jitsi_web_query_prefix, session.jitsi_web_query_room); + return false; + end + + -- check that the participant sending the message is an occupant in the room + local from = 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 + + if not is_feature_allowed( + 'file-upload', + session.jitsi_meet_context_features, + room:get_affiliation(stanza.attr.from) == 'owner') then + session.send(st.error_reply(stanza, 'auth', 'forbidden')); + return true; + end + + if message.attr.type == JSON_TYPE_ADD_FILE then + local msg_obj, error = json.decode(message:get_text()); + + if error then + module:log('error','Error decoding data error:%s %s', error, stanza); + return false; + end + + if not msg_obj.fileId then + module:log('error', 'Error missing required field: %s', stanza); + return false; + end + + -- make sure we overwrite data for sender so we avoid spoofing + msg_obj.authorParticipantId = jid.resource(occupant.nick); + msg_obj.authorParticipantJid = from; + + local nick_element = occupant:get_presence():get_child('nick', NICK_NS); + if nick_element then + msg_obj.authorParticipantName = nick_element:get_text(); + else + msg_obj.authorParticipantName = 'anonymous'; + end + msg_obj.conferenceFullName = internal_room_jid_match_rewrite(room.jid); + + if not room.jitsi_shared_files then + room.jitsi_shared_files = {}; + end + + room.jitsi_shared_files[msg_obj.fileId] = msg_obj; + + local json_msg, error = json.encode({ + type = FILE_SHARING_IDENTITY_TYPE, + event = JSON_TYPE_ADD_FILE, + file = msg_obj + }); + + if not json_msg then + module:log('error', 'skip sending add request room:%s error:%s', room.jid, error); + return false + end + + local stanza = st.message({ from = module.host; }):tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }) + :text(json_msg):up(); + + -- send add file to all occupants except jicofo and sender + for _, room_occupant in room:each_occupant() do + if not is_admin(room_occupant.bare_jid) and room_occupant.nick ~= occupant.nick then + local to_send = st.clone(stanza); + to_send.attr.to = room_occupant.jid; + module:send(to_send); + end + end + + return true; + elseif message.attr.type == JSON_TYPE_REMOVE_FILE then + if not message.attr.fileId then + module:log('error', 'Error missing required field: %s', stanza); + return true; + end + + room.jitsi_shared_files[message.attr.fileId] = nil; + + local json_msg, error = json.encode({ + type = FILE_SHARING_IDENTITY_TYPE, + event = JSON_TYPE_REMOVE_FILE, + fileId = message.attr.fileId + }); + + if not json_msg then + module:log('error', 'skip sending remove request room:%s error:%s', room.jid, error); + return false + end + + local stanza = st.message({ from = module.host; }):tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }) + :text(json_msg):up(); + + -- send remove file to all occupants except jicofo and sender + for _, room_occupant in room:each_occupant() do + if not is_admin(room_occupant.bare_jid) and room_occupant.nick ~= occupant.nick then + local to_send = st.clone(stanza); + to_send.attr.to = room_occupant.jid; + module:send(to_send); + end + end + + return true; + else + -- return error. + return false; + end +end + +-- handles new occupants to inform them about any file shared by other participants +function occupant_joined(event) + local room, occupant = event.room, event.occupant; + + -- healthcheck rooms does not have shared files + if not room.jitsi_shared_files + or is_admin(occupant.bare_jid) + or not room.jitsi_shared_files + or next(room.jitsi_shared_files) == nil then + return; + end + + + -- send file list to the new occupant + local json_msg, error = json.encode({ + type = FILE_SHARING_IDENTITY_TYPE, + event = JSON_TYPE_LIST_FILES, + files = room.jitsi_shared_files + }); + + local stanza = st.message({ from = module.host; to = occupant.jid; }) + :tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }) + :text(json_msg):up(); + + module:send(stanza); +end + +process_host_module(muc_component_host, function(host_module, host) + module:log('info','Hook to muc events on %s', host); + host_module:hook('muc-occupant-joined', occupant_joined, -10); -- make sure it runs after allowners or similar +end); + +-- we will receive messages from the clients +module:hook('message/host', on_message); + +process_host_module(muc_domain_base, function(host_module, host) + module:context(host_module.host):fire_event('jitsi-add-identity', { + name = FILE_SHARING_IDENTITY_TYPE; host = module.host; + }); +end);