diff --git a/server/index.js b/server/index.js index a4b19e310..6f42d820a 100644 --- a/server/index.js +++ b/server/index.js @@ -580,24 +580,40 @@ function logger(type, message) { console.log(`${currentDate} [${type}] ${message}`); } +const clients = new Set(); + +// Used to broadcast messages to all connected clients +function broadcastToClients(message){ + const payload = JSON.stringify(message); + clients.forEach((client) => { + if(client.readyState === WebSocket.OPEN){ + logger('INFO', `Payload sent to the client`); + client.send(payload); + } + }); +} + wss.on('connection', (ws) => { logger('INFO', `A client connected to the web socket server.`); + clients.add(ws); // The following messages can be received in stringified JSON format: // { uuid: , lock: true } // { uuid: , lock: false } ws.on('message', function (msg) { const message = JSON.parse(msg); - const uuid = message.uuid; + const {uuid, lock} = message; + if (!uuid) { ws.send(JSON.stringify({ status: 'fail', message: 'UUID not provided.' })); } + logger('INFO', `${msg}`); // User wants to lock storyline since they are about to load/edit it. - if (message.lock) { - // Unlock any storyline that the user was previously locking. - delete lockedUuids[ws.uuid]; + if (lock) { + const currentLock = lockedUuids[uuid]; + // Someone else is currently accessing this storyline, do not allow the user to lock! - if (!!lockedUuids[uuid] && ws.uuid !== uuid) { + if (currentLock && ws.uuid !== uuid) { logger('INFO', `A client failed to lock the storyline ${uuid}.`); ws.send(JSON.stringify({ status: 'fail', message: 'Another user has locked this storyline.' })); } @@ -610,6 +626,11 @@ wss.on('connection', (ws) => { lockedUuids[uuid] = secret; ws.uuid = uuid; ws.send(JSON.stringify({ status: 'success', secret })); + + broadcastToClients({ + type:'lock', + uuid, + }); } } else { // Attempting to unlock a different storyline, other than the one this connection has locked, so do not allow. @@ -625,9 +646,14 @@ wss.on('connection', (ws) => { // Unlock the storyline for any other user/connection to use. else { logger('INFO', `A client successfully unlocked the storyline ${uuid}.`); - delete ws.uuid; delete lockedUuids[uuid]; + delete ws.uuid; ws.send(JSON.stringify({ status: 'success' })); + + broadcastToClients({ + type:'unlock', + uuid, + }); } } }); @@ -636,9 +662,18 @@ wss.on('connection', (ws) => { logger('INFO', `Client connection with web socket server has closed.`); // Connection was closed, unlock this user's locked storyline if (ws.uuid) { - delete lockedUuids[ws.uuid]; - delete ws.uuid; + const currentLock = lockedUuids[ws.uuid]; + if (currentLock) { + logger('INFO', `Releasing lock on storyline ${ws.uuid} after connection closed`); + delete lockedUuids[ws.uuid]; + broadcastToClients({ + type: 'unlock', + uuid: ws.uuid, + }); + } } + + clients.delete(ws); }); }); diff --git a/src/components/metadata-editor.vue b/src/components/metadata-editor.vue index 445b412e1..9411dded7 100644 --- a/src/components/metadata-editor.vue +++ b/src/components/metadata-editor.vue @@ -594,6 +594,7 @@ import { import { VueSpinnerOval } from 'vue3-spinners'; import { VueFinalModal } from 'vue-final-modal'; import { useUserStore } from '../stores/userStore'; +import { computed } from "vue"; import JSZip from 'jszip'; import axios from 'axios'; @@ -646,7 +647,7 @@ export default class MetadataEditorV extends Vue { @Prop({ default: true }) editExisting!: boolean; // true if editing existing storylines product, false if new product currentRoute = window.location.href; - + user = computed(() => useUserStore().userProfile.userName || 'Guest'); configs: { [key: string]: StoryRampConfig | undefined; } = { en: undefined, fr: undefined }; @@ -751,7 +752,7 @@ export default class MetadataEditorV extends Vue { // Initialize Storylines config and the configuration structure. this.configs = { en: undefined, fr: undefined }; this.configFileStructure = undefined; - + // set any metadata default values for creating new product if (!this.loadExisting) { // set current date as default @@ -849,7 +850,15 @@ export default class MetadataEditorV extends Vue { // If a product UUID is provided, fetch the contents from the server. if (this.$route.params.uid) { - this.generateRemoteConfig(); + this.generateRemoteConfig().catch(() => { + // Handle any connection/lock errors here + Message.error(this.$t('editor.editMetadata.message.error.unauthorized')); + if (this.$route.name === 'editor') { + setTimeout(() => { + this.$router.push({ name: 'home' }); + }, 2000); + } + }); } } @@ -1017,10 +1026,9 @@ export default class MetadataEditorV extends Vue { this.controller = new AbortController(); this.loadStatus = 'loading'; - const user = useUserStore().userProfile.userName || 'Guest'; const secret = this.lockStore.secret; fetch(this.apiUrl + `/retrieve/${this.uuid}/${version}`, { - headers: { user, secret: secret }, + headers: { user: this.user, secret: secret }, signal: this.controller.signal }) .then((res: Response) => { @@ -1143,9 +1151,8 @@ export default class MetadataEditorV extends Vue { return; } this.loadStatus = 'loading'; - const user = useUserStore().userProfile.userName || 'Guest'; const secret = this.lockStore.secret; - fetch(this.apiUrl + `/history/${this.uuid}`, { headers: { user, secret } }).then((res: Response) => { + fetch(this.apiUrl + `/history/${this.uuid}`, { headers: { user: this.user, secret } }).then((res: Response) => { if (res.status === 404) { // Product not found. this.loadStatus = 'waiting'; @@ -1210,7 +1217,7 @@ export default class MetadataEditorV extends Vue { // First, hit the Express server `rename` endpoint to perform the `rename` syscall on the file system. await axios .post(this.apiUrl + `/rename`, { - user: userStore.userProfile.userName || 'Guest', + user: this.user, previousUuid: prevUuid, newUuid: this.changeUuid, configs: { en: convertedEnglish, fr: convertedFrench } @@ -1631,10 +1638,9 @@ export default class MetadataEditorV extends Vue { this.configFileStructure?.zip.generateAsync({ type: 'blob' }).then((content: Blob) => { const formData = new FormData(); formData.append('data', content, `${this.uuid}.zip`); - const userStore = useUserStore(); const headers = { 'Content-Type': 'multipart/form-data', - user: userStore.userProfile.userName || 'Guest', + user: this.user, secret: this.lockStore.secret }; Message.warning(this.$t('editor.editMetadata.message.wait')); diff --git a/src/stores/lockStore.ts b/src/stores/lockStore.ts index 4fb6fb80e..234fb0d43 100644 --- a/src/stores/lockStore.ts +++ b/src/stores/lockStore.ts @@ -12,59 +12,67 @@ export const useLockStore = defineStore('lock', { result: {} as any, broadcast: undefined as BroadcastChannel | undefined, confirmationTimeout: undefined as NodeJS.Timeout | undefined, // the timer to show the session extension confirmation modal - endTimeout: undefined as NodeJS.Timeout | undefined // the timer to kill the session due to timeout + endTimeout: undefined as NodeJS.Timeout | undefined, // the timer to kill the session due to timeout }), actions: { // Opens a connection with the web socket initConnection() { - const socketUrl = `${ - import.meta.env.VITE_APP_CURR_ENV ? import.meta.env.VITE_APP_API_URL : 'http://localhost:6040' - }`; - this.socket = new WebSocket(socketUrl); + return new Promise((resolve) => { + const socketUrl = `${ + import.meta.env.VITE_APP_CURR_ENV ? import.meta.env.VITE_APP_API_URL : 'http://localhost:6040' + }`; + this.socket = new WebSocket(socketUrl); - // Connection opened - this.socket.onopen = () => { - this.connected = true; - return false; - }; + // Connection opened + this.socket.onopen = () => { + this.connected = true; + resolve(); + }; - // Listen for messages - this.socket.onmessage = (event) => { - const res = JSON.parse(event.data); - this.received = true; - this.result = res; - }; + // Listen for messages + this.socket.onmessage = (event) => { + const res = JSON.parse(event.data); + this.received = true; + this.result = res; + }; + }); }, - // Attempts to lock a storyline for this user. + // Attempts to lock a storyline for this user. // Returns a promise that resolves if the lock was successfully fetched and rejects if it was not. - lockStoryline(uuid: string): Promise { - // Stop the previous storyline's timer. + async lockStoryline(uuid: string): Promise { + // Stop the previous storyline's timer clearInterval(this.timeInterval); + + // If not connected or socket isn't open, try to connect first + if (!this.connected || !this.socket || this.socket.readyState !== WebSocket.OPEN) { + await this.initConnection(); + } + return new Promise((resolve, reject) => { - // First we need to keep polling for the connection to be established. - // Is there a better way to do this? :( - const connectionPoll = setInterval(() => { - if (this.connected) { - // Now that we are connected, we need to poll for the message to be received back from the - // web socket server. - clearInterval(connectionPoll); - this.received = false; - this.socket?.send(JSON.stringify({ uuid, lock: true })); - const receiptPoll = setInterval(() => { - if (this.received) { - clearInterval(receiptPoll); - if (this.result.status === 'fail') { - reject(); - } else { - this.uuid = uuid; - this.secret = this.result.secret; - this.broadcast = new BroadcastChannel(this.result.secret); - resolve(); - } - } - }); + this.received = false; + this.socket?.send(JSON.stringify({ uuid, lock: true })); + + const handleMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data); + + if(data !== undefined){ + if(data.status === 'fail'){ + this.socket!.removeEventListener('message', handleMessage); + reject(new Error(data.message || 'Failed to lock storyline.')); + } + else if (data.status === 'success') { + this.socket!.removeEventListener('message', handleMessage); + + this.uuid = uuid; + this.secret = data.secret; + this.broadcast = new BroadcastChannel(data.secret); + + resolve(); + } } - }, 100); + }; + + this.socket!.addEventListener('message', handleMessage); }); }, // Unlocks the curent storyline for this user. Only to be called on session end.