diff --git a/react/features/base/conference/reducer.ts b/react/features/base/conference/reducer.ts index 6ec7cc6be0..c30c824643 100644 --- a/react/features/base/conference/reducer.ts +++ b/react/features/base/conference/reducer.ts @@ -107,6 +107,7 @@ export interface IJitsiConference { getParticipantCount: Function; getParticipants: Function; getRole: Function; + getShortTermCredentials: Function; getSpeakerStats: () => ISpeakerStats; getSsrcByTrack: Function; getTranscriptionStatus: Function; diff --git a/react/features/file-sharing/constants.ts b/react/features/file-sharing/constants.ts index bf76b82baa..b9f83a547b 100644 --- a/react/features/file-sharing/constants.ts +++ b/react/features/file-sharing/constants.ts @@ -3,8 +3,13 @@ */ 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. */ export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB - diff --git a/react/features/file-sharing/middleware.web.ts b/react/features/file-sharing/middleware.web.ts index 6f71274622..e0a0bb6813 100644 --- a/react/features/file-sharing/middleware.web.ts +++ b/react/features/file-sharing/middleware.web.ts @@ -10,8 +10,9 @@ import { DOWNLOAD_FILE, REMOVE_FILE, UPLOAD_FILES } from './actionTypes'; import { addFile, removeFile, updateFileProgress } from './actions'; import { getFileExtension } from './functions.any'; import logger from './logger'; -import { FILE_SHARING_PREFIX } from './constants'; +import { FILE_SHARING_PREFIX, FILE_SHARING_SERVICE } from './constants'; import { IFileMetadata } from './types'; +import { IStore } from '../app/types'; /** * Middleware that handles file sharing actions. @@ -24,86 +25,12 @@ MiddlewareRegistry.register(store => next => action => { case UPLOAD_FILES: { const state = store.getState(); const conference = getCurrentConference(state); - const sessionId = conference?.getMeetingUniqueId(); - const localParticipant = getLocalParticipant(state); - const { fileSharing } = state['features/base/config']; - const { connection } = state['features/base/connection']; - const { jwt } = state['features/base/jwt']; - const roomJid = conference?.room?.roomjid; - for (const file of action.files) { - const jid = connection!.getJid(); - const fileId = uuidv4(); - const fileMetadata: IFileMetadata = { - authorParticipantId: localParticipant!.id, - authorParticipantJid: jid, - authorParticipantName: getParticipantDisplayName(state, localParticipant!.id), - conferenceFullName: roomJid ?? '', - fileId, - fileName: file.name, - fileSize: file.size, - fileType: getFileExtension(file.name), - timestamp: Date.now() - }; - - store.dispatch(addFile(fileMetadata)); - store.dispatch(updateFileProgress(fileId, 1)); - - // Upload file. - const formData = new FormData(); - - formData.append('metadata', JSON.stringify(fileMetadata)); - - // @ts-ignore - formData.append('file', file as Blob, file.name); - - // Use XMLHttpRequest to track upload - const xhr = new XMLHttpRequest(); - - const handleError = () => { - logger.warn('Could not upload file:', xhr.statusText); - - store.dispatch(removeFile(fileId)); - store.dispatch(showErrorNotification({ - titleKey: 'fileSharing.uploadFailedTitle', - descriptionKey: 'fileSharing.uploadFailedDescription', - appearance: NOTIFICATION_TYPE.ERROR - }, NOTIFICATION_TIMEOUT_TYPE.STICKY)); - }; - - xhr.open('POST', `${fileSharing!.apiUrl!}/sessions/${sessionId}/files`); - xhr.responseType = 'json'; - - if (jwt) { - xhr.setRequestHeader('Authorization', `Bearer ${jwt}`); + conference?.getShortTermCredentials(FILE_SHARING_SERVICE).then((token: string) => { + for (const file of action.files) { + uploadFile(file, store, token); } - - xhr.upload.onprogress = event => { - if (event.lengthComputable) { - // We use 99% as the max value to avoid showing 100% before the - // upload is actually finished, that is, when the request is completed. - const percent = Math.max((event.loaded / event.total) * 100, 99); - - store.dispatch(updateFileProgress(fileId, percent)); - } - }; - - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - store.dispatch(updateFileProgress(fileId, 100)); - - const metadataHandler = conference?.getMetadataHandler(); - - metadataHandler?.setMetadata(`${FILE_SHARING_PREFIX}.${fileId}`, fileMetadata); - } else { - handleError(); - } - }; - - xhr.onerror = handleError; - - xhr.send(formData); - } + }); return next(action); } @@ -112,7 +39,6 @@ MiddlewareRegistry.register(store => next => action => { const state = store.getState(); const conference = getCurrentConference(state); const { fileSharing } = state['features/base/config']; - const { jwt } = state['features/base/jwt']; const sessionId = conference?.getMeetingUniqueId(); let doDelete = false; @@ -133,18 +59,19 @@ MiddlewareRegistry.register(store => next => action => { } // Now delete it from the server. - fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, { + conference?.getShortTermCredentials(FILE_SHARING_SERVICE) + .then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, { method: 'DELETE', headers: { - ...jwt && { 'Authorization': `Bearer ${jwt}` } + 'Authorization': `Bearer ${token}` } - }) - .then(response => { + })) + .then((response: { ok: any; statusText: any; }) => { if (!response.ok) { throw new Error(`Failed to delete file: ${response.statusText}`); } }) - .catch(error => { + .catch((error: any) => { logger.warn('Could not delete file:', error); }); @@ -157,14 +84,15 @@ MiddlewareRegistry.register(store => next => action => { const conference = getCurrentConference(state); const sessionId = conference?.getMeetingUniqueId(); - fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/document`, { + conference?.getShortTermCredentials(FILE_SHARING_SERVICE) + .then((token: string) => fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, { method: 'GET', headers: { - 'X-File-Id': action.fileId, + 'Authorization': `Bearer ${token}` } - }) - .then(response => response.json()) - .then(data => { + })) + .then((response: any) => response.json()) + .then((data: { presignedUrl: any; }) => { const url = data.presignedUrl; if (!url) { @@ -173,7 +101,7 @@ MiddlewareRegistry.register(store => next => action => { window.open(url, '_blank', 'noreferrer,noopener'); }) - .catch(error => { + .catch((error: any) => { logger.warn('Could not download file:', error); store.dispatch(showErrorNotification({ @@ -189,3 +117,93 @@ MiddlewareRegistry.register(store => next => action => { return next(action); }); + +/** + * Uploads a file to the server. + * + * @param {File} file - The file to upload. + * @param {IStore} store - The redux store. + * @param {string} token - The token to use for requests. + * @returns {void} + */ +function uploadFile(file: File, store: IStore, token: string): void { + const state = store.getState(); + const conference = getCurrentConference(state); + const sessionId = conference?.getMeetingUniqueId(); + const localParticipant = getLocalParticipant(state); + const { fileSharing } = state['features/base/config']; + const { connection } = state['features/base/connection']; + const roomJid = conference?.room?.roomjid; + + const jid = connection!.getJid(); + const fileId = uuidv4(); + const fileMetadata: IFileMetadata = { + authorParticipantId: localParticipant!.id, + authorParticipantJid: jid, + authorParticipantName: getParticipantDisplayName(state, localParticipant!.id), + conferenceFullName: roomJid ?? '', + fileId, + fileName: file.name, + fileSize: file.size, + fileType: getFileExtension(file.name), + timestamp: Date.now() + }; + + store.dispatch(addFile(fileMetadata)); + store.dispatch(updateFileProgress(fileId, 1)); + + // Upload file. + const formData = new FormData(); + + formData.append('metadata', JSON.stringify(fileMetadata)); + + // @ts-ignore + formData.append('file', file as Blob, file.name); + + // Use XMLHttpRequest to track upload + const xhr = new XMLHttpRequest(); + + const handleError = () => { + logger.warn('Could not upload file:', xhr.statusText); + + store.dispatch(removeFile(fileId)); + store.dispatch(showErrorNotification({ + titleKey: 'fileSharing.uploadFailedTitle', + descriptionKey: 'fileSharing.uploadFailedDescription', + appearance: NOTIFICATION_TYPE.ERROR + }, NOTIFICATION_TIMEOUT_TYPE.STICKY)); + }; + + xhr.open('POST', `${fileSharing!.apiUrl!}/sessions/${sessionId}/files`); + xhr.responseType = 'json'; + + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + + xhr.upload.onprogress = event => { + if (event.lengthComputable) { + // We use 99% as the max value to avoid showing 100% before the + // upload is actually finished, that is, when the request is completed. + const percent = Math.max((event.loaded / event.total) * 100, 99); + + store.dispatch(updateFileProgress(fileId, percent)); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + store.dispatch(updateFileProgress(fileId, 100)); + + const metadataHandler = conference?.getMetadataHandler(); + + metadataHandler?.setMetadata(`${FILE_SHARING_PREFIX}.${fileId}`, fileMetadata); + } else { + handleError(); + } + }; + + xhr.onerror = handleError; + + xhr.send(formData); +} diff --git a/resources/file-sharing.yaml b/resources/file-sharing.yaml index 102f4b1c42..233264e953 100644 --- a/resources/file-sharing.yaml +++ b/resources/file-sharing.yaml @@ -10,13 +10,7 @@ security: - HttpBearerKey: [] tags: - name: Content sharing history - description: crud operation for screenshots - - name: Webhook controller - description: listens for room destroy events and generates screenshots webhooks and return jaas sign links - - name: Document sharing history description: crud operation for shared documents - - name: JaaS controller - description: Management of jaas screenshots paths: /v1/documents/sessions/{sessionId}/files: get: @@ -58,11 +52,12 @@ paths: tags: - Document sharing history summary: Save an document and metadata - description: Add a document to a meeting - allowed only to moderators if any + description: Add a document to a meeting - allowed only to those with file-sharing feature if any operationId: addDocumentInMeeting parameters: - name: sessionId in: path + description: The session ID of the meeting. required: true schema: type: string @@ -77,9 +72,11 @@ paths: properties: metadata: type: string - example: '{"conferenceFullName":"myroomname@conference.tenant.jitsi-meet.example.com","timestamp":1741017572040,"fileSize":1042157,"fileId":"e393a7e5-e790-4f43-836e-d27238201904","authorParticipantJid":"9b9a25dc-7e1a-46b8-bae1-ad8be604871c@jitsi-meet.example.com/SVBypbHvcgRT"}' + description: The metadata of the document in JSON format. + example: '{"conferenceFullName":"myroomname@conference.tenant.jitsi-meet.example.com","timestamp":1741017572040,"fileSize":1042157,"fileId":"e393a7e5-e790-4f43-836e-d27238201904"}' file: type: string + description: The file to be uploaded. format: binary responses: '200': @@ -114,21 +111,22 @@ paths: responses: '204': description: No Content - /v1/documents/sessions/{sessionId}/document: + /v1/documents/sessions/{sessionId}/files/{fileId}: get: tags: - Document sharing history summary: Get file pre-signed URL plus document info description: Used by UI to get the presign url for the file before serving it to the user who needs it - operationId: getFileInfo + operationId: getDocumentInfoDuringMeeting parameters: - name: sessionId in: path required: true schema: type: string - - name: X-File-Id - in: header + - name: fileId + in: path + description: The file ID to be deleted. required: true schema: type: string @@ -139,7 +137,6 @@ paths: '*/*': schema: $ref: '#/components/schemas/DocumentMetadataResponse' - /v1/documents/sessions/{sessionId}/files/{fileId}: delete: tags: - Document sharing history @@ -149,11 +146,14 @@ paths: parameters: - name: sessionId in: path + description: The session ID of the meeting. required: true schema: type: string + example: 86bf35e2-62a5-497e-9cae-efd35139f81f - name: fileId in: path + description: The file ID to be deleted. required: true schema: type: string