Skip to content

Commit 1c3c199

Browse files
author
Matt Szczerba
authored
Merge pull request #459 from mohsin-r/sockets
Implement one user concurrent access for storylines
2 parents 5a71a47 + 22e8414 commit 1c3c199

File tree

16 files changed

+737
-137
lines changed

16 files changed

+737
-137
lines changed

.env

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
VITE_APP_API_URL=#{API_URL}#
2+
VITE_APP_SOCKET_URL=#{SOCKET_URL}#
23
VITE_APP_CURR_ENV=#{CURR_ENV}#
3-
VITE_APP_NET_API_URL=#{NET_API_URL}#
4+
VITE_APP_NET_API_URL=#{NET_API_URL}#
5+
VITE_SESSION_END=#{SESSION_END}#
6+
VITE_SESSION_WARN=#{SESSION_WARN}#

server/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ SERVER_CURR_ENV=#{CURR_ENV}#
22
SERVER_LOG_PATH=#{LOG_PATH}#
33
SERVER_UPLOAD_PATH=#{UPLOAD_PATH}#
44
SERVER_TARGET_PATH=#{TARGET_PATH}#
5-
SERVER_API_URL=#{NET_API_URL}#
5+
SERVER_API_URL=#{NET_API_URL}#

server/index.js

Lines changed: 141 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ var moment = require('moment'); // require
88
const decompress = require('decompress');
99
const archiver = require('archiver');
1010
const simpleGit = require('simple-git');
11+
const uuid = require('uuid');
12+
const generateKey = uuid.v4;
1113
const responseMessages = [];
14+
const http = require('http');
15+
const { WebSocketServer } = require('ws');
16+
17+
let lockedUuids = {}; // the uuids of the storylines currently in use, along with the secret key to access them
1218
require('dotenv').config();
1319

1420
// CONFIGURATION
@@ -32,6 +38,8 @@ ROUTE_PREFIX =
3238

3339
// Create express app.
3440
var app = express();
41+
const server = http.createServer(app);
42+
const wss = new WebSocketServer({ server, perMessageDeflate: false });
3543

3644
// Open the logfile in append mode.
3745
var logFile = fs.createWriteStream(LOG_PATH, { flags: 'a' });
@@ -42,16 +50,28 @@ app.use(bodyParser.urlencoded({ extended: true }));
4250
app.use(bodyParser.json());
4351
app.use(cors());
4452

53+
// CORS headers to allow connections
54+
app.use((req, res, next) => {
55+
res.header('Access-Control-Allow-Origin', '*');
56+
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
57+
res.header('Access-Control-Allow-Headers', 'Content-Type');
58+
next();
59+
});
60+
4561
// POST requests made to /upload will be handled here.
46-
app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
62+
app.route(ROUTE_PREFIX + '/upload/:id').post(function (req, res, next) {
63+
// 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.
64+
if (!verifySecret(req.params.id, req.headers.secret)) {
65+
res.status(400).send({
66+
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/not provided.'
67+
});
68+
return;
69+
}
70+
4771
// 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.
4872
const user = req.headers.user;
4973
if (!user) {
5074
// Send back error if the user uploading the storyline was not provided.
51-
responseMessages.push({
52-
type: 'WARNING',
53-
message: 'Upload Aborted: the user uploading the form was not provided.'
54-
});
5575
logger('WARNING', 'Upload Aborted: the user uploading the form was not provided.');
5676
res.status(400).send({ status: 'Bad Request' });
5777
return;
@@ -111,8 +131,9 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
111131
logger('INFO', `Uploaded files to product ${fileName}`);
112132
// Initialize a new git repo if this is a new storyline.
113133
// Otherwise, simply create a new commit with the zipped folder.
134+
let committed = true;
114135
if (!newStorylines) {
115-
await commitToRepo(fileName, user, false);
136+
committed = await commitToRepo(fileName, user, false);
116137
} else {
117138
await initGitRepo(fileName, user);
118139
}
@@ -123,24 +144,29 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
123144
// Get the hash of the latest commit
124145
const lastHash = commits.latest.hash;
125146
// Send a response back to the client.
126-
res.json({ new: newStorylines, commitHash: lastHash });
147+
res.json({ new: newStorylines, commitHash: lastHash, committed });
127148
});
128149
});
129150
});
130151

131152
// GET requests made to /retrieve/ID/commitHash will be handled here.
132153
// Calling this with commitHash as "latest" simply fetches the product as normal.
133154
app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) {
155+
// If the user is retrieving the product just to preview it, do not check locks.
156+
const isPreview = req.headers.preview;
157+
if (!verifySecret(req.params.id, req.headers.secret) && !isPreview) {
158+
res.status(400).send({
159+
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/not provided.'
160+
});
161+
return;
162+
}
163+
134164
// This user is only needed for backwards compatibility.
135165
// If we have an existing storylines product that is not a git repo, we need to initialize a git repo
136166
// and make an initial commit for it, but we need the user for the commit.
137167
const user = req.headers.user;
138168
if (!user) {
139169
// Send back error if the user uploading the storyline was not provided.
140-
responseMessages.push({
141-
type: 'WARNING',
142-
message: 'Upload Aborted: the user uploading the form was not provided.'
143-
});
144170
logger('WARNING', 'Upload Aborted: the user uploading the form was not provided.');
145171
res.status(400).send({ status: 'Bad Request' });
146172
return;
@@ -230,6 +256,14 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) {
230256

231257
// GET requests made to /retrieve/ID/LANG will be handled here.
232258
app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) {
259+
// 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.
260+
if (!verifySecret(req.params.id, req.headers.secret)) {
261+
res.status(400).send({
262+
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/not provided.'
263+
});
264+
return;
265+
}
266+
233267
const CONFIG_PATH = `${TARGET_PATH}/${req.params.id}/${req.params.id}_${req.params.lang}.json`;
234268

235269
// obtain requested config file if it exists
@@ -258,18 +292,24 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) {
258292
);
259293
});
260294

295+
// GET requests made to /retrieve/ID/LANG will be handled here.
296+
// Returns the version history ({commitHash, createdDate}) for the requested storyline.
261297
app.route(ROUTE_PREFIX + '/history/:id').get(function (req, res, next) {
298+
// 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.
299+
if (!verifySecret(req.params.id, req.headers.secret)) {
300+
res.status(400).send({
301+
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/not provided.'
302+
});
303+
return;
304+
}
305+
262306
// This user is only needed for backwards compatibility.
263307
// If we have an existing storylines product that is not a git repo, we need to initialize a git repo
264308
// and make an initial commit for it, but we need the user for the commit.
265309
const user = req.headers.user;
266310
if (!user) {
267311
// Send back error if the user uploading the storyline was not provided.
268-
responseMessages.push({
269-
type: 'WARNING',
270-
message: 'Upload Aborted: the user uploading the form was not provided.'
271-
});
272-
logger('WARNING', 'Upload Aborted: the user uploading the form was not provided.');
312+
logger('WARNING', 'Aborted: the user uploading the form was not provided.');
273313
res.status(400).send({ status: 'Bad Request' });
274314
return;
275315
}
@@ -278,10 +318,6 @@ app.route(ROUTE_PREFIX + '/history/:id').get(function (req, res, next) {
278318
// Check if the product exists.
279319
fs.access(PRODUCT_PATH, async (error) => {
280320
if (error) {
281-
responseMessages.push({
282-
type: 'INFO',
283-
message: `Access attempt to versions of ${req.params.id} failed, does not exist.`
284-
});
285321
logger('INFO', `Access attempt to versions of ${req.params.id} failed, does not exist.`);
286322
res.status(404).send({ status: 'Not Found' });
287323
} else {
@@ -419,6 +455,20 @@ app.route(ROUTE_PREFIX + '/retrieveMessages').get(function (req, res) {
419455
responseMessages.length = 0;
420456
});
421457

458+
function verifySecret(uuid, secret) {
459+
if (!lockedUuids[uuid]) {
460+
logger('WARNING', 'Aborted: Storyline is not locked.');
461+
return false;
462+
}
463+
464+
if (!secret || secret !== lockedUuids[uuid]) {
465+
logger('WARNING', `Aborted: The secret key is missing or does not correspond to this storyline's lock.`);
466+
return false;
467+
}
468+
469+
return true;
470+
}
471+
422472
/*
423473
* Initializes a git repo at the requested path, if one does not already exist.
424474
* Creates an initial commit with any currently existing files in the directory.
@@ -464,17 +514,22 @@ async function commitToRepo(path, username, initial) {
464514
// Initialize git
465515
const git = simpleGit(path);
466516
let versionNumber = 1;
517+
let commitsBefore = 0;
467518
if (!initial) {
468519
// Compute version number for storyline if this is not the initial commit.
469520
const log = await git.log();
521+
commitsBefore = log.total;
470522
const lastMessage = log.latest.message;
471523
versionNumber = lastMessage.split(' ')[3];
472524
versionNumber = Number(versionNumber) + 1;
473525
}
474526
// Commit the files for this storyline to its repo.
475-
await git.add('./*').commit(`Add product version ${versionNumber} on ${date} at ${time}`, {
476-
'--author': `"${username} <>"`
477-
});
527+
await git
528+
.add('./*')
529+
.commit(`Add product version ${versionNumber} on ${date} at ${time}`, { '--author': `"${username} <>"` });
530+
const log = await git.log();
531+
const commitsAfter = log.total;
532+
return commitsAfter > commitsBefore;
478533
}
479534

480535
/*
@@ -525,7 +580,69 @@ function logger(type, message) {
525580
console.log(`${currentDate} [${type}] ${message}`);
526581
}
527582

583+
wss.on('connection', (ws) => {
584+
logger('INFO', `A client connected to the web socket server.`);
585+
586+
// The following messages can be received in stringified JSON format:
587+
// { uuid: <uuid>, lock: true }
588+
// { uuid: <uuid>, lock: false }
589+
ws.on('message', function (msg) {
590+
const message = JSON.parse(msg);
591+
const uuid = message.uuid;
592+
if (!uuid) {
593+
ws.send(JSON.stringify({ status: 'fail', message: 'UUID not provided.' }));
594+
}
595+
// User wants to lock storyline since they are about to load/edit it.
596+
if (message.lock) {
597+
// Unlock any storyline that the user was previously locking.
598+
delete lockedUuids[ws.uuid];
599+
// Someone else is currently accessing this storyline, do not allow the user to lock!
600+
if (!!lockedUuids[uuid] && ws.uuid !== uuid) {
601+
logger('INFO', `A client failed to lock the storyline ${uuid}.`);
602+
ws.send(JSON.stringify({ status: 'fail', message: 'Another user has locked this storyline.' }));
603+
}
604+
// Lock the storyline for this user. No-one else can access it until the user is done with it.
605+
// Send the secret key back to the client so that they can now get/save the storyline by passing in the
606+
// secret key to the server routes.
607+
else {
608+
logger('INFO', `A client successfully locked the storyline ${uuid}.`);
609+
const secret = generateKey();
610+
lockedUuids[uuid] = secret;
611+
ws.uuid = uuid;
612+
ws.send(JSON.stringify({ status: 'success', secret }));
613+
}
614+
} else {
615+
// Attempting to unlock a different storyline, other than the one this connection has locked, so do not allow.
616+
if (uuid !== ws.uuid) {
617+
logger('INFO', `A client failed to unlock the storyline ${uuid} because they had not locked it.`);
618+
ws.send(
619+
JSON.stringify({
620+
status: 'fail',
621+
message: 'You have not locked this storyline, so you may not unlock it.'
622+
})
623+
);
624+
}
625+
// Unlock the storyline for any other user/connection to use.
626+
else {
627+
logger('INFO', `A client successfully unlocked the storyline ${uuid}.`);
628+
delete ws.uuid;
629+
delete lockedUuids[uuid];
630+
ws.send(JSON.stringify({ status: 'success' }));
631+
}
632+
}
633+
});
634+
635+
ws.on('close', () => {
636+
logger('INFO', `Client connection with web socket server has closed.`);
637+
// Connection was closed, unlock this user's locked storyline
638+
if (ws.uuid) {
639+
delete lockedUuids[ws.uuid];
640+
delete ws.uuid;
641+
}
642+
});
643+
});
644+
528645
// Run the express app on the IIS Port.
529-
var server = app.listen(PORT, function () {
646+
server.listen(PORT, () => {
530647
logger('INFO', `Storylines Express Server Started, PORT: ${PORT}`);
531648
});

server/package-lock.json

Lines changed: 36 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"moment": "^2.29.4",
2222
"path": "^0.12.7",
2323
"recursive-readdir": "^2.2.3",
24-
"simple-git": "^3.27.0"
24+
"simple-git": "^3.27.0",
25+
"uuid": "^11.0.3",
26+
"ws": "^8.18.0"
2527
}
2628
}

server/web.config

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
<configuration>
22
<system.webServer>
33
<handlers>
4-
<add name="StorylinesExpress" path="index.js" verb="*" modules="iisnode" />
4+
<add name="StorylinesExpress" path="index.js" verb="*" modules="iisnode" resourceType="Unspecified" />
55
</handlers>
6+
<webSocket enabled="true" />
67
<rewrite>
78
<rules>
9+
<rule name="WebSocket remove keep-alive header" stopProcessing="true">
10+
<match url="(.*)" />
11+
<serverVariables>
12+
<set name="HTTP_CONNECTION" value="Upgrade" />
13+
</serverVariables>
14+
<action type="Rewrite" url="index.js" />
15+
<conditions logicalGrouping="MatchAny">
16+
<add input="{HTTP_CONNECTION}" pattern="keep-alive, Upgrade" />
17+
<add input="{HTTP_CONNECTION}" pattern="Upgrade, keep-alive" />
18+
</conditions>
19+
</rule>
820
<rule name="sendToNode">
921
<match url="/*" />
1022
<action type="Rewrite" url="index.js" />

0 commit comments

Comments
 (0)