diff --git a/server/files/884889330141e08c53e860b00.zip b/server/files/884889330141e08c53e860b00.zip deleted file mode 100644 index f0d34e6d..00000000 Binary files a/server/files/884889330141e08c53e860b00.zip and /dev/null differ diff --git a/server/index.js b/server/index.js index 499952d2..072d7558 100644 --- a/server/index.js +++ b/server/index.js @@ -7,6 +7,7 @@ var cors = require('cors'); var moment = require('moment'); // require const decompress = require('decompress'); const archiver = require('archiver'); +const simpleGit = require('simple-git'); const responseMessages = []; require('dotenv').config(); @@ -43,6 +44,19 @@ app.use(cors()); // POST requests made to /upload will be handled here. app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) { + // 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; + } + const options = { uploadDir: UPLOAD_PATH, keepExtensions: true, @@ -56,7 +70,7 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) { //const projectNameRegex = /[a-zA-Z0-9]{8}(-[a-zA-Z0-9]{4}){3}-[a-zA-Z0-9]{12}/g; // Upload the file to the server, into the /files/ folder. - form.parse(req, function (err, field, file) { + form.parse(req, async function (err, field, file) { if (err) { responseMessages.push({ type: 'WARNING', @@ -92,12 +106,15 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) { responseMessages.push({ type: 'INFO', message: `Successfully created new product ${fileName}` }); logger('INFO', `Successfully created new product ${fileName}`); fs.mkdirSync(fileName); + // Initialize a new git repo in the storyline's folder + const git = simpleGit(fileName); + await git.init(); newStorylines = true; } // Unzip the contents of the uploaded zip file into the target directory. Will overwrite // old files in the folder. - decompress(secureFilename, fileName).then((files) => { + decompress(secureFilename, fileName).then(async () => { // SECURITY FEATURE: delete all files in the folder that don't have one of the following extensions: // .json, .jpg, .jpeg, .gif, .png, .csv // TODO: Disabled until I can find a better regex @@ -107,25 +124,67 @@ app.route(ROUTE_PREFIX + '/upload').post(function (req, res, next) { responseMessages.push({ type: 'INFO', message: `Uploaded files to product ${fileName}` }); logger('INFO', `Uploaded files to product ${fileName}`); + const date = moment().format('YYYY-MM-DD') + const time = moment().format('hh:mm:ss a') + // Initialize git + const git = simpleGit(fileName); + let versionNumber = 1; + // We check if this is not the first save, otherwise the git rev-list command will error + if (!newStorylines) { + // Compute version number for storyline + versionNumber = await git.raw('rev-list', '--count', 'HEAD'); + versionNumber = Number(versionNumber[0]) + 1; + } + // Commit the files for this storyline to its repo + await git.add('./*').commit(`Add product version ${versionNumber} on ${date} at ${time}`, {'--author': `"${user} <>"`}) // Finally, delete the uploaded zip file. safeRM(secureFilename, UPLOAD_PATH); - + // Get the hash of the latest commit + const commits = await git.log(); + const lastHash = commits.latest.hash; // Send a response back to the client. - res.json({ new: newStorylines }); + res.json({ new: newStorylines, commitHash: lastHash }); }); }); }); -// GET requests made to /retrieve/ID will be handled here. -app.route(ROUTE_PREFIX + '/retrieve/:id').get(function (req, res, next) { +// GET requests made to /retrieve/ID/commitHash will be handled here. +// Callimg this with commitHash as "latest" simply fetches the product as normal. +app.route(ROUTE_PREFIX + '/retrieve/:id/:hash').get(function (req, res, next) { var archive = archiver('zip'); const PRODUCT_PATH = `${TARGET_PATH}/${req.params.id}`; const uploadLocation = `${UPLOAD_PATH}/${req.params.id}-outgoing.zip`; + const commitHash = req.params.hash // Check if the product exists. if ( - fs.access(PRODUCT_PATH, (error) => { + fs.access(PRODUCT_PATH, async (error) => { if (!error) { + const git = simpleGit(PRODUCT_PATH); + if (commitHash !== 'latest') { + // If the user does not ask for the latest commit, we checkout a new branch at the point of the requested commit, + // and then proceed with getting the zipped folder below. + try { + // First, we check if the requested commit exists. + // NOTE: When calling from frontend, the catch block should never run. + const commitExists = await git.catFile(['-t', commitHash]); + if (commitExists !== 'commit\n') { + throw new Error() + } + } catch (error) { + responseMessages.push({ + type: 'INFO', + message: `Access attempt to version ${commitHash} of product ${req.params.id} failed, does not exist.` + }); + logger('INFO', `Access attempt to version ${commitHash} of product ${req.params.id} failed, does not exist.`); + res.status(404).send({ status: 'Not Found' }); + return; + } + // Checkout a new branch at the point of the requested commit + // This will result in the code below returning the version's folder back to the client. + await git.checkoutBranch(`version-${commitHash}`, commitHash); + } + const output = fs.createWriteStream(uploadLocation); // This event listener is fired when the write stream has finished. This means that the // ZIP file should be correctly populated. Now, we can set the correct headers and send the @@ -155,6 +214,13 @@ app.route(ROUTE_PREFIX + '/retrieve/:id').get(function (req, res, next) { responseMessages.push({ type: 'INFO', message: `Successfully loaded product ${req.params.id}` }); logger('INFO', `Successfully loaded product ${req.params.id}`); + + if (commitHash !== 'latest') { + // Since the user has not asked for the latest commit, we need to clean up. + // Go back to the main branch and delete the newly created branch. + await git.checkout('main'); + await git.deleteLocalBranch(`version-${commitHash}`) + } } else { responseMessages.push({ type: 'INFO', @@ -209,6 +275,29 @@ app.route(ROUTE_PREFIX + '/retrieve/:id/:lang').get(function (req, res) { ); }); +app.route(ROUTE_PREFIX + '/history/:id').get(function (req, res, next) { + const PRODUCT_PATH = `${TARGET_PATH}/${req.params.id}`; + // 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 { + // Get version history for this product via git log command + const git = simpleGit(PRODUCT_PATH); + const log = await git.log() + const history = log.all.map((commit) => ({hash: commit.hash, created: commit.date, storylineUUID: req.params.id})) + res.json(history) + } + }) + +}) + // GET reuests made to /retrieveMessages will recieve all the responseMessages currently queued. app.route(ROUTE_PREFIX + '/retrieveMessages').get(function (req, res) { res.json({ messages: responseMessages }); diff --git a/server/package-lock.json b/server/package-lock.json index e31c50f4..2649074f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -20,9 +20,48 @@ "fs-extra": "^11.1.0", "moment": "^2.29.4", "path": "^0.12.7", - "recursive-readdir": "^2.2.3" + "recursive-readdir": "^2.2.3", + "simple-git": "^3.27.0" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/file-exists/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@kwsites/file-exists/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1525,6 +1564,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-git": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.27.0.tgz", + "integrity": "sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-git/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/simple-git/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", diff --git a/server/package.json b/server/package.json index 9d4f7bfb..205243e5 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "fs-extra": "^11.1.0", "moment": "^2.29.4", "path": "^0.12.7", - "recursive-readdir": "^2.2.3" + "recursive-readdir": "^2.2.3", + "simple-git": "^3.27.0" } } diff --git a/src/components/metadata-editor.vue b/src/components/metadata-editor.vue index 873d1d42..097c0d48 100644 --- a/src/components/metadata-editor.vue +++ b/src/components/metadata-editor.vue @@ -111,10 +111,10 @@