diff --git a/.ebextensions/nginx-max-body-size.config b/.ebextensions/nginx-max-body-size.config new file mode 100644 index 000000000..fdf7dacca --- /dev/null +++ b/.ebextensions/nginx-max-body-size.config @@ -0,0 +1,11 @@ +files: + "/etc/nginx/conf.d/proxy.conf" : + mode: "000755" + owner: root + group: root + content: | + client_max_body_size 10M; + +commands: + 00_reload_nginx: + command: "service nginx reload" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c92d71ce0..e0c8ea01b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - name: Install NPM run: npm ci - name: Zip application - run: zip -r "deploy.zip" * -x .env-example .gitignore package-lock.json + run: zip -r "deploy.zip" * .ebextensions -x .env-example .gitignore package-lock.json - name: Get timestamp shell: bash run: echo "##[set-output name=timestamp;]$(env TZ=Asia/Singapore date '+%Y%m%d%H%M%S')" diff --git a/classes/Collection.js b/classes/Collection.js index be5519057..378a27618 100644 --- a/classes/Collection.js +++ b/classes/Collection.js @@ -4,7 +4,10 @@ const Bluebird = require('bluebird') const _ = require('lodash') const { Config } = require('./Config.js') -const { File, CollectionPageType } = require('./File.js') +const { File, CollectionPageType, DataType } = require('./File.js') +const { getCommitAndTreeSha, getTree, sendTree, deslugifyCollectionName } = require('../utils/utils.js') + +const NAV_FILE_NAME = 'navigation.yml' class Collection { constructor(accessToken, siteName) { @@ -32,14 +35,27 @@ class Collection { // TO-DO: Verify that collection doesn't already exist - contentObject.collections[`${collectionName}`] = { - permalink: '/:collection/:path/:title', + contentObject.collections[`${collectionName}`] = { output: true } const newContent = base64.encode(yaml.safeDump(contentObject)) await config.update(newContent, sha) + const nav = new File(this.accessToken, this.siteName) + const dataType = new DataType() + nav.setFileType(dataType) + const { content:navContent, sha:navSha } = await nav.read(NAV_FILE_NAME) + const navContentObject = yaml.safeLoad(base64.decode(navContent)) + + navContentObject.links.push({ + title: deslugifyCollectionName(collectionName), + collection: collectionName + }) + const newNavContent = base64.encode(yaml.safeDump(navContentObject)) + + await nav.update(NAV_FILE_NAME, newNavContent, navSha) + } catch (err) { throw err } @@ -57,6 +73,21 @@ class Collection { await config.update(newContent, sha) + // Delete collection in nav if it exists + const nav = new File(this.accessToken, this.siteName) + const dataType = new DataType() + nav.setFileType(dataType) + const { content:navContent, sha:navSha } = await nav.read(NAV_FILE_NAME) + const navContentObject = yaml.safeLoad(base64.decode(navContent)) + + const newNavLinks = navContentObject.links.filter(link => link.collection !== collectionName) + const newNavContentObject = { + ...navContentObject, + links: newNavLinks, + } + const newNavContent = base64.encode(yaml.safeDump(newNavContentObject)) + await nav.update(NAV_FILE_NAME, newNavContent, navSha) + // Get all collectionPages const IsomerFile = new File(this.accessToken, this.siteName) const collectionPageType = new CollectionPageType(collectionName) @@ -66,7 +97,7 @@ class Collection { if (!_.isEmpty(collectionPages)) { // Delete all collectionPages await Bluebird.map(collectionPages, async(collectionPage) => { - let pageName = collectionPage.pageName + let pageName = collectionPage.fileName const { sha } = await IsomerFile.read(pageName) return IsomerFile.delete(pageName, sha) }) @@ -78,13 +109,13 @@ class Collection { async rename(oldCollectionName, newCollectionName) { try { + const commitMessage = `Rename collection from ${oldCollectionName} to ${newCollectionName}` // Rename collection in config const config = new Config(this.accessToken, this.siteName) const { content, sha } = await config.read() const contentObject = yaml.safeLoad(base64.decode(content)) - contentObject.collections[`${newCollectionName}`] = { - permalink: '/:collection/:path/:title', + contentObject.collections[`${newCollectionName}`] = { output: true } delete contentObject.collections[`${oldCollectionName}`] @@ -92,28 +123,45 @@ class Collection { await config.update(newContent, sha) - // Get all collectionPages - const OldIsomerFile = new File(this.accessToken, this.siteName) - const oldCollectionPageType = new CollectionPageType(oldCollectionName) - OldIsomerFile.setFileType(oldCollectionPageType) - const collectionPages = await OldIsomerFile.list() - - // If the object is empty (there are no pages in the collection), do nothing - if (_.isEmpty(collectionPages)) return - - // Set up new collection File instance - const NewIsomerFile = new File(this.accessToken, this.siteName) - const newCollectionPageType = new CollectionPageType(newCollectionName) - NewIsomerFile.setFileType(newCollectionPageType) - - // Rename all collectionPages - await Bluebird.map(collectionPages, async(collectionPage) => { - let pageName = collectionPage.fileName - const { content, sha } = await OldIsomerFile.read(pageName) - await OldIsomerFile.delete(pageName, sha) - return NewIsomerFile.create(pageName, content) + // Rename collection in nav if it exists + const nav = new File(this.accessToken, this.siteName) + const dataType = new DataType() + nav.setFileType(dataType) + const { content:navContent, sha:navSha } = await nav.read(NAV_FILE_NAME) + const navContentObject = yaml.safeLoad(base64.decode(navContent)) + + const newNavLinks = navContentObject.links.map(link => { + if (link.collection === oldCollectionName) { + return { + title: deslugifyCollectionName(newCollectionName), + collection: newCollectionName + } + } else { + return link + } + }) + const newNavContentObject = { + ...navContentObject, + links: newNavLinks, + } + const newNavContent = base64.encode(yaml.safeDump(newNavContentObject)) + await nav.update(NAV_FILE_NAME, newNavContent, navSha) + + const { currentCommitSha, treeSha } = await getCommitAndTreeSha(this.siteName, this.accessToken) + const gitTree = await getTree(this.siteName, this.accessToken, treeSha); + const oldCollectionDirectoryName = `_${oldCollectionName}` + const newCollectionDirectoryName = `_${newCollectionName}` + const newGitTree = gitTree.map(item => { + if (item.path === oldCollectionDirectoryName) { + return { + ...item, + path: newCollectionDirectoryName + } + } else { + return item + } }) - + await sendTree(newGitTree, currentCommitSha, this.siteName, this.accessToken, commitMessage); } catch (err) { throw err } diff --git a/classes/File.js b/classes/File.js index ff16048ff..24ecb7706 100644 --- a/classes/File.js +++ b/classes/File.js @@ -3,7 +3,8 @@ const _ = require('lodash') const validateStatus = require('../utils/axios-utils') // Import error -const { NotFoundError } = require('../errors/NotFoundError') +const { NotFoundError } = require('../errors/NotFoundError') +const { ConflictError, inputNameConflictErrorMsg } = require('../errors/ConflictError') const GITHUB_ORG_NAME = process.env.GITHUB_ORG_NAME const BRANCH_REF = process.env.BRANCH_REF @@ -78,14 +79,18 @@ class File { return { sha: resp.data.content.sha } } catch (err) { - throw err + const status = err.response.status + if (status === 422 || status === 409) throw new ConflictError(inputNameConflictErrorMsg(fileName)) + throw err.response } } async read(fileName) { try { const files = await this.list() + if (_.isEmpty(files)) throw new NotFoundError ('File does not exist') const fileToRead = files.filter((file) => file.fileName === fileName)[0] + if (fileToRead === undefined) throw new NotFoundError ('File does not exist') const endpoint = `${this.baseBlobEndpoint}/${fileToRead.sha}` const params = { @@ -100,9 +105,7 @@ class File { "Content-Type": "application/json" } }) - - if (resp.status === 404) throw new NotFoundError ('File does not exist') - + const { content, sha } = resp.data return { content, sha } @@ -131,6 +134,8 @@ class File { return { newSha: resp.data.commit.sha } } catch (err) { + const status = err.response.status + if (status === 404) throw new NotFoundError ('File does not exist') throw err } } @@ -153,6 +158,8 @@ class File { } }) } catch (err) { + const status = err.response.status + if (status === 404) throw new NotFoundError ('File does not exist') throw err } } diff --git a/classes/Resource.js b/classes/Resource.js index 2711d62c9..76d0fcd27 100644 --- a/classes/Resource.js +++ b/classes/Resource.js @@ -4,6 +4,7 @@ const _ = require('lodash') // Import classes const { File, ResourceCategoryType, ResourcePageType } = require('../classes/File.js') const { Directory, ResourceRoomType } = require('../classes/Directory.js') +const { getCommitAndTreeSha, getTree, sendTree } = require('../utils/utils.js') // Constants const RESOURCE_INDEX_PATH = 'index.html' @@ -38,42 +39,37 @@ class Resource { } } - async rename(resourceRoomName, resourceName, newResourceRoomName, newResourceName) { + async rename(resourceRoomName, resourceName, newResourceName) { try { - // Delete old index file in old resource - const OldIsomerIndexFile = new File(this.accessToken, this.siteName) - const resourceType = new ResourceCategoryType(resourceRoomName, resourceName) - OldIsomerIndexFile.setFileType(resourceType) - const { sha: oldSha } = await OldIsomerIndexFile.read(`${RESOURCE_INDEX_PATH}`) - await OldIsomerIndexFile.delete(`${RESOURCE_INDEX_PATH}`, oldSha) - - // Create new index file in new resource - const NewIsomerIndexFile = new File(this.accessToken, this.siteName) - const newResourceType = new ResourceCategoryType(newResourceRoomName, newResourceName) - NewIsomerIndexFile.setFileType(newResourceType) - await NewIsomerIndexFile.create(`${RESOURCE_INDEX_PATH}`, RESOURCE_INDEX_CONTENT) - - // Rename resourcePages - const OldIsomerFile = new File(this.accessToken, this.siteName) - const resourcePageType = new ResourcePageType(resourceRoomName, resourceName) - OldIsomerFile.setFileType(resourcePageType) - - const NewIsomerFile = new File(this.accessToken, this.siteName) - const newResourcePageType = new ResourcePageType(newResourceRoomName, newResourceName) - NewIsomerFile.setFileType(newResourcePageType) - - // 1. List all resourcePages in old resource - const resourcePages = await OldIsomerFile.list() - - if (_.isEmpty(resourcePages)) return - - await Bluebird.each(resourcePages, async(resourcePage) => { - // 2. Create new resourcePages in newResource - const { content, sha } = await OldIsomerFile.read(resourcePage.fileName) - await NewIsomerFile.create(resourcePage.fileName, content) - // 3. Delete all resourcePages in resource - return OldIsomerFile.delete(resourcePage.fileName, sha) + const commitMessage = `Rename resource category from ${resourceName} to ${newResourceName}` + const { currentCommitSha, treeSha } = await getCommitAndTreeSha(this.siteName, this.accessToken) + const gitTree = await getTree(this.siteName, this.accessToken, treeSha); + let newGitTree = [] + let resourceRoomTreeSha + // Retrieve all git trees of other items + gitTree.forEach((item) => { + if (item.path === resourceRoomName) { + resourceRoomTreeSha = item.sha + } else { + newGitTree.push(item) + } + }) + const resourceRoomTree = await getTree(this.siteName, this.accessToken, resourceRoomTreeSha) + resourceRoomTree.forEach(item => { + // We need to append resource room to the file path because the path is relative to the subtree + if (item.path === resourceName) { + newGitTree.push({ + ...item, + path: `${resourceRoomName}/${newResourceName}` + }) + } else { + newGitTree.push({ + ...item, + path: `${resourceRoomName}/${item.path}` + }) + } }) + await sendTree(newGitTree, currentCommitSha, this.siteName, this.accessToken, commitMessage); } catch (err) { throw err } diff --git a/classes/ResourceRoom.js b/classes/ResourceRoom.js index 97a59ebe4..e14df3372 100644 --- a/classes/ResourceRoom.js +++ b/classes/ResourceRoom.js @@ -6,11 +6,13 @@ const _ = require('lodash') // Import Classes const { Config } = require('./Config.js') const { Resource } = require('../classes/Resource.js') -const { File, ResourceType } = require('../classes/File.js') +const { File, ResourceType, DataType } = require('../classes/File.js') +const { getCommitAndTreeSha, getTree, sendTree, deslugifyCollectionName } = require('../utils/utils.js') // Constants const RESOURCE_ROOM_INDEX_PATH = 'index.html' const RESOURCE_ROOM_INDEX_CONTENT = 'LS0tCmxheW91dDogcmVzb3VyY2VzCnRpdGxlOiBSZXNvdXJjZSBSb29tCi0tLQ==' +const NAV_FILE_NAME = 'navigation.yml' class ResourceRoom { constructor(accessToken, siteName) { @@ -48,6 +50,20 @@ class ResourceRoom { await config.update(newContent, sha) + const nav = new File(this.accessToken, this.siteName) + const dataType = new DataType() + nav.setFileType(dataType) + const { content:navContent, sha:navSha } = await nav.read(NAV_FILE_NAME) + const navContentObject = yaml.safeLoad(base64.decode(navContent)) + + navContentObject.links.push({ + title: deslugifyCollectionName(resourceRoom), + resource_room: true + }) + const newNavContent = base64.encode(yaml.safeDump(navContentObject)) + + await nav.update(NAV_FILE_NAME, newNavContent, navSha) + return resourceRoom } catch (err) { throw err @@ -56,6 +72,8 @@ class ResourceRoom { async rename(newResourceRoom) { try { + const commitMessage = `Rename resource room from ${resourceRoomName} to ${newResourceRoom}` + // Add resource room to config const config = new Config(this.accessToken, this.siteName) const { content, sha } = await config.read() const contentObject = yaml.safeLoad(base64.decode(content)) @@ -63,30 +81,45 @@ class ResourceRoom { // Obtain existing resourceRoomName const resourceRoomName = contentObject.resources_name contentObject.resources_name = newResourceRoom - const newContent = base64.encode(yaml.safeDump(contentObject)) - - // Delete all resources and resourcePages - const IsomerResource = new Resource(this.accessToken, this.siteName) - const resources = await IsomerResource.list(resourceRoomName) - - // Create index file in resourceRoom - const NewIsomerIndexFile = new File(this.accessToken, this.siteName) - const newResourceType = new ResourceType(newResourceRoom) - NewIsomerIndexFile.setFileType(newResourceType) - await NewIsomerIndexFile.create(RESOURCE_ROOM_INDEX_PATH, RESOURCE_ROOM_INDEX_CONTENT) - - // Delete index file in resourceRoom - const IsomerIndexFile = new File(this.accessToken, this.siteName) - const resourceType = new ResourceType(resourceRoomName) - IsomerIndexFile.setFileType(resourceType) - const { sha: deleteSha } = await IsomerIndexFile.read(RESOURCE_ROOM_INDEX_PATH) - await IsomerIndexFile.delete(RESOURCE_ROOM_INDEX_PATH, deleteSha) - - if (!_.isEmpty(resources)) { - await Bluebird.map(resources, async(resource) => { - return IsomerResource.rename(resourceRoomName, resource.dirName, newResourceRoom, resource.dirName) - }) + const newContent = base64.encode(yaml.safeDump(contentObject)) + + // Rename resource room in nav if it exists + const nav = new File(this.accessToken, this.siteName) + const dataType = new DataType() + nav.setFileType(dataType) + const { content:navContent, sha:navSha } = await nav.read(NAV_FILE_NAME) + const navContentObject = yaml.safeLoad(base64.decode(navContent)) + + const newNavLinks = navContentObject.links.map(link => { + if (link.resource_room === true) { + return { + title: deslugifyCollectionName(newResourceRoom), + resource_room: true + } + } else { + return link + } + }) + const newNavContentObject = { + ...navContentObject, + links: newNavLinks, } + const newNavContent = base64.encode(yaml.safeDump(newNavContentObject)) + await nav.update(NAV_FILE_NAME, newNavContent, navSha) + + const { currentCommitSha, treeSha } = await getCommitAndTreeSha(this.siteName, this.accessToken) + const gitTree = await getTree(this.siteName, this.accessToken, treeSha); + const newGitTree = gitTree.map(item => { + if (item.path === resourceRoomName) { + return { + ...item, + path: newResourceRoom + } + } else { + return item + } + }) + await sendTree(newGitTree, currentCommitSha, this.siteName, this.accessToken, commitMessage); await config.update(newContent, sha) @@ -98,7 +131,7 @@ class ResourceRoom { async delete() { try { - // Delete collection in config + // Delete resource in config const config = new Config(this.accessToken, this.siteName) const { content, sha } = await config.read() const contentObject = yaml.safeLoad(base64.decode(content)) @@ -108,7 +141,23 @@ class ResourceRoom { // Delete resourcses_name from Config delete contentObject.resources_name - const newContent = base64.encode(yaml.safeDump(contentObject)) + const newContent = base64.encode(yaml.safeDump(contentObject)) + + // Delete resource room in nav if it exists + const nav = new File(this.accessToken, this.siteName) + const dataType = new DataType() + nav.setFileType(dataType) + const { content:navContent, sha:navSha } = await nav.read(NAV_FILE_NAME) + const navContentObject = yaml.safeLoad(base64.decode(navContent)) + + // Assumption: only a single resource room exists + const newNavLinks = navContentObject.links.filter(link => link.resource_room !== true) + const newNavContentObject = { + ...navContentObject, + links: newNavLinks, + } + const newNavContent = base64.encode(yaml.safeDump(newNavContentObject)) + await nav.update(NAV_FILE_NAME, newNavContent, navSha) // Delete all resources and resourcePages const IsomerResource = new Resource(this.accessToken, this.siteName) diff --git a/classes/Settings.js b/classes/Settings.js index 16dcea754..d68619900 100644 --- a/classes/Settings.js +++ b/classes/Settings.js @@ -1,13 +1,71 @@ const { Base64 } = require('js-base64') +const _ = require('lodash') const yaml = require('js-yaml') const Bluebird = require('bluebird') // import classes const { Config } = require('../classes/Config.js') -const { File, DataType } = require('../classes/File.js') +const { File, DataType, HomepageType } = require('../classes/File.js') // Constants const FOOTER_PATH = 'footer.yml' +const NAVIGATION_PATH = 'navigation.yml' +const HOMEPAGE_INDEX_PATH = 'index.md' // Empty string + +const retrieveSettingsFiles = async (accessToken, siteName, shouldRetrieveHomepage) => { + const configResp = new Config(accessToken, siteName) + + const FooterFile = new File(accessToken, siteName) + const dataType = new DataType() + FooterFile.setFileType(dataType) + + const NavigationFile = new File(accessToken, siteName) + NavigationFile.setFileType(dataType) + + const HomepageFile = new File(accessToken, siteName) + const homepageType = new HomepageType() + HomepageFile.setFileType(homepageType) + + const fileRetrievalObj = { + config: configResp.read(), + footer: FooterFile.read(FOOTER_PATH), + navigation: NavigationFile.read(NAVIGATION_PATH), + } + + // Retrieve homepage only if flag is set to true + if (shouldRetrieveHomepage) { + fileRetrievalObj.homepage = HomepageFile.read(HOMEPAGE_INDEX_PATH); + } + + + const fileContentsArr = await Bluebird.map(Object.keys(fileRetrievalObj), async (fileOpKey) => { + const { content, sha } = await fileRetrievalObj[fileOpKey] + + // homepage requires special extraction as the content is wrapped in front matter + if (fileOpKey === 'homepage') { + const homepageContent = Base64.decode(content) + const homepageFrontMatterObj = yaml.safeLoad(homepageContent.split('---')[1]) + return { type: fileOpKey, content: homepageFrontMatterObj, sha } + } + + return { type: fileOpKey, content: yaml.safeLoad(Base64.decode(content)), sha } + }) + + // Convert to an object so that data is accessible by key + const fileContentsObj = {} + fileContentsArr.forEach((fileObj) => { + const { type, content, sha } = fileObj + fileContentsObj[type] = { content, sha } + }) + + return { + configResp, + FooterFile, + NavigationFile, + HomepageFile, + fileContentsObj, + } +} class Settings { constructor(accessToken, siteName) { @@ -16,66 +74,137 @@ class Settings { } async get() { - // retrieve _config.yml and footer.yml - const configResp = new Config(this.accessToken, this.siteName) - - const IsomerDataFile = new File(this.accessToken, this.siteName) - const dataType = new DataType() - IsomerDataFile.setFileType(dataType) - - const fileRetrievalArr = [configResp.read(), IsomerDataFile.read(FOOTER_PATH)] - - const fileContentsArr = await Bluebird.map(fileRetrievalArr, async (fileOp) => { - const { content, sha } = await fileOp - return { content, sha} - }) + const { fileContentsObj: { + config, + footer, + navigation, + } } = await retrieveSettingsFiles(this.accessToken, this.siteName) // convert data to object form - const configContent = fileContentsArr[0].content - const footerContent = fileContentsArr[1].content - - const configReadableContent = yaml.safeLoad(Base64.decode(configContent)); - const footerReadableContent = yaml.safeLoad(Base64.decode(footerContent)); + const configContent = config.content; + const footerContent = footer.content; + const navigationContent = navigation.content; // retrieve only the relevant config and index fields const configFieldsRequired = { - url: configReadableContent.url, - title: configReadableContent.title, - favicon: configReadableContent.favicon, - resources_name: configReadableContent.resources_name, - colors: configReadableContent.colors, + url: configContent.url, + title: configContent.title, + favicon: configContent.favicon, + shareicon: configContent.shareicon, + is_government: configContent.is_government, + facebook_pixel: configContent['facebook-pixel'], + google_analytics: configContent.google_analytics, + resources_name: configContent.resources_name, + colors: configContent.colors, } // retrieve footer sha since we are sending the footer object wholesale - const footerSha = fileContentsArr[1].sha + const footerSha = footer.sha - return ({ configFieldsRequired, footerContent: footerReadableContent, footerSha }) + return ({ + configFieldsRequired, + footerContent, + navigationContent: { logo: navigationContent.logo }, + footerSha, + }) } async post(payload) { - // setup - const configResp = new Config(this.accessToken, this.siteName) - const config = await configResp.read() - const IsomerDataFile = new File(this.accessToken, this.siteName) - const dataType = new DataType() - IsomerDataFile.setFileType(dataType) + const { + configResp, + FooterFile, + NavigationFile, + HomepageFile, + fileContentsObj: { + config, + footer, + navigation, + homepage, + }, + } = await retrieveSettingsFiles(this.accessToken, this.siteName, true) // extract data const { footerSettings, configSettings, - footerSha, + navigationSettings, } = payload - // update config object - const configContent = yaml.safeLoad(Base64.decode(config.content)); - Object.keys(configSettings).forEach((setting) => (configContent[setting] = configSettings[setting])); + // update settings objects + const configContent = config.content + const footerContent = footer.content + const navigationContent = navigation.content + + const settingsObj = {} - // update files - const newConfigContent = Base64.encode(yaml.safeDump(configContent)) - const newFooterContent = Base64.encode(yaml.safeDump(footerSettings)) - await configResp.update(newConfigContent, config.sha) - await IsomerDataFile.update(FOOTER_PATH, newFooterContent, footerSha) + if (!_.isEmpty(configSettings)) { + settingsObj.config = { + payload: configSettings, + currentData: configContent, + } + } + + if (!_.isEmpty(footerSettings)) { + settingsObj.footer = { + payload: footerSettings, + currentData: footerContent, + } + } + + if (!_.isEmpty(navigationSettings)) { + settingsObj.navigation = { + payload: navigationSettings, + currentData: navigationContent, + } + } + + const updatedSettingsObjArr = Object.keys(settingsObj).map((settingsObjKey) => { + const { payload, currentData } = settingsObj[settingsObjKey] + const clonedSettingsObj = _.cloneDeep(currentData); + Object.keys(payload).forEach((setting) => (clonedSettingsObj[setting] = payload[setting])); + return { + type: settingsObjKey, + settingsObj: clonedSettingsObj, + } + }) + + const updatedSettingsObj = {} + updatedSettingsObjArr.forEach((setting) => { + const { type, settingsObj } = setting + updatedSettingsObj[`${type}SettingsObj`] = settingsObj + }) + + const { configSettingsObj, footerSettingsObj, navigationSettingsObj } = updatedSettingsObj + + // To-do: use Git Tree to speed up operations + if (!_.isEmpty(configSettings)) { + const newConfigContent = Base64.encode(yaml.safeDump(configSettingsObj)) + await configResp.update(newConfigContent, config.sha) + + // Update title in homepage as well if it's changed + if (configContent.title !== configSettingsObj.title) { + const { content: homepageContentObj, sha } = homepage; + + homepageContentObj.title = configSettings.title; + const homepageFrontMatter = yaml.safeDump(homepageContentObj); + + const homepageContent = ['---\n', homepageFrontMatter, '---'].join('') ; + const newHomepageContent = Base64.encode(homepageContent) + + await HomepageFile.update(HOMEPAGE_INDEX_PATH, newHomepageContent, sha) + } + } + + if (!_.isEmpty(footerSettings)) { + const newFooterContent = Base64.encode(yaml.safeDump(footerSettingsObj)) + await FooterFile.update(FOOTER_PATH, newFooterContent, footer.sha) + } + + if (!_.isEmpty(navigationSettings)) { + const newNavigationContent = Base64.encode(yaml.safeDump(navigationSettingsObj)) + await NavigationFile.update(NAVIGATION_PATH, newNavigationContent, navigation.sha) + } + return } } diff --git a/docs/openapi.yaml b/docs/openapi.yaml index fbc67a8a7..44b7600b3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -955,6 +955,23 @@ paths: application/json: schema: $ref: "#/components/schemas/SiteListResponse" + /sites/{siteName}: + get: + tags: + - Sites + parameters: + - name: siteName + in: path + required: true + schema: + type: string + description: Checks if site exists and user has write access + responses: + 200: + description: Success + 404: + description: Not found + /v1/sites/{siteName}/resource-room: get: diff --git a/errors/ConflictError.js b/errors/ConflictError.js new file mode 100644 index 000000000..bfbbc944f --- /dev/null +++ b/errors/ConflictError.js @@ -0,0 +1,17 @@ +// Import base error +const { BaseIsomerError } = require('./BaseError') + +const inputNameConflictErrorMsg = (fileName) => `A file with ${fileName} already exists.` + +class ConflictError extends BaseIsomerError { + constructor (fileName) { + super( + 409, + `A file with ${fileName} already exists.` + ) + } +} +module.exports = { + ConflictError, + inputNameConflictErrorMsg, +} \ No newline at end of file diff --git a/middleware/auth.js b/middleware/auth.js index 99dd0b07e..42714401c 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -72,7 +72,7 @@ auth.post('/v1/sites/:siteName/documents/:documentName/rename/:newDocumentName', // Images auth.get('/v1/sites/:siteName/images', verifyJwt) -auth.post('v/sites/:siteName/images', verifyJwt) +auth.post('/v1/sites/:siteName/images', verifyJwt) auth.get('/v1/sites/:siteName/images/:imageName', verifyJwt) auth.post('/v1/sites/:siteName/images/:imageName', verifyJwt) auth.delete('/v1/sites/:siteName/images/:imageName', verifyJwt) diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js index 78dfbc956..5891f59d4 100644 --- a/middleware/errorHandler.js +++ b/middleware/errorHandler.js @@ -5,7 +5,7 @@ const { serializeError } = require('serialize-error') const logger = require('../logger/logger'); function errorHandler (err, req, res, next) { - logger.info(`${new Date()}: ${JSON.stringify(serializeError(err))}`) + const errMsg = `${new Date()}: ${JSON.stringify(serializeError(err))}` // set locals, only providing error in development res.locals.message = err.message; @@ -13,6 +13,7 @@ function errorHandler (err, req, res, next) { // Error handling for custom errors if (err.isIsomerError) { + logger.info(errMsg) res.status(err.status).json({ error: { name: err.name, @@ -21,14 +22,27 @@ function errorHandler (err, req, res, next) { }, }) } else { - res.status(500).json({ - error: { - code: 500, - message: 'Something went wrong', - }, - }) + // Error thrown by large payload is done by express + if (err.name === "PayloadTooLargeError") { + logger.info(errMsg) + res.status(413).json({ + error: { + name: err.name, + code: 413, + message: err.message, + }, + }) + } else { + logger.info(`Unrecognized internal server error: ${errMsg}`) + res.status(500).json({ + error: { + code: 500, + message: 'Something went wrong', + }, + }) + } } - } +} module.exports = { errorHandler, diff --git a/middleware/routeHandler.js b/middleware/routeHandler.js index 5670083f4..d53eccf8c 100644 --- a/middleware/routeHandler.js +++ b/middleware/routeHandler.js @@ -1,10 +1,34 @@ +const { backOff } = require('exponential-backoff') + +const { getCommitAndTreeSha, revertCommit } = require('../utils/utils.js') + const attachRouteHandlerWrapper = (routeHandler) => async (req, res, next) => { - routeHandler(req, res).catch((err) => { - next(err) - }) + routeHandler(req, res).catch((err) => { + next(err) + }) +} + +const attachRollbackRouteHandlerWrapper = (routeHandler) => async (req, res, next) => { + const { accessToken } = req + const { siteName } = req.params + let originalCommitSha + try { + const { currentCommitSha } = await getCommitAndTreeSha(siteName, accessToken) + originalCommitSha = currentCommitSha + } catch (err) { + next(err) } + routeHandler(req, res).catch(async (err) => { + try { + await backOff(() => revertCommit(originalCommitSha, siteName, accessToken)) + } catch (retryErr) { + next(retryErr) + } + next(err) + }) +} - module.exports = { - attachRouteHandlerWrapper, - } - \ No newline at end of file +module.exports = { + attachRouteHandlerWrapper, + attachRollbackRouteHandlerWrapper, +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a894f7e25..2e8f5f2bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1018,6 +1018,11 @@ "homedir-polyfill": "^1.0.1" } }, + "exponential-backoff": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.0.tgz", + "integrity": "sha512-oBuz5SYz5zzyuHINoe9ooePwSu0xApKWgeNzok4hZ5YKXFh9zrQBEM15CXqoZkJJPuI2ArvqjPQd8UKJA753XA==" + }, "express": { "version": "4.16.4", "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", @@ -1644,9 +1649,9 @@ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", + "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", "dev": true }, "inquirer": { diff --git a/package.json b/package.json index 25be48f12..4c3d93079 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "cors": "^2.8.5", "debug": "~2.6.9", "dotenv": "^8.1.0", + "exponential-backoff": "^3.1.0", "express": "~4.16.1", "http-errors": "~1.6.3", "js-base64": "^2.5.1", diff --git a/routes/collectionPages.js b/routes/collectionPages.js index 7c346373b..512f63bc0 100644 --- a/routes/collectionPages.js +++ b/routes/collectionPages.js @@ -6,7 +6,7 @@ const base64 = require('base-64'); const _ = require('lodash'); // Import middleware -const { attachRouteHandlerWrapper } = require('../middleware/routeHandler') +const { attachRouteHandlerWrapper, attachRollbackRouteHandlerWrapper } = require('../middleware/routeHandler') // Import classes const { Collection } = require('../classes/Collection.js') @@ -203,10 +203,10 @@ async function renameCollectionPage (req, res, next) { router.get('/:siteName/collections/:collectionName', attachRouteHandlerWrapper(listCollectionPages)) router.get('/:siteName/collections/:collectionName/pages', attachRouteHandlerWrapper(listCollectionPagesDetails)) -router.post('/:siteName/collections/:collectionName/pages', attachRouteHandlerWrapper(createNewcollectionPage)) +router.post('/:siteName/collections/:collectionName/pages', attachRollbackRouteHandlerWrapper(createNewcollectionPage)) router.get('/:siteName/collections/:collectionName/pages/:pageName', attachRouteHandlerWrapper(readCollectionPage)) router.post('/:siteName/collections/:collectionName/pages/:pageName', attachRouteHandlerWrapper(updateCollectionPage)) -router.delete('/:siteName/collections/:collectionName/pages/:pageName', attachRouteHandlerWrapper(deleteCollectionPage)) -router.post('/:siteName/collections/:collectionName/pages/:pageName/rename/:newPageName', attachRouteHandlerWrapper(renameCollectionPage)) +router.delete('/:siteName/collections/:collectionName/pages/:pageName', attachRollbackRouteHandlerWrapper(deleteCollectionPage)) +router.post('/:siteName/collections/:collectionName/pages/:pageName/rename/:newPageName', attachRollbackRouteHandlerWrapper(renameCollectionPage)) module.exports = router; \ No newline at end of file diff --git a/routes/collections.js b/routes/collections.js index 6a47af4b7..a993c45bf 100644 --- a/routes/collections.js +++ b/routes/collections.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); // Import middleware -const { attachRouteHandlerWrapper } = require('../middleware/routeHandler') +const { attachRouteHandlerWrapper, attachRollbackRouteHandlerWrapper } = require('../middleware/routeHandler') // Import classes const { Collection } = require('../classes/Collection.js') @@ -59,8 +59,8 @@ async function renameCollection (req, res, next) { } router.get('/:siteName/collections', attachRouteHandlerWrapper(listCollections)) -router.post('/:siteName/collections', attachRouteHandlerWrapper(createNewCollection)) -router.delete('/:siteName/collections/:collectionName', attachRouteHandlerWrapper(deleteCollection)) -router.post('/:siteName/collections/:collectionName/rename/:newCollectionName', attachRouteHandlerWrapper(renameCollection)) +router.post('/:siteName/collections', attachRollbackRouteHandlerWrapper(createNewCollection)) +router.delete('/:siteName/collections/:collectionName', attachRollbackRouteHandlerWrapper(deleteCollection)) +router.post('/:siteName/collections/:collectionName/rename/:newCollectionName', attachRollbackRouteHandlerWrapper(renameCollection)) module.exports = router; \ No newline at end of file diff --git a/routes/pages.js b/routes/pages.js index b623232d4..0147d8b77 100644 --- a/routes/pages.js +++ b/routes/pages.js @@ -4,7 +4,7 @@ const Bluebird = require('bluebird') const _ = require('lodash') // Import middleware -const { attachRouteHandlerWrapper } = require('../middleware/routeHandler') +const { attachRouteHandlerWrapper, attachRollbackRouteHandlerWrapper } = require('../middleware/routeHandler') // Import classes const { File, PageType, CollectionPageType } = require('../classes/File.js') @@ -163,6 +163,6 @@ router.post('/:siteName/pages', attachRouteHandlerWrapper(createNewPage)) router.get('/:siteName/pages/:pageName', attachRouteHandlerWrapper(readPage)) router.post('/:siteName/pages/:pageName', attachRouteHandlerWrapper(updatePage)) router.delete('/:siteName/pages/:pageName', attachRouteHandlerWrapper(deletePage)) -router.post('/:siteName/pages/:pageName/rename/:newPageName', attachRouteHandlerWrapper(renamePage)) +router.post('/:siteName/pages/:pageName/rename/:newPageName', attachRollbackRouteHandlerWrapper(renamePage)) module.exports = router; \ No newline at end of file diff --git a/routes/resourcePages.js b/routes/resourcePages.js index 5b75a14e7..16b58c528 100644 --- a/routes/resourcePages.js +++ b/routes/resourcePages.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); // Import middleware -const { attachRouteHandlerWrapper } = require('../middleware/routeHandler') +const { attachRouteHandlerWrapper, attachRollbackRouteHandlerWrapper } = require('../middleware/routeHandler') // Import classes const { File, ResourcePageType } = require('../classes/File.js') @@ -140,10 +140,10 @@ async function renameResourcePage (req, res, next) { res.status(200).json({ resourceName, pageName: newPageName, content, sha: newSha }) } router.get('/:siteName/resources/:resourceName', attachRouteHandlerWrapper(listResourcePages)) -router.post('/:siteName/resources/:resourceName/pages', attachRouteHandlerWrapper(createNewResourcePage)) +router.post('/:siteName/resources/:resourceName/pages', attachRollbackRouteHandlerWrapper(createNewResourcePage)) router.get('/:siteName/resources/:resourceName/pages/:pageName', attachRouteHandlerWrapper(readResourcePage)) router.post('/:siteName/resources/:resourceName/pages/:pageName', attachRouteHandlerWrapper(updateResourcePage)) -router.delete('/:siteName/resources/:resourceName/pages/:pageName', attachRouteHandlerWrapper(deleteResourcePage)) -router.post('/:siteName/resources/:resourceName/pages/:pageName/rename/:newPageName', attachRouteHandlerWrapper(renameResourcePage)) +router.delete('/:siteName/resources/:resourceName/pages/:pageName', attachRollbackRouteHandlerWrapper(deleteResourcePage)) +router.post('/:siteName/resources/:resourceName/pages/:pageName/rename/:newPageName', attachRollbackRouteHandlerWrapper(renameResourcePage)) module.exports = router; \ No newline at end of file diff --git a/routes/resourceRoom.js b/routes/resourceRoom.js index dbf3bf7bc..7ba100b71 100644 --- a/routes/resourceRoom.js +++ b/routes/resourceRoom.js @@ -3,7 +3,7 @@ const router = express.Router(); // Import classes const { ResourceRoom } = require('../classes/ResourceRoom.js'); -const { attachRouteHandlerWrapper } = require('../middleware/routeHandler'); +const { attachRouteHandlerWrapper, attachRollbackRouteHandlerWrapper } = require('../middleware/routeHandler'); // Get resource room name async function getResourceRoomName (req, res, next) { @@ -57,8 +57,8 @@ async function deleteResourceRoom(req, res, next) { } router.get('/:siteName/resource-room', attachRouteHandlerWrapper(getResourceRoomName)) -router.post('/:siteName/resource-room', attachRouteHandlerWrapper(createResourceRoom)) -router.post('/:siteName/resource-room/:resourceRoom', attachRouteHandlerWrapper(renameResourceRoom)) -router.delete('/:siteName/resource-room', attachRouteHandlerWrapper(deleteResourceRoom)) +router.post('/:siteName/resource-room', attachRollbackRouteHandlerWrapper(createResourceRoom)) +router.post('/:siteName/resource-room/:resourceRoom', attachRollbackRouteHandlerWrapper(renameResourceRoom)) +router.delete('/:siteName/resource-room', attachRollbackRouteHandlerWrapper(deleteResourceRoom)) module.exports = router; \ No newline at end of file diff --git a/routes/resources.js b/routes/resources.js index d17d74284..e94986e05 100644 --- a/routes/resources.js +++ b/routes/resources.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); // Import middleware -const { attachRouteHandlerWrapper } = require('../middleware/routeHandler') +const { attachRouteHandlerWrapper, attachRollbackRouteHandlerWrapper } = require('../middleware/routeHandler') // Import classes const { ResourceRoom } = require('../classes/ResourceRoom.js') @@ -60,14 +60,14 @@ async function renameResource (req, res, next) { const resourceRoomName = await IsomerResourceRoom.get() const IsomerResource = new Resource(accessToken, siteName) - await IsomerResource.rename(resourceRoomName, resourceName, resourceRoomName, newResourceName) + await IsomerResource.rename(resourceRoomName, resourceName, newResourceName) res.status(200).json({ resourceName, newResourceName }) } router.get('/:siteName/resources', attachRouteHandlerWrapper(listResources)) -router.post('/:siteName/resources', attachRouteHandlerWrapper(createNewResource)) -router.delete('/:siteName/resources/:resourceName', attachRouteHandlerWrapper(deleteResource)) -router.post('/:siteName/resources/:resourceName/rename/:newResourceName', attachRouteHandlerWrapper(renameResource)) +router.post('/:siteName/resources', attachRollbackRouteHandlerWrapper(createNewResource)) +router.delete('/:siteName/resources/:resourceName', attachRollbackRouteHandlerWrapper(deleteResource)) +router.post('/:siteName/resources/:resourceName/rename/:newResourceName', attachRollbackRouteHandlerWrapper(renameResource)) module.exports = router; \ No newline at end of file diff --git a/routes/settings.js b/routes/settings.js index 8e9bf9497..d2d6acd5f 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); // Import middleware -const { attachRouteHandlerWrapper } = require('../middleware/routeHandler') +const { attachRouteHandlerWrapper, attachRollbackRouteHandlerWrapper } = require('../middleware/routeHandler') // Import Classes const { Settings } = require('../classes/Settings.js') @@ -26,6 +26,6 @@ async function updateSettings (req, res, next) { } router.get('/:siteName/settings', attachRouteHandlerWrapper(getSettings)) -router.post('/:siteName/settings', attachRouteHandlerWrapper(updateSettings)) +router.post('/:siteName/settings', attachRollbackRouteHandlerWrapper(updateSettings)) module.exports = router; diff --git a/routes/sites.js b/routes/sites.js index e4c3facf6..6f55824c4 100644 --- a/routes/sites.js +++ b/routes/sites.js @@ -1,9 +1,16 @@ const express = require('express'); const router = express.Router(); const axios = require('axios'); +const Bluebird = require('bluebird'); const _ = require('lodash'); const { attachRouteHandlerWrapper } = require('../middleware/routeHandler'); +const { flatten } = require('lodash'); +// Import error +const { NotFoundError } = require('../errors/NotFoundError') + +const GH_MAX_REPO_COUNT = 100 +const ISOMERPAGES_REPO_PAGE_COUNT = process.env.ISOMERPAGES_REPO_PAGE_COUNT || 3 const ISOMER_GITHUB_ORG_NAME = process.env.GITHUB_ORG_NAME const ISOMER_ADMIN_REPOS = [ 'isomercms-backend', @@ -44,52 +51,74 @@ const timeDiff = (lastUpdated) => { async function getSites (req, res, next) { const { accessToken } = req - // Variable to store user repos - let siteNames = [] - - // Variables to track pagination of user's repos in case user has more than 100 - let pageCount = 1 - let hasNextPage = true; - const endpoint = `https://api.github.com/orgs/${ISOMER_GITHUB_ORG_NAME}/repos`; - - // Loop through all user repos - while (hasNextPage) { - const resp = await axios.get(endpoint, { - params: { - per_page: 100, - page: pageCount, - sort: "full_name", - }, - headers: { - Authorization: `token ${accessToken}`, - "Content-Type": "application/json", - } - }) - - // Filter for isomer repos - const isomerRepos = resp.data.reduce((acc, repo) => { - const { permissions, updated_at, name } = repo - if (permissions.push === true) { - return acc.concat({ - repoName: name, - lastUpdated: timeDiff(updated_at), - }) + const endpoint = `https://api.github.com/orgs/${ISOMER_GITHUB_ORG_NAME}/repos`; + + const params = { + per_page: GH_MAX_REPO_COUNT, + sort: "full_name", + } + + // Simultaneously retrieve all isomerpages repos + const paramsArr = [] + for (i = 0; i < ISOMERPAGES_REPO_PAGE_COUNT; i++) { + paramsArr.push({ ...params, page: i + 1 }) + } + + const sites = await Bluebird.map(paramsArr, async (params) => { + const resp = await axios.get(endpoint, { + params, + headers: { + Authorization: `token ${accessToken}`, + "Content-Type": "application/json", + } + }) + + return resp.data + .map((repoData) => { + const { + updated_at, + permissions, + name + } = repoData + + return { + lastUpdated: timeDiff(updated_at), + permissions, + repoName: name, } - return acc - }, []) + }).filter((repoData) => repoData.permissions.push === true && !ISOMER_ADMIN_REPOS.includes(repoData.repoName)) + }) + const flattenedSites = _.flatten(sites) - siteNames = siteNames.concat(isomerRepos) - hasNextPage = resp.headers.link ? resp.headers.link.includes('next') : false - ++pageCount - } - - // Remove Isomer admin repositories from this list - siteNames = _.difference(siteNames, ISOMER_ADMIN_REPOS) + res.status(200).json({ siteNames: flattenedSites }) +} + +/* Checks if a user has access to a repo. */ +async function checkHasAccess (req, res, next) { + try { + const { accessToken, userId } = req + const { siteName } = req.params - res.status(200).json({ siteNames }) + const endpoint = `https://api.github.com/repos/${ISOMER_GITHUB_ORG_NAME}/${siteName}/collaborators/${userId}` + await axios.get(endpoint, { + headers: { + Authorization: `token ${accessToken}`, + "Content-Type": "application/json", + } + }) + + res.status(200).json() + } catch (err) { + const status = err.response.status + // If user is unauthorized or site does not exist, show the same NotFoundError + if (status === 404 || status === 403) throw new NotFoundError('Site does not exist') + console.log(err) + throw err + } } router.get('/', attachRouteHandlerWrapper(getSites)); +router.get('/:siteName', attachRouteHandlerWrapper(checkHasAccess)); module.exports = router; diff --git a/server.js b/server.js index 82e8dd0ea..fbb93e43a 100644 --- a/server.js +++ b/server.js @@ -34,15 +34,14 @@ const netlifyTomlRouter = require('./routes/netlifyToml') const app = express(); app.use(logger('dev')); -app.use(express.json({ limit: '5mb'})); -app.use(express.urlencoded({ extended: false })); -app.use(cookieParser()); -app.use(express.static(path.join(__dirname, 'public'))); - app.use(cors({ 'origin': FRONTEND_URL, 'credentials': true, })) +app.use(express.json({ limit: '7mb'})); +app.use(express.urlencoded({ extended: false })); +app.use(cookieParser()); +app.use(express.static(path.join(__dirname, 'public'))); // Use auth middleware app.use(auth) diff --git a/utils/utils.js b/utils/utils.js index 30c53749f..41852dbd3 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -1,3 +1,6 @@ +const axios = require('axios'); +const GITHUB_ORG_NAME = process.env.GITHUB_ORG_NAME + /** * A function to deslugify a collection page's file name, taken from isomercms-frontend src/utils */ @@ -24,6 +27,111 @@ function deslugifyCollectionPage(collectionPageName) { ) } +async function getCommitAndTreeSha(repo, accessToken, branchRef='staging') { + try { + const headers = { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + }; + // Get the commits of the repo + const { data: commits } = await axios.get(`https://api.github.com/repos/${GITHUB_ORG_NAME}/${repo}/commits`, { + params: { + ref: branchRef, + }, + headers, + }); + // Get the tree sha of the latest commit + const { commit: { tree: { sha: treeSha } } } = commits[0]; + const currentCommitSha = commits[0].sha; + + return { treeSha, currentCommitSha }; + } catch (err) { + throw err + } +} + +// retrieve the tree from given tree sha +async function getTree(repo, accessToken, treeSha, branchRef='staging') { + try { + const headers = { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + }; + + const { data: { tree: gitTree } } = await axios.get(`https://api.github.com/repos/${GITHUB_ORG_NAME}/${repo}/git/trees/${treeSha}`, { + params: { + ref: branchRef, + }, + headers, + }); + + return gitTree; + } catch (err) { + throw err + } +} + +// send the new tree object back to Github and point the latest commit on the staging branch to it +async function sendTree(gitTree, currentCommitSha, repo, accessToken, message, branchRef='staging') { + const headers = { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + }; + const resp = await axios.post(`https://api.github.com/repos/${GITHUB_ORG_NAME}/${repo}/git/trees`, { + tree: gitTree, + }, { + headers, + }); + + const { data: { sha: newTreeSha } } = resp; + + const baseRefEndpoint = `https://api.github.com/repos/${GITHUB_ORG_NAME}/${repo}/git/refs`; + const baseCommitEndpoint = `https://api.github.com/repos/${GITHUB_ORG_NAME}/${repo}/git/commits`; + const refEndpoint = `${baseRefEndpoint}/heads/${branchRef}`; + + const newCommitResp = await axios.post(baseCommitEndpoint, { + message: message, + tree: newTreeSha, + parents: [currentCommitSha], + }, { + headers, + }); + + const newCommitSha = newCommitResp.data.sha; + + /** + * The `staging` branch reference will now point + * to `newCommitSha` instead of `currentCommitSha` + */ + await axios.patch(refEndpoint, { + sha: newCommitSha, + force: true, + }, { + headers, + }); +} + +// Revert the staging branch back to `originalCommitSha` +async function revertCommit(originalCommitSha, repo, accessToken, branchRef='staging') { + const headers = { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + }; + + const baseRefEndpoint = `https://api.github.com/repos/${GITHUB_ORG_NAME}/${repo}/git/refs`; + const refEndpoint = `${baseRefEndpoint}/heads/${branchRef}`; + + /** + * The `staging` branch reference will now point to `currentCommitSha` + */ + await axios.patch(refEndpoint, { + sha: originalCommitSha, + force: true, + }, { + headers, + }); +} + /** * A function to deslugify a collection's name */ @@ -37,4 +145,8 @@ function deslugifyCollectionName(collectionName) { module.exports = { deslugifyCollectionPage, deslugifyCollectionName, + getCommitAndTreeSha, + getTree, + sendTree, + revertCommit, }