Files
jitsi-meet/react/features/file-sharing/middleware.web.ts
Saúl Ibarra Corretgé 26423f8e76 fix(file-sharing) rework sync
Rework sync so uploading multiple files at once or several moderators
uploading files simultaneously doesn't break synchronization.

The current room metadata plugin operates on <key,value> pairs and we
were using a generic "files" key and using a nested object as our value.
Since with every operation the entire object is replaced it's easy to
get out of sync because one needs to be sure to have the full state
before overwriting it.

This is not realistic.

We'll look into making the metadata plugin more flexible in order to
support add / delete operations also on nested objects, but for the time
being the following will suffice:

Use a key prefix, so each file has en entry in the room metadata, like
so: "files.<the file ID> -> file metadata". This means that when a file
is deleted we just empty the metadata. The metadata plugin doesn't
currently support removing existing keys.
2025-05-22 12:18:12 +02:00

192 lines
6.7 KiB
TypeScript

import { v4 as uuidv4 } from 'uuid';
import { getCurrentConference } from '../base/conference/functions';
import { getLocalParticipant, getParticipantDisplayName } from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { showErrorNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants';
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 { IFileMetadata } from './types';
/**
* Middleware that handles file sharing actions.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
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.MEDIUM));
};
xhr.open('POST', `${fileSharing!.apiUrl!}/sessions/${sessionId}/files`);
xhr.responseType = 'json';
if (jwt) {
xhr.setRequestHeader('Authorization', `Bearer ${jwt}`);
}
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);
}
case REMOVE_FILE: {
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;
// 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) {
return next(action);
}
// Now delete it from the server.
fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/files/${action.fileId}`, {
method: 'DELETE',
headers: {
...jwt && { 'Authorization': `Bearer ${jwt}` }
}
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to delete file: ${response.statusText}`);
}
})
.catch(error => {
logger.warn('Could not delete file:', error);
});
return next(action);
}
case DOWNLOAD_FILE: {
const state = store.getState();
const { fileSharing } = state['features/base/config'];
const conference = getCurrentConference(state);
const sessionId = conference?.getMeetingUniqueId();
fetch(`${fileSharing!.apiUrl!}/sessions/${sessionId}/document`, {
method: 'GET',
headers: {
'X-File-Id': action.fileId,
}
})
.then(response => response.json())
.then(data => {
const url = data.presignedUrl;
if (!url) {
throw new Error('No presigned URL found in the response.');
}
window.open(url, '_blank', 'noreferrer,noopener');
})
.catch(error => {
logger.warn('Could not download file:', error);
store.dispatch(showErrorNotification({
titleKey: 'fileSharing.downloadFailedTitle',
descriptionKey: 'fileSharing.downloadFailedDescription',
appearance: NOTIFICATION_TYPE.ERROR
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
});
return next(action);
}
}
return next(action);
});