@@ -7,6 +7,7 @@ var cors = require('cors');
7
7
var moment = require ( 'moment' ) ; // require
8
8
const decompress = require ( 'decompress' ) ;
9
9
const archiver = require ( 'archiver' ) ;
10
+ const simpleGit = require ( 'simple-git' ) ;
10
11
const responseMessages = [ ] ;
11
12
require ( 'dotenv' ) . config ( ) ;
12
13
@@ -43,6 +44,19 @@ app.use(cors());
43
44
44
45
// POST requests made to /upload will be handled here.
45
46
app . route ( ROUTE_PREFIX + '/upload' ) . post ( function ( req , res , next ) {
47
+ // 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
+ const user = req . headers . user
49
+ if ( ! user ) {
50
+ // 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
+ logger ( 'WARNING' , 'Upload Aborted: the user uploading the form was not provided.' ) ;
56
+ res . status ( 400 ) . send ( { status : 'Bad Request' } ) ;
57
+ return ;
58
+ }
59
+
46
60
const options = {
47
61
uploadDir : UPLOAD_PATH ,
48
62
keepExtensions : true ,
@@ -56,7 +70,7 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
56
70
//const projectNameRegex = /[a-zA-Z0-9]{8}(-[a-zA-Z0-9]{4}){3}-[a-zA-Z0-9]{12}/g;
57
71
58
72
// Upload the file to the server, into the /files/ folder.
59
- form . parse ( req , function ( err , field , file ) {
73
+ form . parse ( req , async function ( err , field , file ) {
60
74
if ( err ) {
61
75
responseMessages . push ( {
62
76
type : 'WARNING' ,
@@ -97,7 +111,7 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
97
111
98
112
// Unzip the contents of the uploaded zip file into the target directory. Will overwrite
99
113
// old files in the folder.
100
- decompress ( secureFilename , fileName ) . then ( ( files ) => {
114
+ decompress ( secureFilename , fileName ) . then ( async ( ) => {
101
115
// SECURITY FEATURE: delete all files in the folder that don't have one of the following extensions:
102
116
// .json, .jpg, .jpeg, .gif, .png, .csv
103
117
// TODO: Disabled until I can find a better regex
@@ -106,26 +120,84 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
106
120
// });
107
121
responseMessages . push ( { type : 'INFO' , message : `Uploaded files to product ${ fileName } ` } ) ;
108
122
logger ( 'INFO' , `Uploaded files to product ${ fileName } ` ) ;
109
-
123
+ // Initialize a new git repo if this is a new storyline.
124
+ // Otherwise, simply create a new commit with the zipped folder.
125
+ if ( ! newStorylines ) {
126
+ await commitToRepo ( fileName , user , false )
127
+ }
128
+ else {
129
+ await initGitRepo ( fileName , user )
130
+ }
110
131
// Finally, delete the uploaded zip file.
111
132
safeRM ( secureFilename , UPLOAD_PATH ) ;
112
-
133
+ const git = simpleGit ( fileName ) ;
134
+ const commits = await git . log ( ) ;
135
+ // Get the hash of the latest commit
136
+ const lastHash = commits . latest . hash ;
113
137
// Send a response back to the client.
114
- res . json ( { new : newStorylines } ) ;
138
+ res . json ( { new : newStorylines , commitHash : lastHash } ) ;
115
139
} ) ;
116
140
} ) ;
117
141
} ) ;
118
142
119
- // GET requests made to /retrieve/ID will be handled here.
120
- app . route ( ROUTE_PREFIX + '/retrieve/:id' ) . get ( function ( req , res , next ) {
143
+ // GET requests made to /retrieve/ID/commitHash will be handled here.
144
+ // Calling this with commitHash as "latest" simply fetches the product as normal.
145
+ app . route ( ROUTE_PREFIX + '/retrieve/:id/:hash' ) . get ( function ( req , res , next ) {
146
+ // This user is only needed for backwards compatibility.
147
+ // If we have an existing storylines product that is not a git repo, we need to initialize a git repo
148
+ // and make an initial commit for it, but we need the user for the commit.
149
+ const user = req . headers . user
150
+ if ( ! user ) {
151
+ // Send back error if the user uploading the storyline was not provided.
152
+ responseMessages . push ( {
153
+ type : 'WARNING' ,
154
+ message : 'Upload Aborted: the user uploading the form was not provided.'
155
+ } ) ;
156
+ logger ( 'WARNING' , 'Upload Aborted: the user uploading the form was not provided.' ) ;
157
+ res . status ( 400 ) . send ( { status : 'Bad Request' } ) ;
158
+ return ;
159
+ }
160
+
121
161
var archive = archiver ( 'zip' ) ;
122
162
const PRODUCT_PATH = `${ TARGET_PATH } /${ req . params . id } ` ;
123
163
const uploadLocation = `${ UPLOAD_PATH } /${ req . params . id } -outgoing.zip` ;
164
+ const commitHash = req . params . hash
124
165
125
166
// Check if the product exists.
126
167
if (
127
- fs . access ( PRODUCT_PATH , ( error ) => {
168
+ fs . access ( PRODUCT_PATH , async ( error ) => {
128
169
if ( ! error ) {
170
+ // Backwards compatibility. If the existing product is not a git repo i.e. it existed before git version control,
171
+ // we make a git repo for it before returning the version history. Otherwise, the code below will explode.
172
+ await initGitRepo ( PRODUCT_PATH , user )
173
+ const git = simpleGit ( PRODUCT_PATH ) ;
174
+ // Get the current branch. We do it this way instead of assuming its "main" in case someone has it set to master.
175
+ const branches = await git . branchLocal ( )
176
+ const currBranch = branches . current
177
+ if ( commitHash !== 'latest' ) {
178
+ // If the user does not ask for the latest commit, we checkout a new branch at the point of the requested commit,
179
+ // and then proceed with getting the zipped folder below.
180
+ try {
181
+ // First, we check if the requested commit exists.
182
+ // NOTE: When calling from frontend, the catch block should never run.
183
+ const commitExists = await git . catFile ( [ '-t' , commitHash ] ) ;
184
+ if ( commitExists !== 'commit\n' ) {
185
+ throw new Error ( )
186
+ }
187
+ } catch ( error ) {
188
+ responseMessages . push ( {
189
+ type : 'INFO' ,
190
+ message : `Access attempt to version ${ commitHash } of product ${ req . params . id } failed, does not exist.`
191
+ } ) ;
192
+ logger ( 'INFO' , `Access attempt to version ${ commitHash } of product ${ req . params . id } failed, does not exist.` ) ;
193
+ res . status ( 404 ) . send ( { status : 'Not Found' } ) ;
194
+ return ;
195
+ }
196
+ // Checkout a new branch at the point of the requested commit
197
+ // This will result in the code below returning the version's folder back to the client.
198
+ await git . checkoutBranch ( `version-${ commitHash } ` , commitHash ) ;
199
+ }
200
+
129
201
const output = fs . createWriteStream ( uploadLocation ) ;
130
202
// This event listener is fired when the write stream has finished. This means that the
131
203
// ZIP file should be correctly populated. Now, we can set the correct headers and send the
@@ -139,10 +211,18 @@ app.route(ROUTE_PREFIX + '/retrieve/:id').get(function (req, res, next) {
139
211
140
212
const result = fs . createReadStream ( uploadLocation ) . pipe ( res ) ;
141
213
142
- // When the piping is finished, delete the stream.
143
- result . on ( 'finish' , ( ) => {
214
+ // When the piping is finished, delete the stream and perform any git cleanup .
215
+ result . on ( 'finish' , async ( ) => {
144
216
fs . rm ( uploadLocation ) ;
217
+
218
+ if ( commitHash !== 'latest' ) {
219
+ // Since the user has not asked for the latest commit, we need to clean up.
220
+ // Go back to the main branch and delete the newly created branch.
221
+ await git . checkout ( currBranch ) ;
222
+ await git . deleteLocalBranch ( `version-${ commitHash } ` )
223
+ }
145
224
} ) ;
225
+
146
226
} ) ;
147
227
148
228
// Write the product data to the ZIP file.
@@ -155,6 +235,7 @@ app.route(ROUTE_PREFIX + '/retrieve/:id').get(function (req, res, next) {
155
235
156
236
responseMessages . push ( { type : 'INFO' , message : `Successfully loaded product ${ req . params . id } ` } ) ;
157
237
logger ( 'INFO' , `Successfully loaded product ${ req . params . id } ` ) ;
238
+
158
239
} else {
159
240
responseMessages . push ( {
160
241
type : 'INFO' ,
@@ -209,12 +290,110 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) {
209
290
) ;
210
291
} ) ;
211
292
293
+ app . route ( ROUTE_PREFIX + '/history/:id' ) . get ( function ( req , res , next ) {
294
+ // This user is only needed for backwards compatibility.
295
+ // If we have an existing storylines product that is not a git repo, we need to initialize a git repo
296
+ // and make an initial commit for it, but we need the user for the commit.
297
+ const user = req . headers . user
298
+ if ( ! user ) {
299
+ // Send back error if the user uploading the storyline was not provided.
300
+ responseMessages . push ( {
301
+ type : 'WARNING' ,
302
+ message : 'Upload Aborted: the user uploading the form was not provided.'
303
+ } ) ;
304
+ logger ( 'WARNING' , 'Upload Aborted: the user uploading the form was not provided.' ) ;
305
+ res . status ( 400 ) . send ( { status : 'Bad Request' } ) ;
306
+ return ;
307
+ }
308
+
309
+ const PRODUCT_PATH = `${ TARGET_PATH } /${ req . params . id } ` ;
310
+ // Check if the product exists.
311
+ fs . access ( PRODUCT_PATH , async ( error ) => {
312
+ if ( error ) {
313
+ responseMessages . push ( {
314
+ type : 'INFO' ,
315
+ message : `Access attempt to versions of ${ req . params . id } failed, does not exist.`
316
+ } ) ;
317
+ logger ( 'INFO' , `Access attempt to versions of ${ req . params . id } failed, does not exist.` ) ;
318
+ res . status ( 404 ) . send ( { status : 'Not Found' } ) ;
319
+ }
320
+ else {
321
+ // Backwards compatibility. If the existing product is not a git repo i.e. it existed before git version control,
322
+ // we make a git repo for it before returning the version history. Otherwise, the code below will explode.
323
+ await initGitRepo ( PRODUCT_PATH , user )
324
+ // Get version history for this product via git log command
325
+ const git = simpleGit ( PRODUCT_PATH ) ;
326
+ const log = await git . log ( )
327
+ // TODO: Remove the 10 version limit once pagination is implemented
328
+ const history = log . all . slice ( 0 , 10 ) . map ( ( commit ) => ( { hash : commit . hash , created : commit . date , storylineUUID : req . params . id } ) )
329
+ res . json ( history )
330
+ }
331
+ } )
332
+
333
+ } )
334
+
212
335
// GET reuests made to /retrieveMessages will recieve all the responseMessages currently queued.
213
336
app . route ( ROUTE_PREFIX + '/retrieveMessages' ) . get ( function ( req , res ) {
214
337
res . json ( { messages : responseMessages } ) ;
215
338
responseMessages . length = 0 ;
216
339
} ) ;
217
340
341
+ /*
342
+ * Initializes a git repo at the requested path, if one does not already exist.
343
+ * Creates an initial commit with any currently existing files in the directory.
344
+ *
345
+ * @param {string } path the path of the git repo
346
+ * @param {string } username the name of the user initializing the repo
347
+ */
348
+ async function initGitRepo ( path , username ) {
349
+ const git = simpleGit ( path ) ;
350
+ let repoExists = true ;
351
+ try {
352
+ // Check if the product directory is the top-level directory of a git repo.
353
+ // We need to do it this way because locally the storylines-editor is a git repo
354
+ // so simply checking for existence of a git repo is not sufficient.
355
+ const res = await git . raw ( 'rev-parse' , '--git-dir' ) ;
356
+ if ( res !== '.git\n' ) {
357
+ // Product directory is in a git repo but not top-level, we are working locally.
358
+ repoExists = false ;
359
+ }
360
+ } catch ( error ) {
361
+ // Product directory is not a git repo nor is it within a git repo.
362
+ repoExists = false ;
363
+ }
364
+
365
+ if ( ! repoExists ) {
366
+ // Repo does not exist for the storyline product.
367
+ // Initialize a git repo and add an initial commit with all existing files.
368
+ await git . init ( )
369
+ await commitToRepo ( path , username , true )
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Commits any existing files in the repo at the specified directory.
375
+ * Precondition: assumes that the specified directory is already a git repo.
376
+ * @param {string } path the path of the git repo
377
+ * @param {string } username the name of the user making the commit
378
+ * @param {boolean } initial specifies whether this is the initial commit
379
+ */
380
+ async function commitToRepo ( path , username , initial ) {
381
+ const date = moment ( ) . format ( 'YYYY-MM-DD' )
382
+ const time = moment ( ) . format ( 'hh:mm:ss a' )
383
+ // Initialize git
384
+ const git = simpleGit ( path ) ;
385
+ let versionNumber = 1
386
+ if ( ! initial ) {
387
+ // Compute version number for storyline if this is not the initial commit.
388
+ const log = await git . log ( )
389
+ const lastMessage = log . latest . message
390
+ versionNumber = lastMessage . split ( ' ' ) [ 3 ]
391
+ versionNumber = Number ( versionNumber ) + 1 ;
392
+ }
393
+ // Commit the files for this storyline to its repo.
394
+ await git . add ( './*' ) . commit ( `Add product version ${ versionNumber } on ${ date } at ${ time } ` , { '--author' : `"${ username } <>"` } )
395
+ }
396
+
218
397
/*
219
398
* Verifies that the file has a valid extension. If it's not valid, the file is removed.
220
399
*
0 commit comments