diff --git a/src/aws-cognito-s3.js b/src/aws-cognito-s3.js new file mode 100644 index 0000000..52ee67a --- /dev/null +++ b/src/aws-cognito-s3.js @@ -0,0 +1,555 @@ +/** + * This file contains the JavaScript code for accessing AWS Cognito and AWS S3 services. + * 1. Cognito is used for AIND user authentication and authorization. + * Login, logout, and token management functions and UI are customized for the AIND Anivia app. + * 2. S3 is used for storing and retrieving data from the AIND Anivia Data bucket(s). + * S3 list and get functions are customized to load a file or folder into the AIND Anivia app. + * A popup dialog (based on AWS S3 Explorer) is provided for users to select a file/folder. + * + * This file is organized in with the following sections: + * 1. AWS Configuration and Setup + * 2. Cognito Functions (AWS SDK-related) + * 3. Cognito UI + * 4. S3 Functions (AWS SDK-related) + * 5. S3 UI + * 6. Utility Functions + * + * Links: + * - AWS JavaScript SDKv2: https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/ + * - AWS JavaScript S3 Explorer: https://github.com/awslabs/aws-js-s3-explorer + */ + +// ========== AWS CONFIGURATION AND SETUP ==================== +// TODO: check if we want to move these to a separate config +const COGNITO_REGION = 'us-west-2'; +const COGNITO_CLIENT_ID = '4i7qgk46rvmna1sesnljkoihnb'; +const COGNITO_USERPOOL_ID = 'us-west-2_ii4G6y7Qk'; +const COGNITO_IDENTITY_POOL_ID = 'us-west-2:3723a551-9e26-4882-af4e-bcd0310b9885'; +const COGNITO_LOGIN_KEY = `cognito-idp.${COGNITO_REGION}.amazonaws.com/${COGNITO_USERPOOL_ID}`; +const S3_BUCKET_NAME = 'aind-anivia-data-dev'; +const S3_DELIMITER = '/'; + +if (!AWS.config.credentials) { + // TODO: check if this is necessary on non-local environments + AWS.config.update({ region: COGNITO_REGION, accessKeyId: 'PLACEHOLDER', secretAccessKey: 'PLACEHOLDER' }); + console.log("Default AWS credentials created with placeholders."); +} +const INIT_AWS_CREDENTIALS = AWS.config.credentials; + +// global variables +var currentUser = { + username: '', + userIdToken: '', + userAccessToken: '', +}; +var s3Prefix = ''; // S3 prefix for current folder +var AWSCognito = new AWS.CognitoIdentityServiceProvider(); // Cognito client +var s3 = new AWS.S3(); // Default S3 client + +// ==================== COGNITO FUNCTIONS ==================== +/** + * Wrapper for AWS.CognitoServiceProvider.globalSignOut() + * @param {function} callback - custom callback function (default to cognitoSignOutCallback) + */ +function cognitoSignOut(callback = cognitoSignOutCallback) { + var params = { + AccessToken: currentUser.userAccessToken + }; + AWSCognito.globalSignOut(params, callback); +} + +/** + * Wrapper for AWS.CognitoServiceProvider.initiateAuth() + * @param {function} callback - custom callback function + */ +function cognitoInitiateAuth(callback) { + currentUser.username = $('#username').val(); + const authenticationDetails = { + AuthFlow: "USER_PASSWORD_AUTH", + AuthParameters: { + USERNAME: $('#username').val(), + PASSWORD: $("#password").val(), + }, + ClientId: COGNITO_CLIENT_ID, + }; + AWSCognito.initiateAuth(authenticationDetails, callback); +} + +/** + * Set temporary credentials using the IdentityPoolId and IdToken recieved from Cognito, + * and re-initialize the S3 client. + * @param {string} idToken - user id token from Cognito auth result + * @param {string} accessToken - user access token from Cognito auth result + */ +function setTempCredentials(idToken, accessToken) { + currentUser.userIdToken = idToken; + currentUser.userAccessToken = accessToken; + AWS.config.credentials = new AWS.CognitoIdentityCredentials({ + IdentityPoolId: COGNITO_IDENTITY_POOL_ID, + Logins: { + [COGNITO_LOGIN_KEY]: currentUser.userIdToken + } + }); + // Any AWS services we use must be reinitialized + s3 = new AWS.S3(); +} + +/** + * Clear cached credentials and reset currentUser object and S3 client. + */ +function resetTempCredentials() { + AWS.config.credentials.clearCachedId(); + AWS.config.credentials = INIT_AWS_CREDENTIALS; + currentUser = { + username: '', + userIdToken: '', + userAccessToken: '' + }; + // Any AWS services we use must be reinitialized + s3 = new AWS.S3(); +} + +// ==================== COGNITO UI ==================== +/** + * Prompt user for login credentials. + * @param {function | null} successCallback - Optional callback function to execute after successful login. + */ +function promptLogin(successCallback = null) { + bootbox.confirm({ + title: 'Log in', + message: "
", + buttons: { confirm: { label: 'Log in' } }, + callback: (result) => { + if (result) { + if (!$('#username').val() || !$('#password').val()) { + console.error("Username and password are required.") + promptLogin(); + return; + } + cognitoInitiateAuth((err, result) => cognitoInititateAuthCallback(err, result, successCallback)); + } else { + console.log("User cancelled login."); + } + } + }); +} + +/** + * Prompt user to confirm logout. + */ +function promptLogout() { + bootbox.confirm({ + title: 'Log out', + message: 'Are you sure?', + buttons: { confirm: { label: 'Log out' } }, + callback: (result) => { + if (result) { + cognitoSignOut(); + } else { + console.log("User cancelled logout."); + } + } + }); +} + +/** + * Custom callback function for cognitoInitiateAuth() + * If auth successful, set temporary credentials and update UI. + * Otherwise, re-prompt for login. + * @param {*} err - Error object + * @param {*} result - AuthenticationResult object + * @param {function | null} successCallback - Optional callback function to execute after successful login. + */ +function cognitoInititateAuthCallback(err, result, successCallback = null) { + if (err) { + currentUser.username = '' + console.error(err) + bootbox.confirm({ + title: 'Login Unsuccessful', + message: `Error: ${err.message}`, + buttons: { confirm: { label: 'Try again' } }, + callback: (result) => { if (result) promptLogin(); } + }); + } else { + console.log("Authenticated successfully.") + const { IdToken, AccessToken } = result.AuthenticationResult; + setTempCredentials(IdToken, AccessToken); + $('#login').hide(); + $('#logout').show(); + $('#username-display').text(currentUser.username); + if (successCallback) successCallback(); + } +} + +/** + * Custom callback function for cognitoSignOut() + * If sign out successful, clear current user tokens and reset UI. + * Otherwise, notify user of error. + * @param {*} error - Error if cognitoSignOut returned an error + */ +function cognitoSignOutCallback(error) { + if (error) { + logErrorAndAlertUser("Error signing out", error) + } + else { + console.log("User signed out."); + resetTempCredentials(); + $('#login').show(); + $('#logout').hide(); + $('#username-display').text(''); + } +} + +// ==================== S3 FUNCTIONS ==================== +/** + * Wrapper for AWS.S3.listObjectsV2. + * Uses the global S3 client to make an authenticated listObjectsV2 request. + * @param {function} callback - Callback function (defaults to use s3draw() to update the UI) + */ +function s3list(callback = s3draw) { + var scope = { + Contents: [], + CommonPrefixes: [], + params: { + Bucket: S3_BUCKET_NAME, + Delimiter: S3_DELIMITER, + Prefix: s3Prefix, + }, + stop: false, + completecb: callback + }; + return { + // Callback that the S3 API makes when an S3 listObjectsV2 request completes (successfully or in error) + // Note: We do not continue to list objects if response is truncated + cb: function (err, data) { + if (err) { + scope.stop = true; + bootbox.alert({ + title: `Error accessing S3 bucket ${scope.params.Bucket}`, + message: `Error: ${err.message}`, + }); + } else { + scope.Contents.push.apply(scope.Contents, data.Contents); + scope.CommonPrefixes.push.apply(scope.CommonPrefixes, data.CommonPrefixes); + if (scope.stop) { + console.log(`Bucket ${scope.params.Bucket} stopped`); + return; + } else if (data.IsTruncated) { + console.log(`Bucket ${scope.params.Bucket} truncated before complete`); + } + console.log(`Listed ${scope.Contents.length} objects from ${scope.params.Bucket}/${scope.params.Prefix ?? ''}, including ${scope.CommonPrefixes.length} folders`); + scope.completecb(scope, true); + } + }, + go: function () { + scope.cb = this.cb; + s3.makeRequest('listObjectsV2', scope.params, this.cb); + }, + stop: function () { + scope.stop = true; + scope.completecb(scope, false); + } + }; +} + +/** + * Loads files from S3 into the VIA app. + * If target path is a folder, load all files in the folder by first calling AWS.S3.listObjectsV2to get folder contents. + * For each file, call AWS.S3.getObject to get the file. + * Calls appropriate project_file_callback function from via.js based on input loadType to add the file(s) to the app. + * @param {string} path - S3 path to file or folder + * @param {string} loadType - project load option (options are 'deeplabcut', 'lightning_pose', 'slp', 'file/folder') + */ +function load_s3_into_app(path, loadType) { + path = path.replace(S3_BUCKET_NAME + '/', ''); // remove bucket name prefix + var s3Params = { + Bucket: S3_BUCKET_NAME, + Key: path + }; + var fakeEvent = { target: { files: [] } }; + var project_file_callback = getViaProjectFileCallback(loadType); + if (pathIsFolder(path)) { + // get list of objects in this folder + s3.makeRequest('listObjectsV2', { Bucket: S3_BUCKET_NAME, Prefix: path }, async (err, data) => { + if (err) { + logErrorAndAlertUser("S3 Error", err) + } else { + const parentPrefix = getParentFolderFromPath(path); + // get each file, then add all files to VIA + await Promise.all(data.Contents.map((obj) => { + let filepath = obj.Key; + return new Promise((resolve, reject) => { + s3Params.Key = filepath; + s3.makeRequest('getObject', s3Params, (err, data) => { + if (err) { + reject(err); + } else { + var file = new File([data.Body], getFileOrFolderNameFromPath(filepath), { type: data.ContentType }); + file.s3RelativePath = filepath.replace(parentPrefix, ''); // preserve relative path + resolve(file); + } + }); + }); + })).then((files) => { + console.log(`Retrieved ${files.length} files from ${path}`) + fakeEvent.target.files = files; + project_file_callback(fakeEvent, true); + }).catch((err) => { + logErrorAndAlertUser("S3 Download Error", err) + }); + } + }); + } else { + s3.makeRequest('getObject', s3Params, (err, data) => { + if (err) { + logErrorAndAlertUser("S3 Error", err) + } else { + console.log(`Retrieved file from ${path}`) + var file = new File([data.Body], getFileOrFolderNameFromPath(path), { type: data.ContentType }); + fakeEvent.target.files = [file]; + project_file_callback(fakeEvent, true); + } + }); + } +} + +// ==================== S3 UI ==================== +/** + * Modal to prompt user to select files from AIND Anivia S3 bucket. + * First prompts user to log in if not already logged in. + * Initializes a DataTable to display S3 objects. + * Includes delegated event handlers for folder entry, selection, reset, and back buttons. + * @param {string} loadType - project load option (default to 'file/folder', other options are 'deeplabcut', 'lightning_pose', 'slp') + */ +function sel_s3_images(loadType = 'file/folder') { + if (currentUser.username === '') { + promptLogin(sel_s3_images.bind(null, loadType)); + return; + } + bootbox.prompt({ + title: `Load ${loadType} from AWS S3`, + message: + `Object | Last Modified | Size |
---|