From b13ec6b05b0ecf6434a5a72672536edd43b78a6a Mon Sep 17 00:00:00 2001 From: mohsin-r Date: Thu, 28 Nov 2024 14:38:09 -0500 Subject: [PATCH] Implement one user concurrent access for storylines --- .env | 1 + server/index.js | 237 ++++++++++++++---- server/package-lock.json | 53 +++- server/package.json | 4 +- src/components/helpers/confirmation-modal.vue | 3 +- src/components/metadata-editor.vue | 164 +++++++++--- src/lang/lang.csv | 2 + src/stores/lockStore.ts | 93 +++++++ src/stores/userStore.ts | 2 +- 9 files changed, 473 insertions(+), 86 deletions(-) create mode 100644 src/stores/lockStore.ts diff --git a/.env b/.env index 792805a7..ed817f45 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ VITE_APP_API_URL=#{API_URL}# +VITE_APP_SOCKET_URL=#{SOCKET_URL}# VITE_APP_CURR_ENV=#{CURR_ENV}# VITE_APP_NET_API_URL=#{NET_API_URL}# \ No newline at end of file diff --git a/server/index.js b/server/index.js index 399ae2ec..7478edd4 100644 --- a/server/index.js +++ b/server/index.js @@ -8,7 +8,10 @@ var moment = require('moment'); // require const decompress = require('decompress'); const archiver = require('archiver'); const simpleGit = require('simple-git'); +const uuid = require('uuid'); +const generateKey = uuid.v4; const responseMessages = []; +let lockedUuids = {}; // the uuids of the storylines currently in use, along with the secret key to access them require('dotenv').config(); // CONFIGURATION @@ -32,6 +35,7 @@ ROUTE_PREFIX = // Create express app. var app = express(); +var expressWs = require('express-ws')(app); // Open the logfile in append mode. var logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' }); @@ -43,9 +47,26 @@ app.use(bodyParser.json()); app.use(cors()); // POST requests made to /upload will be handled here. -app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) { +app.route(ROUTE_PREFIX + '/upload/:id').post(function (req, res, next) { + // Before any operation can be performed with the storyline, we need to ensure that the requester is the one who holds the lock for this storyline. + if (!lockedUuids[req.params.id]) { + res.status(401).send({ status: 'Need to lock storyline from other users first.' }); + return; + } + + const secret = req.headers.secret; + if (!secret || secret !== lockedUuids[req.params.id]) { + responseMessages.push({ + type: 'WARNING', + message: 'Aborted: Secret key corresponding to storyline lock not provided.' + }); + logger('WARNING', 'Aborted: Secret key corresponding to storyline lock not provided.'); + res.status(401).send({ status: 'Secret key corresponding to storyline lock not provided.' }); + return; + } + // TODO: Putting this on the header isn't great. The body has the zipped folder. And usernames in the URL doesn't look great either. Maybe improve this somehow. - const user = req.headers.user + const user = req.headers.user; if (!user) { // Send back error if the user uploading the storyline was not provided. responseMessages.push({ @@ -123,10 +144,9 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) { // Initialize a new git repo if this is a new storyline. // Otherwise, simply create a new commit with the zipped folder. if (!newStorylines) { - await commitToRepo(fileName, user, false) - } - else { - await initGitRepo(fileName, user) + await commitToRepo(fileName, user, false); + } else { + await initGitRepo(fileName, user); } // Finally, delete the uploaded zip file. safeRM(secureFilename, UPLOAD_PATH); @@ -140,13 +160,45 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) { }); }); +// GET requests made to /exists/ID will be handled here. +app.route(ROUTE_PREFIX + '/exists/:id').get(function (req, res, next) { + // A simple boolean check for if a product exists or not. + // The reason for separating this from /upload is that we want to allow + // checking for an existing product even if the user doesn't hold the lock + // for that product. + const PRODUCT_PATH = `${TARGET_PATH}/${req.params.id}`; + const uuid = req.params.id; + if (lockedUuids[uuid] || fs.existsSync(PRODUCT_PATH)) { + res.status(200).send({ status: 'Storyline exists.' }); + } else { + res.status(404).send({ status: 'Storyline does not exist.' }); + } +}); + // GET requests made to /retrieve/ID/commitHash will be handled here. // Calling this with commitHash as "latest" simply fetches the product as normal. app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) { - // This user is only needed for backwards compatibility. + // Before any operation can be performed with the storyline, we need to ensure that the requester is the one who holds the lock for this storyline. + if (!lockedUuids[req.params.id]) { + res.status(401).send({ status: 'Need to lock storyline from other users first.' }); + return; + } + + const secret = req.headers.secret; + if (!secret || secret !== lockedUuids[req.params.id]) { + responseMessages.push({ + type: 'WARNING', + message: 'Aborted: Secret key corresponding to storyline lock not provided.' + }); + logger('WARNING', 'Aborted: Secret key corresponding to storyline lock not provided.'); + res.status(401).send({ status: 'Secret key corresponding to storyline lock not provided.' }); + return; + } + + // This user is only needed for backwards compatibility. // If we have an existing storylines product that is not a git repo, we need to initialize a git repo // and make an initial commit for it, but we need the user for the commit. - const user = req.headers.user + const user = req.headers.user; if (!user) { // Send back error if the user uploading the storyline was not provided. responseMessages.push({ @@ -161,7 +213,7 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) { var archive = archiver('zip'); const PRODUCT_PATH = `${TARGET_PATH}/${req.params.id}`; const uploadLocation = `${UPLOAD_PATH}/${req.params.id}-outgoing.zip`; - const commitHash = req.params.hash + const commitHash = req.params.hash; // Check if the product exists. if ( @@ -169,27 +221,30 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) { if (!error) { // Backwards compatibility. If the existing product is not a git repo i.e. it existed before git version control, // we make a git repo for it before returning the version history. Otherwise, the code below will explode. - await initGitRepo(PRODUCT_PATH, user) + await initGitRepo(PRODUCT_PATH, user); const git = simpleGit(PRODUCT_PATH); // Get the current branch. We do it this way instead of assuming its "main" in case someone has it set to master. - const branches = await git.branchLocal() - const currBranch = branches.current + const branches = await git.branchLocal(); + const currBranch = branches.current; if (commitHash !== 'latest') { - // If the user does not ask for the latest commit, we checkout a new branch at the point of the requested commit, + // If the user does not ask for the latest commit, we checkout a new branch at the point of the requested commit, // and then proceed with getting the zipped folder below. try { // First, we check if the requested commit exists. // NOTE: When calling from frontend, the catch block should never run. const commitExists = await git.catFile(['-t', commitHash]); if (commitExists !== 'commit\n') { - throw new Error() + throw new Error(); } } catch (error) { responseMessages.push({ type: 'INFO', message: `Access attempt to version ${commitHash} of product ${req.params.id} failed, does not exist.` }); - logger('INFO', `Access attempt to version ${commitHash} of product ${req.params.id} failed, does not exist.`); + logger( + 'INFO', + `Access attempt to version ${commitHash} of product ${req.params.id} failed, does not exist.` + ); res.status(404).send({ status: 'Not Found' }); return; } @@ -219,10 +274,9 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) { // Since the user has not asked for the latest commit, we need to clean up. // Go back to the main branch and delete the newly created branch. await git.checkout(currBranch); - await git.deleteLocalBranch(`version-${commitHash}`) + await git.deleteLocalBranch(`version-${commitHash}`); } }); - }); // Write the product data to the ZIP file. @@ -235,7 +289,6 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) { responseMessages.push({ type: 'INFO', message: `Successfully loaded product ${req.params.id}` }); logger('INFO', `Successfully loaded product ${req.params.id}`); - } else { responseMessages.push({ type: 'INFO', @@ -250,6 +303,23 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) { // GET requests made to /retrieve/ID/LANG will be handled here. app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) { + // Before any operation can be performed with the storyline, we need to ensure that the requester is the one who holds the lock for this storyline. + if (!lockedUuids[req.params.id]) { + res.status(401).send({ status: 'Need to lock storyline from other users first.' }); + return; + } + + const secret = req.headers.secret; + if (!secret || secret !== lockedUuids[req.params.id]) { + responseMessages.push({ + type: 'WARNING', + message: 'Aborted: Secret key corresponding to storyline lock not provided.' + }); + logger('WARNING', 'Aborted: Secret key corresponding to storyline lock not provided.'); + res.status(401).send({ status: 'Secret key corresponding to storyline lock not provided.' }); + return; + } + const CONFIG_PATH = `${TARGET_PATH}/${req.params.id}/${req.params.id}_${req.params.lang}.json`; // obtain requested config file if it exists @@ -290,18 +360,37 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) { ); }); +// GET requests made to /retrieve/ID/LANG will be handled here. +// Returns the version history ({commitHash, createdDate}) for the requested storyline. app.route(ROUTE_PREFIX + '/history/:id').get(function (req, res, next) { - // This user is only needed for backwards compatibility. + // Before any operation can be performed with the storyline, we need to ensure that the requester is the one who holds the lock for this storyline. + if (!lockedUuids[req.params.id]) { + res.status(401).send({ status: 'Need to lock storyline from other users first.' }); + return; + } + + const secret = req.headers.secret; + if (!secret || secret !== lockedUuids[req.params.id]) { + responseMessages.push({ + type: 'WARNING', + message: 'Aborted: Secret key corresponding to storyline lock not provided.' + }); + logger('WARNING', 'Aborted: Secret key corresponding to storyline lock not provided.'); + res.status(401).send({ status: 'Secret key corresponding to storyline lock not provided.' }); + return; + } + + // This user is only needed for backwards compatibility. // If we have an existing storylines product that is not a git repo, we need to initialize a git repo // and make an initial commit for it, but we need the user for the commit. - const user = req.headers.user + const user = req.headers.user; if (!user) { // Send back error if the user uploading the storyline was not provided. responseMessages.push({ type: 'WARNING', - message: 'Upload Aborted: the user uploading the form was not provided.' + message: 'Aborted: the user uploading the form was not provided.' }); - logger('WARNING', 'Upload Aborted: the user uploading the form was not provided.'); + logger('WARNING', 'Aborted: the user uploading the form was not provided.'); res.status(400).send({ status: 'Bad Request' }); return; } @@ -316,21 +405,21 @@ app.route(ROUTE_PREFIX + '/history/:id').get(function (req, res, next) { }); logger('INFO', `Access attempt to versions of ${req.params.id} failed, does not exist.`); res.status(404).send({ status: 'Not Found' }); - } - else { + } else { // Backwards compatibility. If the existing product is not a git repo i.e. it existed before git version control, // we make a git repo for it before returning the version history. Otherwise, the code below will explode. - await initGitRepo(PRODUCT_PATH, user) + await initGitRepo(PRODUCT_PATH, user); // Get version history for this product via git log command const git = simpleGit(PRODUCT_PATH); - const log = await git.log() + const log = await git.log(); // TODO: Remove the 10 version limit once pagination is implemented - const history = log.all.slice(0, 10).map((commit) => ({hash: commit.hash, created: commit.date, storylineUUID: req.params.id})) - res.json(history) + const history = log.all + .slice(0, 10) + .map((commit) => ({ hash: commit.hash, created: commit.date, storylineUUID: req.params.id })); + res.json(history); } - }) - -}) + }); +}); // GET reuests made to /retrieveMessages will recieve all the responseMessages currently queued. app.route(ROUTE_PREFIX + '/retrieveMessages').get(function (req, res) { @@ -338,10 +427,70 @@ app.route(ROUTE_PREFIX + '/retrieveMessages').get(function (req, res) { responseMessages.length = 0; }); +// Simple web socket server to manage concurrency +// We use a web socket server because it can detect whether the browser +// has closed the window or dropped the connection in any way, unlocking their +// storylines. +app.ws('/', function (ws, req) { + // The following messages can be received in stringified JSON format: + // { uuid: , lock: true } + // { uuid: , lock: false } + // TODO: Do we need this stuff in the logs? + ws.on('message', function (msg) { + const message = JSON.parse(msg); + const uuid = message.uuid; + if (!uuid) { + ws.send(JSON.stringify({ status: 'fail', message: 'UUID not provided.' })); + } + // User wants to lock storyline since they are about to load/edit it. + if (message.lock) { + // Someone else is currently accessing this storyline, do not allow the user to lock! + if (!!lockedUuids[uuid] && ws.uuid !== uuid) { + ws.send(JSON.stringify({ status: 'fail', message: 'Another user has locked this storyline.' })); + } + // Lock the storyline for this user. No-one else can access it until the user is done with it. + // Unlock any storyline that the user was previously locking. + // Send the secret key back to the client so that they can now get/save the storyline by passing in the + // secret key to the server routes. + else { + delete lockedUuids[ws.uuid]; + const secret = generateKey(); + lockedUuids[uuid] = secret; + ws.uuid = uuid; + ws.send(JSON.stringify({ status: 'success', secret })); + } + } else { + // Attempting to unlock a different storyline, other than the one this connection has locked, so do not allow. + if (uuid !== ws.uuid) { + ws.send( + JSON.stringify({ + status: 'fail', + message: 'You have not locked this storyline, so you may not unlock it.' + }) + ); + } + // Unlock the storyline for any other user/connection to use. + else { + delete ws.uuid; + delete lockedUuids[uuid]; + ws.send(JSON.stringify({ status: 'success' })); + } + } + }); + + ws.on('close', () => { + // Connection was closed, unlock this user's locked storyline + if (ws.uuid) { + delete lockedUuids[ws.uuid]; + delete ws.uuid; + } + }); +}); + /* * Initializes a git repo at the requested path, if one does not already exist. * Creates an initial commit with any currently existing files in the directory. - * + * * @param {string} path the path of the git repo * @param {string} username the name of the user initializing the repo */ @@ -357,16 +506,16 @@ async function initGitRepo(path, username) { // Product directory is in a git repo but not top-level, we are working locally. repoExists = false; } - } catch(error) { + } catch (error) { // Product directory is not a git repo nor is it within a git repo. repoExists = false; } if (!repoExists) { - // Repo does not exist for the storyline product. + // Repo does not exist for the storyline product. // Initialize a git repo and add an initial commit with all existing files. - await git.init() - await commitToRepo(path, username, true) + await git.init(); + await commitToRepo(path, username, true); } } @@ -378,20 +527,22 @@ async function initGitRepo(path, username) { * @param {boolean} initial specifies whether this is the initial commit */ async function commitToRepo(path, username, initial) { - const date = moment().format('YYYY-MM-DD') - const time = moment().format('hh:mm:ss a') + const date = moment().format('YYYY-MM-DD'); + const time = moment().format('hh:mm:ss a'); // Initialize git const git = simpleGit(path); - let versionNumber = 1 + let versionNumber = 1; if (!initial) { // Compute version number for storyline if this is not the initial commit. - const log = await git.log() - const lastMessage = log.latest.message - versionNumber = lastMessage.split(' ')[3] + const log = await git.log(); + const lastMessage = log.latest.message; + versionNumber = lastMessage.split(' ')[3]; versionNumber = Number(versionNumber) + 1; } // Commit the files for this storyline to its repo. - await git.add('./*').commit(`Add product version ${versionNumber} on ${date} at ${time}`, {'--author': `"${username} <>"`}) + await git + .add('./*') + .commit(`Add product version ${versionNumber} on ${date} at ${time}`, { '--author': `"${username} <>"` }); } /* diff --git a/server/package-lock.json b/server/package-lock.json index 2649074f..bcb0fee4 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,12 +16,14 @@ "decompress": "^4.2.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-ws": "^5.0.2", "formidable": "^2.1.2", "fs-extra": "^11.1.0", "moment": "^2.29.4", "path": "^0.12.7", "recursive-readdir": "^2.2.3", - "simple-git": "^3.27.0" + "simple-git": "^3.27.0", + "uuid": "^11.0.3" } }, "node_modules/@kwsites/file-exists": { @@ -722,6 +724,21 @@ "node": ">= 0.10.0" } }, + "node_modules/express-ws": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/express-ws/-/express-ws-5.0.2.tgz", + "integrity": "sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==", + "license": "BSD-2-Clause", + "dependencies": { + "ws": "^7.4.6" + }, + "engines": { + "node": ">=4.5.0" + }, + "peerDependencies": { + "express": "^4.0.0 || ^5.0.0-alpha.1" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -1722,6 +1739,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1735,6 +1765,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/server/package.json b/server/package.json index 205243e5..b73bbcca 100644 --- a/server/package.json +++ b/server/package.json @@ -16,11 +16,13 @@ "decompress": "^4.2.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-ws": "^5.0.2", "formidable": "^2.1.2", "fs-extra": "^11.1.0", "moment": "^2.29.4", "path": "^0.12.7", "recursive-readdir": "^2.2.3", - "simple-git": "^3.27.0" + "simple-git": "^3.27.0", + "uuid": "^11.0.3" } } diff --git a/src/components/helpers/confirmation-modal.vue b/src/components/helpers/confirmation-modal.vue index a1e082a7..3ed40fc8 100644 --- a/src/components/helpers/confirmation-modal.vue +++ b/src/components/helpers/confirmation-modal.vue @@ -4,7 +4,7 @@ class="flex justify-center items-center" content-class="flex flex-col max-w-xl mx-4 p-4 bg-white dark:bg-gray-900 border dark:border-gray-700 rounded-lg space-y-2" > -

{{ message }}

+

{{ message }}

@@ -588,6 +598,7 @@ import ConfirmationModalV from './helpers/confirmation-modal.vue'; import EditorV from './editor.vue'; import cloneDeep from 'clone-deep'; +import { useLockStore } from '@/stores/lockStore'; interface RouteParams { uid: string; @@ -641,6 +652,7 @@ export default class MetadataEditorV extends Vue { currLang = 'en'; // page language showDropdown = false; highlightedIndex = -1; + lockStore = useLockStore(); storylineHistory: History[] = []; selectedHistory: History | null = null; @@ -709,7 +721,8 @@ export default class MetadataEditorV extends Vue { uuid: true }; slides: MultiLanguageSlide[] = []; - + confirmationTimeout: NodeJS.Timeout | undefined = undefined; // the timer to show the session extension confirmation modal + endTimeout: NodeJS.Timeout | undefined = undefined; // the timer to kill the session due to timeout sourceCounts: SourceCounts = {}; mounted(): void { @@ -748,6 +761,8 @@ export default class MetadataEditorV extends Vue { // Properties already passed in props, load editor view (could use a refactor to clean up this workflow process) if (props && props.configs && props.configFileStructure) { + // New product has UUID locked in, need to start timing sessions + this.extendSession(); this.configs = props.configs; this.configLang = props.configLang; this.configFileStructure = props.configFileStructure; @@ -800,6 +815,28 @@ export default class MetadataEditorV extends Vue { } } + handleSessionTimeout(): void { + // Clear any lingering timers + clearTimeout(this.endTimeout); + clearTimeout(this.confirmationTimeout); + // We prompt the user to extend the session when there are 30 seconds left (i.e. 29.5 minutes have passed). + this.confirmationTimeout = setTimeout(() => { + this.$vfm.open(`confirm-extend-session`); + }, this.lockStore.timeRemaining * 1000 - 30000); + // After the timer has run out, if the session was not extended, go back to the landing page (which will unlock the storyline). + this.endTimeout = setTimeout(() => { + this.$vfm.close('confirm-extend-session'); + this.$router.push({ name: 'home' }); + }, this.lockStore.timeRemaining * 1000 + 1000); + } + + extendSession(): void { + // If the user wants to extend the timer, this method will reset the time remaining. + this.lockStore.resetSession(); + // We need to call this method again because we need to keep checking that the time has not run out. + this.handleSessionTimeout(); + } + /** * Open current editor config as a new Storylines product in new tab. * Note: Preview button on metadata editor will only show when editing an existing product, not cwhen creating a new one @@ -911,7 +948,8 @@ export default class MetadataEditorV extends Vue { return new Promise((resolve, reject) => { this.loadStatus = 'loading'; const user = useUserStore().userProfile.userName || 'Guest'; - fetch(this.apiUrl + `/retrieve/${this.uuid}/${version}`, { headers: { user } }) + const secret = this.lockStore.secret; + fetch(this.apiUrl + `/retrieve/${this.uuid}/${version}`, { headers: { user, secret: secret } }) .then((res: Response) => { if (res.status === 404) { // Version not found. @@ -930,6 +968,8 @@ export default class MetadataEditorV extends Vue { res.blob().then((file: Blob) => { configZip.loadAsync(file).then(() => { this.configFileStructureHelper(configZip); + // Extend the session on load + this.extendSession(); }); }); } @@ -968,16 +1008,34 @@ export default class MetadataEditorV extends Vue { * Provided with a UID, retrieve the project contents from the file server. */ generateRemoteConfig(): Promise { - this.loadStatus = 'loading'; - - // Reset fields - this.baseUuid = this.uuid; - this.renamed = ''; - this.changeUuid = ''; - - // Attempt to fetch the project from the server. return new Promise((resolve, reject) => { - this.loadVersion('latest').then(resolve).catch(reject); + // Clear any lingering timeouts, don't want to exit while stuff is loading. + clearInterval(this.confirmationTimeout); + clearInterval(this.endTimeout); + + // Before loading the product, we need to try and get its "lock". + // If successful i.e. the product is free to use, we load the product. + // If not i.e. another user is using it right now, we show an error message. + this.lockStore + .lockStoryline(this.uuid) + .then(() => { + this.loadStatus = 'loading'; + this.error = false; + + // Reset fields + this.baseUuid = this.uuid; + this.renamed = ''; + this.changeUuid = ''; + + // Attempt to fetch the project from the server. + this.loadVersion('latest').then(resolve).catch(reject); + }) + .catch(() => { + this.error = true; + this.loadStatus = 'waiting'; + this.clearConfig(); + Message.error(this.$t('editor.editMetadata.message.error.unauthorized')); + }); }); } @@ -986,9 +1044,9 @@ export default class MetadataEditorV extends Vue { // as the history should be much smaller and quicker to fetch than the config if (this.uuid === undefined) Message.error(this.$t('editor.warning.mustEnterUuid')); - this.loadStatus = 'loading'; const user = useUserStore().userProfile.userName || 'Guest'; - fetch(this.apiUrl + `/history/${this.uuid}`, { headers: { user } }).then((res: Response) => { + const secret = this.lockStore.secret; + fetch(this.apiUrl + `/history/${this.uuid}`, { headers: { user, secret } }).then((res: Response) => { if (res.status === 404) { // Product not found. Message.error(`The requested UUID '${this.uuid ?? ''}' does not exist.`); @@ -997,7 +1055,6 @@ export default class MetadataEditorV extends Vue { this.storylineHistory = json; }); } - this.loadStatus = 'loaded'; }); } @@ -1324,6 +1381,10 @@ export default class MetadataEditorV extends Vue { generateConfig(): ConfigFileStructure { this.saving = true; + // Clear any session timeouts, don't want the app to exit while saving, duh + clearTimeout(this.confirmationTimeout); + clearTimeout(this.endTimeout); + // Update the configuration files, for both languages. const engFileName = `${this.uuid}_en.json`; const frFileName = `${this.uuid}_fr.json`; @@ -1348,11 +1409,15 @@ export default class MetadataEditorV extends Vue { 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' }; + const headers = { + 'Content-Type': 'multipart/form-data', + user: userStore.userProfile.userName || 'Guest', + secret: this.lockStore.secret + }; Message.warning(this.$t('editor.editMetadata.message.wait')); axios - .post(this.apiUrl + '/upload', formData, { headers }) + .post(this.apiUrl + `/upload/${this.uuid}`, formData, { headers }) .then((res: AxiosResponse) => { const responseData = res.data; responseData.files; // binary representation of the file @@ -1380,6 +1445,8 @@ export default class MetadataEditorV extends Vue { axios .post(import.meta.env.VITE_APP_NET_API_URL + '/api/version/commit', formData) .then((response: any) => { + // Extend the session on save + this.extendSession(); Message.success(this.$t('editor.editMetadata.message.successfulSave')); }) .catch((error: any) => console.log(error.response || error)) @@ -1401,6 +1468,8 @@ export default class MetadataEditorV extends Vue { .post(import.meta.env.VITE_APP_NET_API_URL + '/api/version/commit', formData) .then((response: any) => { Message.success(this.$t('editor.editMetadata.message.successfulSave')); + // Extend the session on save + this.extendSession(); }) .catch((error: any) => console.log(error.response || error)) .finally(() => { @@ -1542,30 +1611,27 @@ export default class MetadataEditorV extends Vue { if (rename) this.checkingUuid = true; if (!this.loadExisting || rename) { - const user = useUserStore().userProfile.userName || 'Guest'; // If renaming, show the loading spinner while we check whether the UUID is taken. - fetch(this.apiUrl + `/retrieve/${rename ? this.changeUuid : this.uuid}/latest`, { headers: { user } }).then( - (res: Response) => { - if (res.status !== 404) { - this.warning = rename ? 'rename' : 'uuid'; - } + fetch(this.apiUrl + `/exists/${rename ? this.changeUuid : this.uuid}`).then((res: Response) => { + if (res.status !== 404) { + this.warning = rename ? 'rename' : 'uuid'; + } - if (rename) this.checkingUuid = false; + if (rename) this.checkingUuid = false; - fetch(this.apiUrl + `/retrieveMessages`) - .then((res: any) => { - if (res.ok) return res.json(); - }) - .then((data) => { - axios - .post(import.meta.env.VITE_APP_NET_API_URL + '/api/log/create', { - messages: data.messages - }) - .catch((error: any) => console.log(error.response || error)); - }) - .catch((error: any) => console.log(error.response || error)); - } - ); + fetch(this.apiUrl + `/retrieveMessages`) + .then((res: any) => { + if (res.ok) return res.json(); + }) + .then((data) => { + axios + .post(import.meta.env.VITE_APP_NET_API_URL + '/api/log/create', { + messages: data.messages + }) + .catch((error: any) => console.log(error.response || error)); + }) + .catch((error: any) => console.log(error.response || error)); + }); } this.warning = 'none'; this.highlightedIndex = -1; @@ -1656,6 +1722,9 @@ export default class MetadataEditorV extends Vue { if (this.configs[this.configLang] !== undefined && this.uuid === this.configFileStructure?.uuid) { this.loadEditor = true; this.saveMetadata(false); + // We have loaded an existing product and are now going to the main editor tab. + // We extend the session so that the user has a full 30 minutes to make their edits. + this.extendSession(); this.updateEditorPath(); } else { Message.error(this.$t('editor.editMetadata.message.error.noConfig')); @@ -1664,7 +1733,16 @@ export default class MetadataEditorV extends Vue { Message.error(this.$t('editor.warning.mustEnterUuid')); this.error = true; } else { - this.generateNewConfig(); + // We have a new product that is going to the main editor route, so its UUID is now locked. + // Therefore, we also lock it in the server so that another user does not create a new product + // with the same UUID until the user's session is in progress. + this.lockStore + .lockStoryline(this.uuid) + .then(() => this.generateNewConfig()) + .catch(() => { + this.error = true; + Message.error(this.$t('editor.editMetadata.message.error.unauthorized')); + }); } } @@ -1689,7 +1767,15 @@ export default class MetadataEditorV extends Vue { beforeRouteLeave(to: RouteLocationNormalized, from: RouteLocationNormalized, next: (cont?: boolean) => void): void { const curEditor = this.$route.name === 'editor'; const confirmationMessage = 'Leave the page? Changes made may not be saved.'; - if (this.unsavedChanges && curEditor && !window.confirm(confirmationMessage)) { + const stay = this.unsavedChanges && curEditor && !window.confirm(confirmationMessage); + const exitingProduct = to.name !== 'editor' && !stay; + if (exitingProduct) { + // Unlock the storyline for other users if we are exiting the product e.g. by navigating to a different route. + this.lockStore.unlockStoryline(); + clearTimeout(this.confirmationTimeout); + clearTimeout(this.endTimeout); + } + if (stay) { next(false); } else { next(); diff --git a/src/lang/lang.csv b/src/lang/lang.csv index d748cb97..82b1fde7 100644 --- a/src/lang/lang.csv +++ b/src/lang/lang.csv @@ -51,6 +51,7 @@ editor.editMetadata.message.error.noRequestedVersion,The requested version does editor.editMetadata.message.error.noResponseFromServer,"Failed to load product, no response from server",1,"Échec du chargement du produit, aucune réponse du serveur",0 editor.editMetadata.message.error.malformedProduct,The requested product {uuid} is malformed.,1,Le produit demandé {uuid} est mal formé.,0 editor.editMetadata.message.error.failedSave,Failed to save changes.,1,Échec de l'enregistrement des modifications.,0 +editor.editMetadata.message.error.unauthorized,The product is being accessed by another user. Please try again later.,1,[FR] The product is being accessed by another user. Please try again later.,0 editor.editMetadata.message.error.logoFailedLoad,Failed to load logo image.,1,Échec du chargement de l'image du logo.,0 editor.editMetadata.message.error.requiredFieldsNotFilled,Please fill out the required fields before proceeding.,1,Veuillez remplir les champs obligatoires avant de continuer.,0 editor.editMetadata.message.logoSuccessfulLoad,Successfully loaded logo image.,1,Image du logo chargée avec succès.,0 @@ -81,6 +82,7 @@ editor.metadataForm.endOfPage.explanation,"This information is displayed at the editor.metadataForm.enabled,Enabled,1,Activé,0 editor.metadataForm.disabled,Disabled,1,Désactivé,0 editor.metadataForm.na,N/A,1,N / A,0 +editor.extendSession,We noticed that you have been working on this product for a while. Your session is going to expire in {secs} seconds. Please confirm that you are still working on it to extend your session time by another 30 minutes.,1,[FR] We noticed that you have been working on this product for a while. Your session is going to expire in {secs} seconds. Please confirm that you are still working on it to extend your session time by another 30 minutes.,0 editor.done,Done,1,Fini,0 editor.editProduct,Edit Existing Storylines Product,1,Modifier un produit de scénarios,1 editor.editMetadata,Edit Project Metadata,1,Mod. les métadonnées,1 diff --git a/src/stores/lockStore.ts b/src/stores/lockStore.ts new file mode 100644 index 00000000..817b15e0 --- /dev/null +++ b/src/stores/lockStore.ts @@ -0,0 +1,93 @@ +import { defineStore } from 'pinia'; + +export const useLockStore = defineStore('lock', { + state: () => ({ + socket: undefined as WebSocket | undefined, + uuid: '', + secret: '', + connected: false, + received: false, + timeInterval: undefined as NodeJS.Timeout | undefined, + timeRemaining: 100000000000000, // in seconds + result: {} as any + }), + actions: { + // Opens a connection with the web socket + initConnection() { + const socketUrl = import.meta.env.VITE_APP_CURR_ENV + ? import.meta.env.VITE_APP_SOCKET_URL + : 'ws://localhost:6040'; + this.socket = new WebSocket(socketUrl); + + // Connection opened + this.socket.addEventListener('open', (event) => { + this.connected = true; + }); + + // Listen for messages + this.socket.addEventListener('message', (event) => { + const res = JSON.parse(event.data); + this.received = true; + this.result = res; + }); + }, + // 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 { + return new Promise((resolve, reject) => { + if (!this.connected) { + this.initConnection(); + } + // 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; + resolve(); + } + } + }); + } + }, 100); + }); + }, + // Unlocks the curent storyline for this user. + unlockStoryline() { + if (this.connected) { + this.socket!.send(JSON.stringify({ uuid: this.uuid, lock: false })); + this.uuid = ''; + this.secret = ''; + clearInterval(this.timeInterval); + } + }, + // Resets the current session back to a full 30 minutes. + resetSession() { + this.timeRemaining = 40; // TODO: Change to 1800 (30 minutes) when testing is done. + if (this.timeInterval) { + clearInterval(this.timeInterval); + } + // Update the time remaining every second. + this.timeInterval = setInterval(() => { + // TODO: Remove later. Countdown for debugging. + console.log(this.timeRemaining); + if (this.timeRemaining === 0) { + clearInterval(this.timeInterval); + } else { + this.timeRemaining -= 1; + } + }, 1000); + } + } +}); diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index 26710700..068126fe 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -4,7 +4,7 @@ interface Storyline { uuid: string; titleEN: string; titleFR: string; - lastModified: Date; + lastModified: string; isUserStoryline?: boolean; }