@@ -8,7 +8,13 @@ var moment = require('moment'); // require
8
8
const decompress = require ( 'decompress' ) ;
9
9
const archiver = require ( 'archiver' ) ;
10
10
const simpleGit = require ( 'simple-git' ) ;
11
+ const uuid = require ( 'uuid' ) ;
12
+ const generateKey = uuid . v4 ;
11
13
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
12
18
require ( 'dotenv' ) . config ( ) ;
13
19
14
20
// CONFIGURATION
@@ -32,6 +38,8 @@ ROUTE_PREFIX =
32
38
33
39
// Create express app.
34
40
var app = express ( ) ;
41
+ const server = http . createServer ( app ) ;
42
+ const wss = new WebSocketServer ( { server, perMessageDeflate : false } ) ;
35
43
36
44
// Open the logfile in append mode.
37
45
var logFile = fs . createWriteStream ( LOG_PATH , { flags : 'a' } ) ;
@@ -42,16 +50,28 @@ app.use(bodyParser.urlencoded({ extended: true }));
42
50
app . use ( bodyParser . json ( ) ) ;
43
51
app . use ( cors ( ) ) ;
44
52
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
+
45
61
// 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
+
47
71
// 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.
48
72
const user = req . headers . user ;
49
73
if ( ! user ) {
50
74
// 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
- } ) ;
55
75
logger ( 'WARNING' , 'Upload Aborted: the user uploading the form was not provided.' ) ;
56
76
res . status ( 400 ) . send ( { status : 'Bad Request' } ) ;
57
77
return ;
@@ -111,8 +131,9 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
111
131
logger ( 'INFO' , `Uploaded files to product ${ fileName } ` ) ;
112
132
// Initialize a new git repo if this is a new storyline.
113
133
// Otherwise, simply create a new commit with the zipped folder.
134
+ let committed = true ;
114
135
if ( ! newStorylines ) {
115
- await commitToRepo ( fileName , user , false ) ;
136
+ committed = await commitToRepo ( fileName , user , false ) ;
116
137
} else {
117
138
await initGitRepo ( fileName , user ) ;
118
139
}
@@ -123,24 +144,29 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
123
144
// Get the hash of the latest commit
124
145
const lastHash = commits . latest . hash ;
125
146
// Send a response back to the client.
126
- res . json ( { new : newStorylines , commitHash : lastHash } ) ;
147
+ res . json ( { new : newStorylines , commitHash : lastHash , committed } ) ;
127
148
} ) ;
128
149
} ) ;
129
150
} ) ;
130
151
131
152
// GET requests made to /retrieve/ID/commitHash will be handled here.
132
153
// Calling this with commitHash as "latest" simply fetches the product as normal.
133
154
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
+
134
164
// This user is only needed for backwards compatibility.
135
165
// If we have an existing storylines product that is not a git repo, we need to initialize a git repo
136
166
// and make an initial commit for it, but we need the user for the commit.
137
167
const user = req . headers . user ;
138
168
if ( ! user ) {
139
169
// 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
- } ) ;
144
170
logger ( 'WARNING' , 'Upload Aborted: the user uploading the form was not provided.' ) ;
145
171
res . status ( 400 ) . send ( { status : 'Bad Request' } ) ;
146
172
return ;
@@ -230,6 +256,14 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) {
230
256
231
257
// GET requests made to /retrieve/ID/LANG will be handled here.
232
258
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
+
233
267
const CONFIG_PATH = `${ TARGET_PATH } /${ req . params . id } /${ req . params . id } _${ req . params . lang } .json` ;
234
268
235
269
// obtain requested config file if it exists
@@ -258,18 +292,24 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) {
258
292
) ;
259
293
} ) ;
260
294
295
+ // GET requests made to /retrieve/ID/LANG will be handled here.
296
+ // Returns the version history ({commitHash, createdDate}) for the requested storyline.
261
297
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
+
262
306
// This user is only needed for backwards compatibility.
263
307
// If we have an existing storylines product that is not a git repo, we need to initialize a git repo
264
308
// and make an initial commit for it, but we need the user for the commit.
265
309
const user = req . headers . user ;
266
310
if ( ! user ) {
267
311
// 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.' ) ;
273
313
res . status ( 400 ) . send ( { status : 'Bad Request' } ) ;
274
314
return ;
275
315
}
@@ -278,10 +318,6 @@ app.route(ROUTE_PREFIX + '/history/:id').get(function (req, res, next) {
278
318
// Check if the product exists.
279
319
fs . access ( PRODUCT_PATH , async ( error ) => {
280
320
if ( error ) {
281
- responseMessages . push ( {
282
- type : 'INFO' ,
283
- message : `Access attempt to versions of ${ req . params . id } failed, does not exist.`
284
- } ) ;
285
321
logger ( 'INFO' , `Access attempt to versions of ${ req . params . id } failed, does not exist.` ) ;
286
322
res . status ( 404 ) . send ( { status : 'Not Found' } ) ;
287
323
} else {
@@ -419,6 +455,20 @@ app.route(ROUTE_PREFIX + '/retrieveMessages').get(function (req, res) {
419
455
responseMessages . length = 0 ;
420
456
} ) ;
421
457
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
+
422
472
/*
423
473
* Initializes a git repo at the requested path, if one does not already exist.
424
474
* Creates an initial commit with any currently existing files in the directory.
@@ -464,17 +514,22 @@ async function commitToRepo(path, username, initial) {
464
514
// Initialize git
465
515
const git = simpleGit ( path ) ;
466
516
let versionNumber = 1 ;
517
+ let commitsBefore = 0 ;
467
518
if ( ! initial ) {
468
519
// Compute version number for storyline if this is not the initial commit.
469
520
const log = await git . log ( ) ;
521
+ commitsBefore = log . total ;
470
522
const lastMessage = log . latest . message ;
471
523
versionNumber = lastMessage . split ( ' ' ) [ 3 ] ;
472
524
versionNumber = Number ( versionNumber ) + 1 ;
473
525
}
474
526
// 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 ;
478
533
}
479
534
480
535
/*
@@ -525,7 +580,69 @@ function logger(type, message) {
525
580
console . log ( `${ currentDate } [${ type } ] ${ message } ` ) ;
526
581
}
527
582
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
+
528
645
// Run the express app on the IIS Port.
529
- var server = app . listen ( PORT , function ( ) {
646
+ server . listen ( PORT , ( ) => {
530
647
logger ( 'INFO' , `Storylines Express Server Started, PORT: ${ PORT } ` ) ;
531
648
} ) ;
0 commit comments