Skip to content

Commit

Permalink
Merge pull request #459 from mohsin-r/sockets
Browse files Browse the repository at this point in the history
Implement one user concurrent access for storylines
  • Loading branch information
szczz authored Jan 17, 2025
2 parents 5a71a47 + 22e8414 commit 1c3c199
Show file tree
Hide file tree
Showing 16 changed files with 737 additions and 137 deletions.
5 changes: 4 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
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}#
VITE_APP_NET_API_URL=#{NET_API_URL}#
VITE_SESSION_END=#{SESSION_END}#
VITE_SESSION_WARN=#{SESSION_WARN}#
2 changes: 1 addition & 1 deletion server/.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ SERVER_CURR_ENV=#{CURR_ENV}#
SERVER_LOG_PATH=#{LOG_PATH}#
SERVER_UPLOAD_PATH=#{UPLOAD_PATH}#
SERVER_TARGET_PATH=#{TARGET_PATH}#
SERVER_API_URL=#{NET_API_URL}#
SERVER_API_URL=#{NET_API_URL}#
165 changes: 141 additions & 24 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ 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 = [];
const http = require('http');
const { WebSocketServer } = require('ws');

let lockedUuids = {}; // the uuids of the storylines currently in use, along with the secret key to access them
require('dotenv').config();

// CONFIGURATION
Expand All @@ -32,6 +38,8 @@ ROUTE_PREFIX =

// Create express app.
var app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server, perMessageDeflate: false });

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

// CORS headers to allow connections
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
});

// 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 (!verifySecret(req.params.id, req.headers.secret)) {
res.status(400).send({
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/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;
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.'
});
logger('WARNING', 'Upload Aborted: the user uploading the form was not provided.');
res.status(400).send({ status: 'Bad Request' });
return;
Expand Down Expand Up @@ -111,8 +131,9 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
logger('INFO', `Uploaded files to product ${fileName}`);
// Initialize a new git repo if this is a new storyline.
// Otherwise, simply create a new commit with the zipped folder.
let committed = true;
if (!newStorylines) {
await commitToRepo(fileName, user, false);
committed = await commitToRepo(fileName, user, false);
} else {
await initGitRepo(fileName, user);
}
Expand All @@ -123,24 +144,29 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
// Get the hash of the latest commit
const lastHash = commits.latest.hash;
// Send a response back to the client.
res.json({ new: newStorylines, commitHash: lastHash });
res.json({ new: newStorylines, commitHash: lastHash, committed });
});
});
});

// 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) {
// If the user is retrieving the product just to preview it, do not check locks.
const isPreview = req.headers.preview;
if (!verifySecret(req.params.id, req.headers.secret) && !isPreview) {
res.status(400).send({
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/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;
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.'
});
logger('WARNING', 'Upload Aborted: the user uploading the form was not provided.');
res.status(400).send({ status: 'Bad Request' });
return;
Expand Down Expand Up @@ -230,6 +256,14 @@ 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 (!verifySecret(req.params.id, req.headers.secret)) {
res.status(400).send({
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/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
Expand Down Expand Up @@ -258,18 +292,24 @@ 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) {
// 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 (!verifySecret(req.params.id, req.headers.secret)) {
res.status(400).send({
status: 'Storyline was not locked or secret key corresponding to storyline lock incorrect/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;
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.'
});
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;
}
Expand All @@ -278,10 +318,6 @@ app.route(ROUTE_PREFIX + '/history/:id').get(function (req, res, next) {
// Check if the product exists.
fs.access(PRODUCT_PATH, async (error) => {
if (error) {
responseMessages.push({
type: 'INFO',
message: `Access attempt to versions of ${req.params.id} failed, does not exist.`
});
logger('INFO', `Access attempt to versions of ${req.params.id} failed, does not exist.`);
res.status(404).send({ status: 'Not Found' });
} else {
Expand Down Expand Up @@ -419,6 +455,20 @@ app.route(ROUTE_PREFIX + '/retrieveMessages').get(function (req, res) {
responseMessages.length = 0;
});

function verifySecret(uuid, secret) {
if (!lockedUuids[uuid]) {
logger('WARNING', 'Aborted: Storyline is not locked.');
return false;
}

if (!secret || secret !== lockedUuids[uuid]) {
logger('WARNING', `Aborted: The secret key is missing or does not correspond to this storyline's lock.`);
return false;
}

return true;
}

/*
* 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.
Expand Down Expand Up @@ -464,17 +514,22 @@ async function commitToRepo(path, username, initial) {
// Initialize git
const git = simpleGit(path);
let versionNumber = 1;
let commitsBefore = 0;
if (!initial) {
// Compute version number for storyline if this is not the initial commit.
const log = await git.log();
commitsBefore = log.total;
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} <>"` });
const log = await git.log();
const commitsAfter = log.total;
return commitsAfter > commitsBefore;
}

/*
Expand Down Expand Up @@ -525,7 +580,69 @@ function logger(type, message) {
console.log(`${currentDate} [${type}] ${message}`);
}

wss.on('connection', (ws) => {
logger('INFO', `A client connected to the web socket server.`);

// The following messages can be received in stringified JSON format:
// { uuid: <uuid>, lock: true }
// { uuid: <uuid>, lock: false }
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) {
// Unlock any storyline that the user was previously locking.
delete lockedUuids[ws.uuid];
// Someone else is currently accessing this storyline, do not allow the user to lock!
if (!!lockedUuids[uuid] && 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.' }));
}
// Lock the storyline for this user. No-one else can access it until the user is done with it.
// 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 {
logger('INFO', `A client successfully locked the storyline ${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) {
logger('INFO', `A client failed to unlock the storyline ${uuid} because they had not locked it.`);
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 {
logger('INFO', `A client successfully unlocked the storyline ${uuid}.`);
delete ws.uuid;
delete lockedUuids[uuid];
ws.send(JSON.stringify({ status: 'success' }));
}
}
});

ws.on('close', () => {
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;
}
});
});

// Run the express app on the IIS Port.
var server = app.listen(PORT, function () {
server.listen(PORT, () => {
logger('INFO', `Storylines Express Server Started, PORT: ${PORT}`);
});
37 changes: 36 additions & 1 deletion server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"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",
"ws": "^8.18.0"
}
}
14 changes: 13 additions & 1 deletion server/web.config
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
<configuration>
<system.webServer>
<handlers>
<add name="StorylinesExpress" path="index.js" verb="*" modules="iisnode" />
<add name="StorylinesExpress" path="index.js" verb="*" modules="iisnode" resourceType="Unspecified" />
</handlers>
<webSocket enabled="true" />
<rewrite>
<rules>
<rule name="WebSocket remove keep-alive header" stopProcessing="true">
<match url="(.*)" />
<serverVariables>
<set name="HTTP_CONNECTION" value="Upgrade" />
</serverVariables>
<action type="Rewrite" url="index.js" />
<conditions logicalGrouping="MatchAny">
<add input="{HTTP_CONNECTION}" pattern="keep-alive, Upgrade" />
<add input="{HTTP_CONNECTION}" pattern="Upgrade, keep-alive" />
</conditions>
</rule>
<rule name="sendToNode">
<match url="/*" />
<action type="Rewrite" url="index.js" />
Expand Down
Loading

0 comments on commit 1c3c199

Please sign in to comment.