diff --git a/docker-compose.yml b/docker-compose.yml index cf03a0e..42b5ff5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,14 @@ version: "3.8" services: + ngrok: + image: wernight/ngrok:latest + ports: + - 4040:4040 + environment: + NGROK_PROTOCOL: http + NGROK_PORT: server:3000 + NGROK_AUTH: ${NGROK_AUTH} # access through `psql -h localhost -p 5432 -d filesync -U user`, password is 'pass' # to be able to build after the initializing the db, you may need to run `sudo chown -R $USER db_data/` @@ -19,6 +27,7 @@ services: dockerfile: Dockerfile depends_on: - database + - ngrok env_file: ./.env volumes: - ./src/build:/ffs/ diff --git a/environment.d.ts b/environment.d.ts index e91e6ca..218060a 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -7,6 +7,10 @@ declare global { PORT_SERVER: number; PORT_DB: number; WEBHOOK_TOKEN: string; + POSTGRES_USER: string; + POSTGRES_DB: string; + POSTGRES_PASSWORD: string; + HOOK_CALLBACK_HOSTNAME: string; } } } diff --git a/package.json b/package.json index b2779b7..66b41bf 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start:ngrok": "ngrok http 3000", "test": "echo \"Error: no test specified\" && exit 1", "build": "npm run open:page && npm run build:docker", - "build:docker": "node src/ngrok_script.js && tsc --build && docker-compose up --build --remove-orphans", + "build:docker": "tsc --build && docker-compose up --build --remove-orphans", "open:page": "opener http://localhost:3000/auth" }, "repository": { diff --git a/src/config.ts b/src/config.ts index 91b6855..1871b73 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,54 +2,60 @@ * Configures constants used in the other parts of this app */ -import { AuthClientThreeLegged } from 'forge-apis'; -import * as dotenv from 'dotenv'; -dotenv.config({path: '../.env'}); +import * as ForgeSDK from "forge-apis"; +import * as dotenv from "dotenv"; +dotenv.config({ path: "../.env" }); -export const ForgeAuthClient = AuthClientThreeLegged; - -export const scopes: any = ['bucket:create', 'bucket:read', 'data:read', 'data:create', 'data:write']; +// export const ForgeAuthClient = ForgeSDK.AuthClientThreeLegged; +export const scopes: ForgeSDK.Scope[] = [ + "bucket:create", + "bucket:read", + "data:read", + "data:create", + "data:write", +]; // TODO: add production environment variables -export const authClient = new ForgeAuthClient( - process.env.FORGE_CLIENT_ID, - process.env.FORGE_CLIENT_SECRET, - process.env.FORGE_CALLBACK_URL, - scopes, - true +export const authClient = new ForgeSDK.AuthClientThreeLegged( + process.env.FORGE_CLIENT_ID, + process.env.FORGE_CLIENT_SECRET, + process.env.FORGE_CALLBACK_URL, + scopes, + true ); -export const projectID = "a.YnVzaW5lc3M6cHVyZHVlMjY5NiMyMDIxMTExNTQ2Njg0NTI1MQ"; +export const projectID: string = + "a.YnVzaW5lc3M6cHVyZHVlMjY5NiMyMDIxMTExNTQ2Njg0NTI1MQ"; // The local fields are only for internal/container use, host paths are specified in the environment export const folderMap: any = { - gantry: { - fusionID: "urn:adsk.wipprod:fs.folder:co.IxiedfeBTOGg6NSIGQhXxQ", - local: "/Gantry" - }, - lathe: { - fusionID: "urn:adsk.wipprod:fs.folder:co.FDWCeyBGQX2rv8xSNWo5lg", - local: "/Lathe" - }, - mill: { - fusionID: "urn:adsk.wipprod:fs.folder:co.nEihcpHUSW-ZsVzU-__1iw", - local: "/Mill" - }, - waterjet: { - fusionID: "urn:adsk.wipprod:fs.folder:co.vJLXAKGbQQayeljr-nwztQ", - local: "/Waterjet" - } + gantry: { + fusionID: "urn:adsk.wipprod:fs.folder:co.IxiedfeBTOGg6NSIGQhXxQ", + local: "/Gantry", + }, + lathe: { + fusionID: "urn:adsk.wipprod:fs.folder:co.FDWCeyBGQX2rv8xSNWo5lg", + local: "/Lathe", + }, + mill: { + fusionID: "urn:adsk.wipprod:fs.folder:co.nEihcpHUSW-ZsVzU-__1iw", + local: "/Mill", + }, + waterjet: { + fusionID: "urn:adsk.wipprod:fs.folder:co.vJLXAKGbQQayeljr-nwztQ", + local: "/Waterjet", + }, }; // dictionary for looking up local directory path based on folder ID export const folderIDtoLocal: any = { - "urn:adsk.wipprod:fs.folder:co.IxiedfeBTOGg6NSIGQhXxQ" : "/Gantry", - "urn:adsk.wipprod:fs.folder:co.FDWCeyBGQX2rv8xSNWo5lg": "/Lathe", - "urn:adsk.wipprod:fs.folder:co.nEihcpHUSW-ZsVzU-__1iw": "/Mill", - "urn:adsk.wipprod:fs.folder:co.vJLXAKGbQQayeljr-nwztQ": "/Waterjet" -} + "urn:adsk.wipprod:fs.folder:co.IxiedfeBTOGg6NSIGQhXxQ": "/Gantry", + "urn:adsk.wipprod:fs.folder:co.FDWCeyBGQX2rv8xSNWo5lg": "/Lathe", + "urn:adsk.wipprod:fs.folder:co.nEihcpHUSW-ZsVzU-__1iw": "/Mill", + "urn:adsk.wipprod:fs.folder:co.vJLXAKGbQQayeljr-nwztQ": "/Waterjet", +}; export const port = process.env.PORT_SERVER; export const forgeClientId = process.env.FORGE_CLIENT_ID; @@ -60,4 +66,4 @@ export const webhookToken = process.env.WEBHOOK_TOKEN; export const postgresUser = process.env.POSTGRES_USER; export const postgresPassword = process.env.POSTGRES_PASSWORD; export const postgresPort = process.env.PORT_DB; -export const postgresDB = process.env.POSTGRES_DB; \ No newline at end of file +export const postgresDB = process.env.POSTGRES_DB; diff --git a/src/db.ts b/src/db.ts index 3ba3686..de2386e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,108 +1,119 @@ -import { Client, Query } from 'ts-postgres'; -import { createPool } from 'generic-pool'; -import * as config from './config'; +import { Client, Query, DatabaseError } from "ts-postgres"; +import { createPool } from "generic-pool"; +import * as config from "./config"; -const pool = createPool({ - create: async () => { - return new Promise((resolve, reject) => { - - const client = new Client({ - 'host': 'database', // name of docker container, not 'localhost' - 'port': config.postgresPort, - 'user': config.postgresUser, - 'password': config.postgresPassword, - 'database': config.postgresDB - }); - - client.connect() - .then(() => { - console.log("successfully connected to db") - resolve(client); - }).catch((err: any) => { - console.log() - console.log(err) - reject(err); - }); - }); - }, - destroy: async (client: Client) => { - return client.end() - }, - validate: (client: Client) => { - return Promise.resolve(!client.closed); - } -}, { - testOnBorrow: true, - max: 10 -}) +const pool = createPool( + { + create: async () => { + return new Promise((resolve, reject) => { + const client = new Client({ + host: "database", // name of docker container, not 'localhost' + port: config.postgresPort, + user: config.postgresUser, + password: config.postgresPassword, + database: config.postgresDB, + }); + + client + .connect() + .then(() => { + console.log("successfully connected to db"); + resolve(client); + }) + .catch((err: DatabaseError) => { + console.log(err); + reject(err); + }); + }); + }, + destroy: async (client: Client) => { + return client.end(); + }, + validate: (client: Client) => { + return Promise.resolve(!client.closed); + }, + }, + { + testOnBorrow: true, + max: 10, + } +); const executeQuery = (query: Query) => { - console.log('executing query'); - - const res = pool.acquire(); + console.log("executing query"); - return res.then(async (client: Client) => { - console.log('aquired client from pool') - const result = await client.execute(query); - pool.release(client).then(() => { - console.log("client released back into pool") - }); - return result; - }).catch((err: any) => { - console.log(err); - }) -} + const res = pool.acquire(); + + return res + .then(async (client: Client) => { + console.log("aquired client from pool"); + const result = await client.execute(query); + pool.release(client).then(() => { + console.log("client released back into pool"); + }); + return result; + }) + .catch((err: DatabaseError) => { + console.log(err); + }); +}; export const shutdown = () => { - pool.drain() - .then(() => { - return pool.clear(); - }); -} + pool.drain().then(() => { + return pool.clear(); + }); +}; export const insert = (table: string, params: any) => { + let fileName = params.fileName; + let folderName = params.folderName; + let fusionID = null; + let username = null; + let size = null; + let version = null; - let fileName = params.fileName; - let folderName = params.folderName; - let fusionID = null; - let username = null; - let size = null; - let version = null; + if (params.hasOwnProperty("fusionID")) { + fusionID = params.fusionID; + } + if (params.hasOwnProperty("username")) { + username = params.username; + } + if (params.hasOwnProperty("size")) { + size = params.size; + } + if (params.hasOwnProperty("version")) { + version = params.version; + } - if (params.hasOwnProperty('fusionID')) {fusionID = params.fusionID} - if (params.hasOwnProperty('username')) {username = params.username} - if (params.hasOwnProperty('size')) {size = params.size} - if (params.hasOwnProperty('version')) {version = params.version} - - let query: Query; + let query: Query; - console.log("params: ", params); + console.log("params: ", params); - switch(table) { - case 'fusion': - query = new Query ( - "INSERT INTO fusion (fusion_id, folder_name, file_name, username, size, version)" + - "VALUES ($1, $2, $3, $4, $5, $6)", - [fusionID, folderName, fileName, username, size, version] - ); - break; - case 'local': - query = new Query ( - "INSERT INTO local (file_name, folder_name, fusion_id)" + - "VALUES ($1, $2, $3)", - [fileName, folderName, fusionID] - ); - break; - case 'archive': - query = new Query ( - "INSERT INTO archive (fusion_id, folder_name, file_name, username, size, version)" + - "VALUES ($1, $2, $3, $4, $5, $6)", - [fusionID, folderName, fileName, username, size, version] - ); - break; - default: - throw new Error("Invalid table/params"); - } + switch (table) { + case "fusion": + query = new Query( + "INSERT INTO fusion (fusion_id, folder_name, file_name, username, size, version)" + + "VALUES ($1, $2, $3, $4, $5, $6)", + [fusionID, folderName, fileName, username, size, version] + ); + break; + case "local": + query = new Query( + "INSERT INTO local (file_name, folder_name, fusion_id)" + + "VALUES ($1, $2, $3)", + [fileName, folderName, fusionID] + ); + break; + case "archive": + query = new Query( + "INSERT INTO archive (fusion_id, folder_name, file_name, username, size, version)" + + "VALUES ($1, $2, $3, $4, $5, $6)", + [fusionID, folderName, fileName, username, size, version] + ); + break; + default: + throw new Error("Invalid table/params"); + } - return executeQuery(query); -} \ No newline at end of file + return executeQuery(query); +}; diff --git a/src/downloader.ts b/src/downloader.ts deleted file mode 100644 index 45e80fb..0000000 --- a/src/downloader.ts +++ /dev/null @@ -1,51 +0,0 @@ -const fs = require('fs').promises; -const axios = require('axios'); -const ForgeSDK = require('forge-apis'); -const config = require('./config'); - -const ObjectsApi = new ForgeSDK.ObjectsApi(); - -/** - * Kevin Pan | pan261@purdue.edu | Last Modified: 12/2/2021 - * - * Downloads a file by using the Forge SDK to make a GET request - * on the storageLocation and then saves the file as `filename` under the `destination` directory - * @param {string} storageLocation - * @param {string} fileName - * @param {string} destination - * @param {Object} credentials - */ -exports.download = async (projectID: string, itemID: string, fileName: string, destination: string, credentials: any) => { - const storageLocation = await getStorageLocation(projectID, itemID, credentials); - - console.log("Downloading file "+ fileName); - - ObjectsApi.getObject('wip.dm.prod', storageLocation, {}, config.authClient, credentials) - .then((res: any) => { - console.log("Downloaded file " + fileName + " to " + destination); - fs.writeFile(`${destination}/${fileName}`, res.body) - }) - .catch((err: any) => { - console.log("error while processing " + storageLocation); - console.log(err); - }); -} - -const getStorageLocation = (projectID: string, itemID: string, credentials: any) => { - return axios({ - method: 'GET', - url: `https://developer.api.autodesk.com/data/v1/projects/${projectID}/items/${itemID}`, - headers: { - Authorization: `Bearer ${credentials.access_token}` - }, - }).then((res: any) => { - return res.data.included[0]; - }).then((data: any) => { - var storageID = data.relationships.storage.data.id; - storageID = storageID.substring(storageID.indexOf('/') + 1); - return storageID; - }).catch((err: any) => { - console.log("unable to get storage location for " + itemID + ": " + err.response.status); - }) -} - diff --git a/src/forge-delete.ts b/src/forge-delete.ts new file mode 100644 index 0000000..f4f56cf --- /dev/null +++ b/src/forge-delete.ts @@ -0,0 +1,89 @@ +// NOT CURRENTLY USED, ENDPOINTS AND FUNCTIONS DO NOT WORK +//////////////////////////////// + +import * as ForgeSDK from "forge-apis"; +import * as axios from "axios"; + +const jsonVersion: ForgeSDK.JsonApiVersionJsonapi = {version: "1.0"} +const versionsAPI = new ForgeSDK.VersionsApi(); + +/** + * Kevin Pan | pan261@purdue.edu | Last Modified: 2/7/2021 + * + * Deletes a file using the versions API + * @param {string} projectID + * @param {string} itemID + * @param {ForgeSDK.AuthClient} credentials + */ + +exports.delete = (projectID: string, itemID: string, fileName: string, credentials: ForgeSDK.AuthToken) => { + const data:ForgeSDK.CreateVersionData = { + type: "versions", + attributes: { + name: fileName, + extension: { + type: "versions:autodesk.core:Deleted", + version: "1.0", + schema: { + href: "" + } + } + }, + relationships: { + item: { + data: { + type: "items", + id: itemID + } + } + } + } + + const body: ForgeSDK.CreateVersion = { + jsonapi: jsonVersion, + data: data + } + + axios.default({ + method: 'POST', + url: `https://developer.api.autodesk.com/data/v1/projects/${projectID}/versions`, + headers: { + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${credentials.access_token}`, + }, + data: { + jsonapi: { + version: "1.0" + }, + data: { + type: "versions", + attributes: { + extension: { + type: "versions:autodesk.core:Deleted", + version: "1.0", + } + }, + relationships: { + item: { + data: { + type: "items", + id: itemID + } + } + } + } + } + }).then(res => { + console.log("Successfully deleted item with id: " + itemID); + }).catch(err => { + console.log("Error deleting item with id: " + itemID); + }); + + // versionsAPI.postVersion(projectID, body, config.authClient, credentials) + // .then((res: ForgeSDK.ApiResponse) => { + // console.log("Successfully deleted item with " + itemID); + // }).catch((err: ForgeSDK.ApiError) => { + // console.log("Error deleting file: " + itemID + ", Error code: " + err.statusCode); + // console.log(JSON.stringify(err)); + // }); +} \ No newline at end of file diff --git a/src/forge-download.ts b/src/forge-download.ts new file mode 100644 index 0000000..204a55a --- /dev/null +++ b/src/forge-download.ts @@ -0,0 +1,46 @@ +import * as ForgeSDK from "forge-apis"; + +const fs = require("fs").promises; +import * as config from "./config"; + +const ObjectsApi = new ForgeSDK.ObjectsApi(); +const ItemsApi = new ForgeSDK.ItemsApi(); + +/** + * Kevin Pan | pan261@purdue.edu | Last Modified: 2/7/2021 + * + * Downloads a file by using the Forge SDK to make a GET request + * on the storageLocation and then saves the file as `filename` under the `destination` directory + * @param {string} storageLocation + * @param {string} fileName + * @param {string} destination + * @param {ForgeSDK.AuthClient} credentials + */ +exports.download = async (projectID: string, itemID: string, fileName: string, destination: string, credentials: ForgeSDK.AuthToken) => { + const storageLocation = await getStorageLocation(projectID, itemID, credentials); + + console.log("Downloading file " + fileName); + + return ObjectsApi.getObject("wip.dm.prod", storageLocation, {}, config.authClient, credentials) + .then((res: ForgeSDK.ApiResponse) => { + console.log("Downloaded file " + fileName + " to " + destination); + fs.writeFile(`${destination}/${fileName}`, res.body); + return itemID; + }) + .catch((err: ForgeSDK.ApiError) => { + console.log("error while processing " + storageLocation); + console.log(err); + }); +}; + +const getStorageLocation = (projectID: string, itemID: string, credentials: ForgeSDK.AuthToken) => { + return ItemsApi.getItem(projectID, itemID, config.authClient, credentials) + .then((res: ForgeSDK.ApiResponse) => { + let data = res.body.included[0]; + let storageID = data.relationships.storage.data.id; + storageID = storageID.substring(storageID.indexOf("/") + 1); + return storageID; + }).catch((err: ForgeSDK.ApiError) => { + console.log("unable to get storage location for " + itemID + ": " + err.statusCode); + }); +}; diff --git a/src/uploadfile.ts b/src/forge-upload.ts similarity index 94% rename from src/uploadfile.ts rename to src/forge-upload.ts index 24940af..77f9771 100644 --- a/src/uploadfile.ts +++ b/src/forge-upload.ts @@ -17,7 +17,7 @@ function getCreateStorageDataObject(fileName: string, folderID: string): ForgeSD relationships: { target: { data: { - type: "folders", + type: "folders", id: folderID } } @@ -69,7 +69,7 @@ function getCreateItemIncludedObject(fileName: string, objectID: string): ForgeS href: "" } } - }, + }, relationships: { storage: { data: { @@ -116,7 +116,7 @@ function getCreateVersionData(fileName: string, itemID: string):ForgeSDK.CreateV * @param folderID Fusion ID of folder * @param objectID fusion ID of uploaded bucket object * @param credentials Forge Authtoken - * Calls: + * Calls: * @function getCreateItemDataObject * @function getCreateItemIncludedObject */ @@ -136,7 +136,7 @@ function createItem(fileName: string, folderID: string, objectID: string, creden } return true; - }, + }, (err: ForgeSDK.ApiError) => { console.log("API ERROR CODE: ", err.statusCode, "\nMESSAGE: ", err.statusMessage, "\nBODY: ", err.statusBody); return false; @@ -164,12 +164,12 @@ function uploadFileObject(fileName: string, folderName: string, objectID: string const objectName:string = objIDTokens[1].trim(); objectsAPI.uploadObject( - bucketKey, + bucketKey, objectName, - fileBuffer.byteLength, - fileBuffer, - {contentDisposition: fileName}, - config.authClient, + fileBuffer.byteLength, + fileBuffer, + {contentDisposition: fileName}, + config.authClient, credentials).then( (resp: ForgeSDK.ApiResponse) => { if (resp.statusCode != 200) { @@ -188,7 +188,7 @@ function uploadFileObject(fileName: string, folderName: string, objectID: string /** * Wrapper for: @function ProjectsApi.postStorage() - * Extracts necessary information from arguments. + * Extracts necessary information from arguments. * Requests bucket storage for file name in specified project and folder. * On success: Uploads file object to bucket * On fail: Returns @@ -209,7 +209,7 @@ export function uploadFile(fileName: string, folderName: string, credentials: Fo jsonapi: jsonVersion, data: getCreateStorageDataObject(fileName, folderID) } - + projectsAPI.postStorage(config.projectID, body, config.authClient, credentials).then( (resp: ForgeSDK.ApiResponse) => { if (resp.statusCode != 201) { @@ -236,12 +236,12 @@ export function uploadFile(fileName: string, folderName: string, credentials: Fo export function newVersion(fileName: string, folderName: string, credentials: ForgeSDK.AuthToken) { - const folderID: string = config.folderMap[folderName].fusionID; const body: ForgeSDK.CreateVersion = { jsonapi: jsonVersion, data: getCreateVersionData(fileName, "") } - versionsAPI.postVersion(folderID, body, config.authClient, credentials).then( + + versionsAPI.postVersion(config.projectID, body, config.authClient, credentials).then( (resp: ForgeSDK.ApiResponse) => { if (resp.statusCode != 201) { console.log("Allocate Storage Error: ", resp.statusCode); diff --git a/src/forge-webhooks.ts b/src/forge-webhooks.ts new file mode 100644 index 0000000..c281e99 --- /dev/null +++ b/src/forge-webhooks.ts @@ -0,0 +1,180 @@ +import * as axios from "axios"; +import * as ForgeSDK from "forge-apis"; +import * as config from "./config"; + +/** + * Kevin Pan | pan261@purdue.edu | Last Modified: 2/7/2022 + * + * Functions to manage hook lifecycle + * setup() checks if hooks are valid and active, if not it will reactivated + * and/or recreate the specified hook. There is one hook for each of the + * following events: + * + * - 'dm.version.added' (file added) + * - 'dm.version.modified' (file modified) + * - 'dm.version.deleted' (file deleted) + * + */ + +const fileShareID = "urn:adsk.wipprod:fs.folder:co.T0n0mYQeS16K0lq1VuuYVQ"; + +/** + * Function to manage hook lifecycle + */ +exports.setupHooks = (callbackUrl: string, credentials: ForgeSDK.AuthToken) => { + console.log("Checking hooks"); + axios.default({ + method: "GET", + url: "https://developer.api.autodesk.com/webhooks/v1/hooks", + headers: { + Authorization: `Bearer ${credentials.access_token}`, + }, + }) + .then((res: axios.AxiosResponse) => { + let data = res.data.data; + var hookEvents = []; + for (var index in data) { + const hook = data[index]; + + if (hook.callbackUrl != callbackUrl) { + // hooks with invalid callbacks will be deleted, not added to current list of hooks + deleteHook(hook.event, hook.hookId, credentials); + } else { + hookEvents.push(hook.event); + if (hook.status === "inactive") { + console.log("reactivating " + hook.event + " hook"); + reactivateHook(hook.event, hook.hookId, credentials); + } + } + } + + // these are the events we want, if not in the list, then register the hook + if (!hookEvents.includes("dm.version.added")) { + createHook("dm.version.added", callbackUrl, credentials); + } + + if (!hookEvents.includes("dm.version.modified")) { + createHook("dm.version.modified", callbackUrl, credentials); + } + + if (!hookEvents.includes("dm.version.deleted")) { + createHook("dm.version.deleted", callbackUrl, credentials); + } + }) + .catch((err: axios.AxiosError) => { + console.log(err); + }); +}; + +const createHook = (event: string, callbackUrl: string, credentials: ForgeSDK.AuthToken) => { + axios.default({ + method: "POST", + url: `https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.access_token}`, + }, + data: { + autoReactivateHook: "true", + callbackUrl: callbackUrl, + scope: { + folder: fileShareID, + }, + }, + }) + .then((res: axios.AxiosResponse) => { + console.log("Sucessfully created hook for " + event); + }) + .catch((err: axios.AxiosError) => { + err.response && console.log("error creating hook for " + event + ": " + err.response.status); + }); +}; + +const reactivateHook = (event: string, hookID: string, credentials: ForgeSDK.AuthToken) => { + axios.default({ + method: "PATCH", + url: `https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks/${hookID}`, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.access_token}`, + }, + data: { + status: "active", + autoReactivateHook: true, + }, + }) + .then((res: axios.AxiosResponse) => { + console.log("successfully reactivated " + event + " hook"); + }) + .catch((err: axios.AxiosError) => { + err.response && console.log("error reactivating hook for " + event + ": " + err.response.status); + }); +}; + +const deleteHook = (event: string, hookID: string, credentials: ForgeSDK.AuthToken) => { + axios.default({ + method: "DELETE", + url: `https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks/${hookID}`, + headers: { + Authorization: `Bearer ${credentials.access_token}`, + }, + }) + .then((res: axios.AxiosResponse) => { + console.log("deleted " + event + " hook with bad callback url"); + }) + .catch((err: axios.AxiosError) => { + if (err.response) { + console.log("error deleting hook for " + event + ": " + err.response.status); + } + }); +}; + +exports.setupToken = (credentials: ForgeSDK.AuthToken) => { + console.log("Checking webhook token"); + + axios.default({ + method: "POST", + url: "https://developer.api.autodesk.com/webhooks/v1/tokens", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.access_token}`, + }, + data: { + token: config.webhookToken, + }, + }) + .then((res: axios.AxiosResponse) => { + console.log(res.data.detail); + }) + .catch((err: axios.AxiosError) => { + if (err.response) { + if (err.response.status == 400) { + console.log("Webhook token already exists, updating"); + updateToken(credentials); + } else { + console.log("Error creating a new webhook token"); + } + } + }); +}; + +// not sure how to incorporate this into webhook token workflow +const updateToken = (credentials: ForgeSDK.AuthToken) => { + axios.default({ + method: "PUT", + url: "https://developer.api.autodesk.com/webhooks/v1/tokens/@me", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.access_token}`, + }, + data: { + token: config.webhookToken, + }, + }) + .then((res: axios.AxiosResponse) => { + console.log("Successfully updated webhook token"); + }) + .catch((err: axios.AxiosError) => { + console.log("Error updating webhook token"); + }); +}; diff --git a/src/localhook.ts b/src/localhook.ts index d18bce7..e9b7855 100644 --- a/src/localhook.ts +++ b/src/localhook.ts @@ -1,6 +1,6 @@ import * as chokidar from 'chokidar'; import { AuthToken } from 'forge-apis'; -import * as uploader from './uploadfile'; +import * as uploader from './forge-upload'; const polltime: number = 100; diff --git a/src/ngrok_script.js b/src/ngrok_script.js deleted file mode 100644 index 2650b50..0000000 --- a/src/ngrok_script.js +++ /dev/null @@ -1,25 +0,0 @@ -const axios = require('axios'); -const fs = require('fs'); - -axios({ - method: 'GET', - url: 'http://127.0.0.1:4040/api/tunnels' -}).then(res => { - return res.data.tunnels; -}).then(data => { - data.forEach((entry) => { - if (entry.name === 'command_line (http)') { - const data = fs.readFileSync(`${__dirname}/../.env`).toString(); - if (data.includes(entry.public_url)) { - console.log("ngrok callback url not changed"); - return; - } - const newData = data.replace(new RegExp(/http:\/\/.+\.ngrok\.io/g), entry.public_url); - fs.writeFileSync(`${__dirname}/../.env`, newData); - console.log("updated ngrok callback url"); - } - }) -}).catch(err => { - console.log(); - throw("ngrok server not started"); -}); \ No newline at end of file diff --git a/src/old/ngrok_script.js b/src/old/ngrok_script.js new file mode 100644 index 0000000..48a6ea0 --- /dev/null +++ b/src/old/ngrok_script.js @@ -0,0 +1,31 @@ +const axios = require("axios"); +const fs = require("fs"); + +axios({ + method: "GET", + url: "http://127.0.0.1:4040/api/tunnels", +}) + .then((res) => { + return res.data.tunnels; + }) + .then((data) => { + data.forEach((entry) => { + if (entry.name === "command_line (http)") { + const data = fs.readFileSync(`${__dirname}/../.env`).toString(); + if (data.includes(entry.public_url)) { + console.log("ngrok callback url not changed"); + return; + } + const newData = data.replace( + new RegExp(/http:\/\/.+\.ngrok\.io/g), + entry.public_url + ); + fs.writeFileSync(`${__dirname}/../.env`, newData); + console.log("updated ngrok callback url"); + } + }); + }) + .catch((err) => { + console.log(); + throw "ngrok server not started"; + }); diff --git a/src/server.ts b/src/server.ts index c15bf00..0788e38 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,52 +1,88 @@ -import express = require('express'); -import bodyParser = require('body-parser'); -import crypto = require('crypto'); -import * as config from './config'; -import * as db from './db'; -import fs = require('fs'); +import express = require("express"); +import bodyParser = require("body-parser"); +import * as axios from 'axios'; +import * as crypto from "crypto"; +import * as config from "./config"; +import * as db from "./db"; -const webhooks = require('./webhooks'); -const downloader = require('./downloader'); -const localhook = require('./localhook'); +import * as ForgeSDK from "forge-apis"; +import { Request, Response, ErrorRequestHandler } from "express"; +import { resolve } from "path/posix"; + +const webhooks = require("./forge-webhooks"); +const downloader = require("./forge-download"); +const deleter = require("./forge-delete"); +const localhook = require("./localhook"); /** * Kevin Pan | pan261@purdue.edu | Last Modified: 1/24/2022 - * + * * Authentication server used to perform 3-legged auth through browser. - * + * * Uses environment variables: * - FORGE_CLIENT_ID * - FORGE CLIENT_SECRET * - FORGE_CALLBACK_URL * - PORT_SERVER (defaults to 3000) - * + * * These are all configured in config.js - * + * */ -let credentials: any = null; -let refreshTime: any = null; +let credentials: ForgeSDK.AuthToken; +let refreshTime: number; let intervalID: NodeJS.Timeout; const app = express(); -const verifySignature = (req: any, res: any, buf: any, encoding: any) => { - console.log("Verifying webhook callback signature"); - const signature = req.header('x-adsk-signature'); - if(!signature) { return; } +const verifySignature = ( + req: any, + res: Response, + buf: Buffer, + encoding: crypto.Encoding +) => { + console.log("Verifying webhook callback signature"); + const signature = req.header("x-adsk-signature"); + if (!signature) { + return; + } - // use utf-8 encoding by default - const body = buf.toString(encoding); - const hmac = crypto.createHmac('sha1', config.webhookToken); - const calcSignature = 'sha1hash=' + hmac.update(body).digest('hex'); - req.signature_match = (calcSignature === signature); -} + // use utf-8 encoding by default + const body = buf.toString(encoding); + const hmac = crypto.createHmac("sha1", config.webhookToken); + const calcSignature = "sha1hash=" + hmac.update(body).digest("hex"); + req.signature_match = calcSignature === signature; +}; + +app.use( + bodyParser.json({ + inflate: true, + limit: "1024kb", + type: "application/json", + verify: verifySignature, + }) +); -app.use(bodyParser.json({ - inflate: true, - limit: '1024kb', - type: 'application/json', - verify: verifySignature -})); +const getWebhookCallbackURL = () => { + return axios.default({ + method: "GET", + url: "http://ngrok:4040/api/tunnels", + }) + .then((res: axios.AxiosResponse) => { + let data = res.data.tunnels; + let url: string = ""; + data.forEach((entry: any) => { + if (entry.name === "command_line (http)") { + url = entry.public_url + "/hook" + console.log("Webhook callback url is:", url); + } + }); + + return url; + }) + .catch((err: axios.AxiosError) => { + console.log("ngrok server error:", err.code, err.message); + }); +} /** * Creates server with three endpoints: @@ -59,143 +95,156 @@ app.use(bodyParser.json({ * @returns express server object */ const createServer = () => { - - const PORT = config.port || 3000; - - if (config.forgeClientId == null || config.forgeClientSecret == null) { - console.error('Missing FORGE_CLIENT_ID or FORGE_CLIENT_SECRET env. variables.'); - return; - } - - // Endpoint to begin authentication process - app.get('/auth', function (req: any, res: any) { - console.log( - '\x1b[96mserver.js::createServer:', - '\x1b[0m/auth endpoint called' - ); - res.redirect(config.authClient.generateAuthUrl("")); - }); - - // Endpoint Forge redirects to after consent screen - app.get('/callback', function (req: any, res: any) { - config.authClient.getToken(req.query.code) - .then((creds) => { - credentials = creds; - refreshTime = creds.expires_in - 300; - res.send('Generated token: ' + credentials.access_token); + + const PORT = config.port || 3000; + + if (config.forgeClientId == null || config.forgeClientSecret == null) { + console.error("Missing FORGE_CLIENT_ID or FORGE_CLIENT_SECRET env. variables."); + return; + } + + // Endpoint to begin authentication process + app.get("/auth", function (req: Request, res: Response) { console.log( - '\x1b[92mserver.js::createServer:', - '\x1b[0m/callback reached, token generated' + "\x1b[96mserver.js::createServer:", + "\x1b[0m/auth endpoint called" ); - }) - .then(() => { - // run the setup function for webhooks - webhooks.setupHooks(credentials); - - // register secret token - webhooks.setupToken(credentials); - - // pass credentials to local hook - localhook.setCredentials(credentials); - - // sets refresh() function to run on an interval every 55 minutes - intervalID = setInterval(() => refresh(), refreshTime * 1000); // 55 seconds to ms - - // sets timeout for 13 days, before clearing refresh interval and clearing credentials - setTimeout(() => { - credentials = null; - clearInterval(intervalID); - console.log( - '\x1b[93mserver.js::createServer:', - '\x1b[0mRefresh token expiring, need to authenticate again' - ); - }, 13 * 24 * 3600 * 1000); // 13 days to ms - }) - .catch((err: any) => { - console.error(err); - res.send(err); - }); - }); - - // Endpoint for internal use, to get credentials from our auth server - app.get('/credentials', function (req: any, res: any) { - if (credentials) { - console.log( - '\x1b[96mserver.js::createServer:', - '\x1b[0m/credentials endpoint called, credentials returned' - ); - res.send(credentials); - } else { - console.log( - '\x1b[93mserver.js::createServer:', - '\x1b[0m/credentials endpoint called, no credentials found' - ); - res.send('Need to authenticate at localhost:3000/auth'); - } - }); + res.redirect(config.authClient.generateAuthUrl("")); + }); - app.post('/hook', function (req: any, res: any) { - if(!req.signature_match) { - console.log('Request received from outside webhooks service') - return res.status(403).send('Not called from webhooks service'); - } - - res.status(204).send(); + // Endpoint Forge redirects to after consent screen + app.get("/callback", function (req: any, res: Response) { + config.authClient + .getToken(req.query.code) + .then((creds: ForgeSDK.AuthToken) => { + credentials = creds; + refreshTime = creds.expires_in - 300; + res.send("Generated token: " + credentials.access_token); + console.log( + "\x1b[92mserver.js::createServer:", + "\x1b[0m/callback reached, token generated" + ); + }) + .then(async () => { + // get webhook callback url from ngrok + let webhookUrl = await getWebhookCallbackURL(); - let body = req.body; + // run the setup function for webhooks + webhooks.setupHooks(webhookUrl, credentials); - if (credentials && body.hook.event === "dm.version.added") { - const itemID = body.payload.lineageUrn; - //TODO: check if extension is in the name - const fileName = body.payload.name; - const destination = __dirname + '/file_share' + config.folderIDtoLocal[body.payload.parentFolderUrn]; + // register secret token + webhooks.setupToken(credentials); - downloader.download(config.projectID, itemID, fileName, destination, credentials); - } - }); + // pass credentials to local hook + localhook.setCredentials(credentials); + + // sets refresh() function to run on an interval every 55 minutes + intervalID = setInterval(() => refresh(), refreshTime * 1000); // 55 seconds to ms - // Will make some server endpoints to test db - app.get('/db/insert', async function (req: any, res: any) { - const result = await db.insert("local", { - fileName: crypto.randomBytes(10).toString('hex'), - folderName: 'Mill', + // sets timeout for 13 days, before clearing refresh interval and clearing credentials + setTimeout(() => { + credentials.access_token = ""; + clearInterval(intervalID); + console.log( + "\x1b[93mserver.js::createServer:", + "\x1b[0mRefresh token expiring, need to authenticate again" + ); + }, 13 * 24 * 3600 * 1000); // 13 days to ms + }) + .catch((err: ForgeSDK.ApiError) => { + console.error(err); + res.send(err); + }); }); - res.send(result); - }); - // Default endpoint - app.use((err: any, req: any, res: any, next: any) => { - console.error(err); - res.status(err.statusCode).json(err); - }); + // Endpoint for internal use, to get credentials from our auth server + app.get("/credentials", function (req: Request, res: Response) { + if (credentials.access_token !== "") { + console.log( + "\x1b[96mserver.js::createServer:", + "\x1b[0m/credentials endpoint called, credentials returned" + ); + res.send(credentials); + } else { + console.log( + "\x1b[93mserver.js::createServer:", + "\x1b[0m/credentials endpoint called, no credentials found" + ); + res.send("Need to authenticate at localhost:3000/auth"); + } + }); - const server = app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); }); + app.post("/hook", async function (req: any, res: Response) { + if (!req.signature_match) { + console.log("Request received from outside webhooks service"); + return res.status(403).send("Not called from webhooks service"); + } - return server; -} + res.status(204).send(); + + let body: Request["body"] = req.body; + + if (credentials && body.hook.event === "dm.version.added") { + const itemID = body.payload.lineageUrn; + //TODO: check if extension is in the name + const fileName = body.payload.name; + const destination = __dirname + "/file_share" + config.folderIDtoLocal[body.payload.parentFolderUrn]; + await downloader.download(config.projectID, itemID, fileName, destination, credentials); + // await deleter.delete(config.projectID, itemID, fileName, credentials); + } + }); + + // Will make some server endpoints to test db + app.get("/db/insert", async function (req: Request, res: Response) { + const result = await db.insert("local", { + fileName: crypto.randomBytes(10).toString("hex"), + folderName: "Mill", + }); + res.send(result); + }); + + + const errorHandler: ErrorRequestHandler = (err, req, res, next) => { + console.error(err); + res.status(err.statusCode).json(err); + }; + + // Default endpoint + app.use(errorHandler); + + const server = app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); + }); + + return server; +}; /** * Used internally to refresh tokens automatically */ const refresh = () => { - config.authClient.refreshToken(credentials, config.scopes) - .then(creds => { - credentials = creds; - console.log( - '\x1b[92mserver.js::refresh:', - '\x1b[0mnew token generated from refresh token:' - ); - console.log(credentials); - - // check on webhooks - webhooks.setupHooks(credentials); - - // refresh localhook credentials - localhook.setCredentials(credentials); - }) - .catch(err => { - console.log(err); - }); + config.authClient + .refreshToken(credentials, config.scopes) + .then(async (creds: ForgeSDK.AuthToken) => { + credentials = creds; + console.log( + "\x1b[92mserver.js::refresh:", + "\x1b[0mnew token generated from refresh token:" + ); + console.log(credentials); + + // check callback URL + let webhookUrl = await getWebhookCallbackURL(); + + // check on webhooks + webhooks.setupHooks(webhookUrl, credentials); + + // refresh localhook credentials + localhook.setCredentials(credentials); + }) + .catch((err: ForgeSDK.ApiError) => { + console.log(err); + }); }; const server = createServer(); @@ -205,9 +254,11 @@ const server = createServer(); * With docker, ctrl+c doesn't work (apparently it is not a SIGINT), * need to open a new terminal and run `docker-compose stop` */ -process.on('SIGTERM', async () => { - console.log("shutting down node process") - await db.shutdown(); - if (server) {server.close()} - process.exit(0) -}); \ No newline at end of file +process.on("SIGTERM", async () => { + console.log("shutting down node process"); + await db.shutdown(); + if (server) { + server.close(); + } + process.exit(0); +}); diff --git a/src/webhooks.ts b/src/webhooks.ts deleted file mode 100644 index a070ae7..0000000 --- a/src/webhooks.ts +++ /dev/null @@ -1,164 +0,0 @@ -const axios = require('axios'); -import * as config from './config'; - - -/** - * Kevin Pan | pan261@purdue.edu | Last Modified: 1/24/2022 - * - * Functions to manage hook lifecycle - * setup() checks if hooks are valid and active, if not it will reactivated - * and/or recreate the specified hook. There is one hook for each of the - * following events: - * - * - 'dm.version.added' (file added) - * - 'dm.version.modified' (file modified) - * - dm.version.deleted (file deleted) - * - */ - -const fileShareID = "urn:adsk.wipprod:fs.folder:co.T0n0mYQeS16K0lq1VuuYVQ" - -/** - * Function to manage hook lifecycle - */ -exports.setupHooks = (credentials: any) => { - console.log("checking hooks"); - axios({ - method: 'GET', - url: 'https://developer.api.autodesk.com/webhooks/v1/hooks', - headers: { - Authorization: `Bearer ${credentials.access_token}` - } - }).then((res: any) => { - return res.data.data; - }).then((data: any) => { - var hookEvents = []; - for (var index in data) { - - const hook = data[index]; - - if (hook.callbackUrl != config.hookCallbackURL) { // hooks with invalid callbacks will be deleted, not added to current list of hooks - deleteHook(hook.event, hook.hookId, credentials); - } else { - hookEvents.push(hook.event); - if (hook.status === 'inactive') { - console.log("reactivating " + hook.event + " hook"); - reactivateHook(hook.event, hook.hookId, credentials); - } - } - } - - // these are the events we want, if not in the list, then register the hook - if (!hookEvents.includes('dm.version.added')) { - createHook('dm.version.added', credentials); - } - - if (!hookEvents.includes('dm.version.modified')) { - createHook('dm.version.modified', credentials); - } - - if (!hookEvents.includes('dm.version.deleted')) { - createHook('dm.version.deleted', credentials); - } - }).catch((err: any) => { - console.log(err); - }) -} - - -const createHook = (event: string, credentials: any) => { - axios({ - method: 'POST', - url: `https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks`, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${credentials.access_token}` - }, - data: { - autoReactivateHook: "true", - callbackUrl: config.hookCallbackURL, - scope: { - folder: fileShareID - } - } - }).then((res: any) => { - console.log("sucessfully created hook for " + event); - }).catch((err: any) => { - console.log("error creating hook for " + event + ": " + err.response.status); - }); -} - -const reactivateHook = (event: string, hookID: string, credentials: any) => { - axios({ - method: 'PATCH', - url: `https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks/${hookID}`, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${credentials.access_token}` - }, - data: { - status: 'active', - autoReactivateHook: true, - } - }).then((res: any) => { - console.log("successfully reactivated " + event + " hook"); - }).catch((err: any) => { - console.log("error reactivating hook for " + event + ": " + err.response.status); - }); -} - -const deleteHook = (event:string, hookID: string, credentials: any) => { - axios({ - method: 'DELETE', - url: `https://developer.api.autodesk.com/webhooks/v1/systems/data/events/${event}/hooks/${hookID}`, - headers: { - Authorization: `Bearer ${credentials.access_token}` - } - }).then((res: any) => { - console.log("deleted " + event + " hook with bad callback url"); - }).catch((err: any) => { - console.log("error deleting hook for " + event + ": " + err.response.status); - }); -} - -exports.setupToken = (credentials: any) => { - console.log("Checking webhook token") - axios({ - method: 'POST', - url: 'https://developer.api.autodesk.com/webhooks/v1/tokens', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${credentials.access_token}` - }, - data: { - token: config.webhookToken - } - }).then((res: any) => { - console.log(res.data.detail); - }).catch((err: any) => { - if (err.response.status == 400) { - console.log("Webhook token already exists") - } else { - console.log("Error creating a new webhook token"); - } - }); -} - -// not sure how to incorporate this into webhook token workflow -const updateToken = (credentials: any) => { - axios({ - method: 'PUT', - url: 'https://developer.api.autodesk.com/webhooks/v1/tokens/@me', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${credentials.access_token}` - }, - data: { - token: config.webhookToken - } - }).then((res: any) => { - console.log("Successfully updated webhook token"); - }).catch((err: any) => { - console.log("Error updating webhook token"); - }); -} \ No newline at end of file