Skip to content

Commit 6ab1b8b

Browse files
authored
Merge pull request #422 from mohsin-r/git-version-hist
Implement git-based version history (#422)
2 parents 788dc7e + d0bda58 commit 6ab1b8b

File tree

7 files changed

+392
-126
lines changed

7 files changed

+392
-126
lines changed

server/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
node_modules/
22
public/*
33
!public/.gitignore
4+
files/*
5+
!files/.gitignore
46
logfile.txt
-1.66 KB
Binary file not shown.

server/index.js

Lines changed: 189 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ var cors = require('cors');
77
var moment = require('moment'); // require
88
const decompress = require('decompress');
99
const archiver = require('archiver');
10+
const simpleGit = require('simple-git');
1011
const responseMessages = [];
1112
require('dotenv').config();
1213

@@ -43,6 +44,19 @@ app.use(cors());
4344

4445
// POST requests made to /upload will be handled here.
4546
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+
4660
const options = {
4761
uploadDir: UPLOAD_PATH,
4862
keepExtensions: true,
@@ -56,7 +70,7 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
5670
//const projectNameRegex = /[a-zA-Z0-9]{8}(-[a-zA-Z0-9]{4}){3}-[a-zA-Z0-9]{12}/g;
5771

5872
// 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) {
6074
if (err) {
6175
responseMessages.push({
6276
type: 'WARNING',
@@ -97,7 +111,7 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
97111

98112
// Unzip the contents of the uploaded zip file into the target directory. Will overwrite
99113
// old files in the folder.
100-
decompress(secureFilename, fileName).then((files) => {
114+
decompress(secureFilename, fileName).then(async () => {
101115
// SECURITY FEATURE: delete all files in the folder that don't have one of the following extensions:
102116
// .json, .jpg, .jpeg, .gif, .png, .csv
103117
// TODO: Disabled until I can find a better regex
@@ -106,26 +120,84 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) {
106120
// });
107121
responseMessages.push({ type: 'INFO', message: `Uploaded files to product ${fileName}` });
108122
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+
}
110131
// Finally, delete the uploaded zip file.
111132
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;
113137
// Send a response back to the client.
114-
res.json({ new: newStorylines });
138+
res.json({ new: newStorylines, commitHash: lastHash });
115139
});
116140
});
117141
});
118142

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+
121161
var archive = archiver('zip');
122162
const PRODUCT_PATH = `${TARGET_PATH}/${req.params.id}`;
123163
const uploadLocation = `${UPLOAD_PATH}/${req.params.id}-outgoing.zip`;
164+
const commitHash = req.params.hash
124165

125166
// Check if the product exists.
126167
if (
127-
fs.access(PRODUCT_PATH, (error) => {
168+
fs.access(PRODUCT_PATH, async (error) => {
128169
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+
129201
const output = fs.createWriteStream(uploadLocation);
130202
// This event listener is fired when the write stream has finished. This means that the
131203
// 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) {
139211

140212
const result = fs.createReadStream(uploadLocation).pipe(res);
141213

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 () => {
144216
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+
}
145224
});
225+
146226
});
147227

148228
// Write the product data to the ZIP file.
@@ -155,6 +235,7 @@ app.route(ROUTE_PREFIX + '/retrieve/:id').get(function (req, res, next) {
155235

156236
responseMessages.push({ type: 'INFO', message: `Successfully loaded product ${req.params.id}` });
157237
logger('INFO', `Successfully loaded product ${req.params.id}`);
238+
158239
} else {
159240
responseMessages.push({
160241
type: 'INFO',
@@ -209,12 +290,110 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) {
209290
);
210291
});
211292

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+
212335
// GET reuests made to /retrieveMessages will recieve all the responseMessages currently queued.
213336
app.route(ROUTE_PREFIX + '/retrieveMessages').get(function (req, res) {
214337
res.json({ messages: responseMessages });
215338
responseMessages.length = 0;
216339
});
217340

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+
218397
/*
219398
* Verifies that the file has a valid extension. If it's not valid, the file is removed.
220399
*

server/package-lock.json

Lines changed: 78 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"fs-extra": "^11.1.0",
2121
"moment": "^2.29.4",
2222
"path": "^0.12.7",
23-
"recursive-readdir": "^2.2.3"
23+
"recursive-readdir": "^2.2.3",
24+
"simple-git": "^3.27.0"
2425
}
2526
}

0 commit comments

Comments
 (0)