<|start_filename|>terra-jupyter-base/custom/google_sign_in.js<|end_filename|>
/*
 * This library is designed to run as a Jupyter/JupyterLab extension to refresh the user's
 * Google credentials while using a notebook. This flow is described in more detail here:
 * https://github.com/DataBiosphere/leonardo/wiki/Connecting-to-a-Leo-Notebook#token-refresh
 *
 * Note since this runs inside both Jupyter and JupyterLab, it should not use any
 * libraries/functionality that exists in one but not the other. Examples: node, requireJS.
 */


// define default values for config parameters
const params = {
  loginHint: '',
  googleClientId: '',
  googleProject: '',
  clusterName: ''
};

// update params with any specified in the server's config file,
// or retrieve that info from the url if we cannot access it (as is the case in terminal view)
function updateParams() {
  if (!Jupyter.notebook || !Jupyter.notebook.config || !Jupyter.notebook.config.data) {
    console.warn('Unable to read notebook config. This is expected in terminal view, but not elsewhere. Attempting to read fallback config.');
    readFallbackConfig();
  } else {
    readNotebookConfig(Jupyter.notebook.config.data);
  }
}

function readNotebookConfig(config) {
  for (const key in params) {
    if (config.hasOwnProperty(key)) {
      params[key] = config[key];
    }
  }
}

// here we attempt to parse the url for the googleProject and the clusterName
function readFallbackConfig() {
  const url = window.location.href;
  const initialSearch = 'proxy/';

  const projectSubstringStartLocation = url.search(initialSearch) + initialSearch.length;
  const projectSubstring = url.substring(projectSubstringStartLocation, url.length);
  const projectEndLocation = projectSubstring.search('/');
  const googleProject = projectSubstring.substring(0, projectEndLocation);

  // we add 1 for the slash between project and cluster
  const clusterSubstring = projectSubstring.substring(projectEndLocation + 1, projectSubstring.length);
  const clusterEndLocation = clusterSubstring.search('/');
  const clusterName = clusterSubstring.substring(0, clusterEndLocation);

  console.info(`Attempted to parse the url for a fallback configuration. 
                         Found googleProject: '${googleProject}' and clusterName '${clusterName}'`);

  params.googleProject = googleProject;
  params.clusterName = clusterName;

  fallbackReadNotebookConfig(googleProject, clusterName);
}

function fallbackReadNotebookConfig(googleProject, clusterName) {
  const url = `/proxy/${googleProject}/${clusterName}/jupyter/api/config/notebook`;
  xhttpGet(url, (res) => {
    readNotebookConfig(res);
  });
}

function receive(event) {
  if (event.data.type == 'bootstrap-auth.response') {
    if (event.source !== window.opener) {
      return;
    }
    params.googleClientId = event.data.body.googleClientId;
  } else if (event.data.type == 'bootstrap-auth.request') {
    if (event.origin !== window.origin) {
      return;
    }
    if (!params.googleClientId) {
      return;
    }
    event.source.postMessage({
      'type': 'bootstrap-auth.response',
      'body': {
        'googleClientId': params.googleClientId
      }
    }, event.origin);
  }
}

// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/response
function xhttpGet(url, callback) {
  const xhr = new XMLHttpRequest();

  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4 && xhr.status == 200) {
      const jsonResp = JSON.parse(xhr.responseText);
      callback(jsonResp);
    }
  };

  xhr.open('GET', url, true);
  xhr.send('');
}

function startTimer() {
  loadGapi('auth2', function() {
    function doAuth() {
      if (params.googleClientId) {
        gapi.auth2.authorize({
          'client_id': params.googleClientId,
          'scope': 'openid profile email',
          'login_hint': params.loginHint,
          'prompt': 'none'
        }, function(result) {
          if (result.error) {
            console.error('Error occurred authorizing with Google: ' + result.error);
            return;
          }
          setCookie(result.access_token, result.expires_in);
        });
      }
    }

    // refresh token every 3 minutes
    console.log('Starting token refresh timer');
    setInterval(doAuth, 180000);
  });

  function statusCheck() {
    const url = `/proxy/${params.googleProject}/${params.clusterName}/jupyter/api/status`;
    // logging the statusCheck can be noisy, and it will tell us in the console if it fails
    xhttpGet(url, () => {});
  }

  setInterval(statusCheck, 60000);
}

// Note: this should match
// https://github.com/DataBiosphere/leonardo/blob/develop/http/src/main/scala/org/broadinstitute/dsde/workbench/leonardo/util/CookieHelper.scala
function setCookie(token, expiresIn) {
  document.cookie = 'LeoToken=' + token + '; Max-Age=' + expiresIn + '; Path=/; Secure; SameSite=None';
}

function loadGapi(googleLib, continuation) {
  console.log('Loading Google APIs');
  // Get the gapi script from Google.
  const gapiScript = document.createElement('script');
  gapiScript.src = 'https://apis.google.com/js/api.js';
  gapiScript.type = 'text/javascript';
  gapiScript.async = true;

  // Load requested API scripts onto the page.
  gapiScript.onload = function() {
    console.log('Loading Google library \'' + googleLib + '\'');
    gapi.load(googleLib, continuation);
  };
  gapiScript.onerror = function() {
    console.error('Unable to load Google APIs');
  };
  document.head.appendChild(gapiScript);
}

function init() {
  console.log('Starting google_sign_in extension');
  updateParams();
  startTimer();
  window.addEventListener('message', receive);
  if (!params.googleClientId && window.opener) {
    window.opener.postMessage({'type': 'bootstrap-auth.request'}, '*');
  }
}

init();


<|start_filename|>terra-jupyter-base/custom/safe-mode.js<|end_filename|>
// Adapted from the All of Us Researcher Workbench "Playground Mode"
// https://github.com/all-of-us/workbench/blob/master/api/cluster-resources/playground-extension.js

// In "Safe Mode", changes are not saved back to GCS. This extension makes
// minor UI tweaks to differentiate this mode from normal Jupyter usage, and
// also removes/hides controls relating to persistence. Technically
// this does not stop autosave from continuing to happen in the background, but
// the intended use of this plugin is in a separate space from normal operation
// which does not support localization features.

// const namespace = require('base/js/namespace')

define(() => {
  // define default values for config parameters
  const params = {
    googleProject: '',
    clusterName: '',
    welderEnabled: 'false'
  };

  // update params with any specified in the server's config file
  function updateParams() {
    const config = Jupyter.notebook.config;
    for (const key in params) {
      if (config.data.hasOwnProperty(key)) {
        params[key] = config.data[key];
      }
    }

    // generate URLs based on params
    const welderUrl = `/proxy/${params.googleProject}/${params.clusterName}/welder`;
    params.checkMetaUrl = welderUrl + '/objects/metadata';
  }

  const headers = {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Headers': '*'
  };

  const basePayload = {
    mode: 'no-cors',
    headers: headers
  };

  function load() {
    console.info('safe mode plugin initialized');

    if (!Jupyter.notebook) {
      return; // exit, they are in list view
    }

    updateParams();

    if (!(params.welderEnabled == 'true')) {
      console.info('welder is not enabled');
      return;
    }

    checkMetaLoop();
  }

  function checkMetaLoop() {
    triggerUIToggle();

    const interval = setInterval(() => {
      triggerUIToggle();
    }, 60000);

    window.onbeforeunload(() => {
      clearInterval(interval);
    });
  }

  async function triggerUIToggle() {
    checkMeta()
        .then((res) => {
          if (res.syncMode == 'EDIT') {
            toggleUIControls(false);
          } else {
            // there is an icon in the jupyter UI that has a tool tip that says 'edit mode'.
            // this shows up whenever a user types, so we change the tool-tip to avoid confusion
            $('#modal_indicator').tooltip({'content': 'Adding code'});
            toggleUIControls(true);
          }
        })
        .catch((err) => {
          console.error(err);
          toggleUIControls(false); // we always assume safe mode if the check meta call fails
        });
  }

  function checkMeta() {
    const payload = {
      ...basePayload,
      body: JSON.stringify({localPath: Jupyter.notebook.notebook_path}),
      method: 'POST'
    };

    return fetch(params.checkMetaUrl, payload)
        .then((res) => {
          if (!res.ok) {
            throw Error('check metadata call failed due to status code');
          }
          return res.json();
        });
  }

  function toggleUIControls(shouldHide) {
    // these are the jquery selectors for the elements we will toggle
    // "notbook" is an intentional typo to match the Jupyter UI HTML.
    const selectorsToHide = ['#save-notbook', '#new_notebook',
      '#open_notebook', '#copy_notebook', '#save_notebook_as',
      '#save_checkpoint', '#restore_checkpoint', '.checkpoint_status',
      '.autosave_status', '#notification_notebook', '#file_menu > li.divider:eq(0)',
      '#file_menu > li.divider:eq(2)'
    ];

    selectorsToHide.forEach((selector) => {
      if (shouldHide) {
        $(selector).hide();
      } else {
        $(selector).show();
      }
    });
  }

  load();
});


<|start_filename|>terra-jupyter-base/custom/extension_entry_jupyter.js<|end_filename|>
define([
  'base/js/events'
], function(events) {
  require(['custom/google_sign_in']);
  require(['custom/edit-mode']);
  require(['custom/safe-mode']);
});


<|start_filename|>terra-jupyter-base/custom/edit-mode.js<|end_filename|>
define(() => {
  // define default values for config parameters
  const params = {
    googleProject: '',
    clusterName: '',
    welderEnabled: 'false'
  };

  // update params with any specified in the server's config file
  function updateParams() {
    const config = Jupyter.notebook.config;
    for (const key in params) {
      if (config.data.hasOwnProperty(key)) {
        params[key] = config.data[key];
      }
    }

    // generate URLs based on params
    const leoUrl = ''; // we are choosing to use a relative path here
    const welderUrl = leoUrl + `/proxy/${params.googleProject}/${params.clusterName}/welder`;

    // URLS for local testing
    // let leoUrl = 'http://localhost:8080' //for testing against local server
    // let welderUrl = leoUrl
    // params.jupyterServerApi = '/api/contents/'
    // params.jupyterFsHref = '/notebooks/'

    params.jupyterServerApi = leoUrl + `/notebooks/${params.googleProject}/${params.clusterName}` + '/api/contents/';
    params.jupyterFsHref = leoUrl + `/notebooks/${params.googleProject}/${params.clusterName}/notebooks/`;
    params.localizeUrl = welderUrl + '/objects';
    params.checkMetaUrl = welderUrl + '/objects/metadata';
    params.lockUrl = welderUrl + '/objects/lock';
  }

  let modalOpen = false;
  // this needs to be available so the loop can be cancelled where needed
  let syncMaintainer;
  let shouldExit = false;

  const syncIssueButtons = (res) => {
    return {
      'Make a Copy': {
        'click': () => notebookSaveAs(),
        'class': 'btn-primary',
        'id': 'modal-copy-1'
      },
      'Reload the workspace version and discard your changes': {
        'click': () => updateLocalCopyWithRemote(res),
        'id': 'modal-reload'
      }
    };
  };

  const lockIssueButtons = (res) => {
    return {
      'Run in Playground Mode': {
        'click': () => openPlaygroundMode(res),
        'class': 'btn-primary',
        'id': 'modal-playground'
      },
      'Make a Copy': {
        'click': () => notebookSaveAs(),
        'id': 'modal-copy-2'
      }
    };
  };

  const noRemoteFileButtons = {
    'Continue working': {
      'click': () => {},
      'class': 'btn-primary'
    }
  };

  const modeBannerId = 'notification_mode';
  const lockConflictTitle = 'File is in use';
  const syncIssueTitle = 'File versions out of sync';
  const syncIssueBody = 'Your version of this file does not match the version in the workspace. What would you like to do?';
  const syncIssueNotFoundBody = 'This file was either deleted from or was never saved to the workspace.';
  const lastLockedTimer = 60000;

  const headers = {
    'Content-Type': 'application/json',
    'Access-Control-Allow-Headers': '*'
  };

  const basePayload = {
    mode: 'no-cors',
    headers: headers
  };

  function init() {
    console.info('edit mode plugin initialized');

    if (!Jupyter.notebook) {
      return; // exit, they are in list view
    }

    updateParams();

    if (!(params.welderEnabled == 'true')) {
      console.info('welder is not enabled');
      return;
    }

    checkMeta();

    const initialSyncDelay = 27000;
    // we want subsequent checkMeta + lock calls to be off-cycle of autosave
    setTimeout(() => initSyncMaintainer(), initialSyncDelay);
  }

  function initSyncMaintainer() {
    syncMaintainer = setInterval(() => {
      checkMeta();
    }, lastLockedTimer);

    window.onbeforeunload(function() {
      clearInterval(syncMaintainer);
    });
  }

  function checkMeta() {
    const localPath = {
      localPath: Jupyter.notebook.notebook_path
    };

    console.info('calling /objects/metadata/ with payload: ', JSON.stringify(localPath));

    const payload = {
      ...basePayload,
      body: JSON.stringify(localPath),
      method: 'POST'
    };

    return fetch(params.checkMetaUrl, payload)
        .then((res) => {
          processInitialCheckMeta(res);
          return res.json();
        })
        .then((res) => {
          handleMetaSuccess(res);
          return res;
        })
        .catch((err) => {
          handleMetaFailure(err);
        });
  }

  function renderFileNotTrackedBanner() {
    removeElementById(modeBannerId);

    const toolTipText = '<p>Your changes are not being saved to the workspace.</p>';

    $('#notification_area').append(
        $('<div>').attr({
          'id': 'notification_not_saving',
          'class': 'btn-warning btn btn-xs navbar-btn',
          'data-toggle': 'tooltip',
          'data-html': 'true',
          'title': toolTipText
        })
            .tooltip({
              'content': function() {
                return $(this).prop('title');
              },
              'placement': 'bottom'
            })
            .append(
                $('<span>').html('Remote Save Disabled')
                    .append(' <i class="fa fa-question-circle" aria-hidden="true"></i>')
            )
    );
  }

  function processInitialCheckMeta(res) {
    if (!res.ok) {
      if (res.status == 412) {
        console.warn('detected 412 from /objects/metadata. stopping loop');
        renderFileNotTrackedBanner();
        shouldExit = true;
        clearInterval(syncMaintainer);
      }

      throw Error(res.statusText);
    }
  }

  function handleMetaSuccess(res) {
    toggleMetaFailureBanner(false); // sets banner for meta status

    const isEditMode = res.syncMode == 'EDIT';

    if (isEditMode) {
      handleCheckMetaResp(res); // displays modal if theres an issue in the payload
      getLock(res); // gets lock if in edit mode
    }
    renderModeBanner(isEditMode); // sets edit/safe mode banner
  }

  function handleMetaFailure(err) {
    console.error(err);
    if (!shouldExit) {
      removeElementById(modeBannerId);
      toggleMetaFailureBanner(true);
    }
  }

  // this function assumes any status not included in these lists represents a notebook out of sync
  // this is done to defend against future fields being added being auto-categorized as failures
  function handleCheckMetaResp(res) {
    const healthySyncStatuses = ['LIVE'];
    // below is included for reference, but commented out so it does not cause errors with linting
    // const outOfSyncStatuses = ['DESYNCHRONIZED', 'REMOTE_CHANGED'];
    const notFoundStatus = ['REMOTE_NOT_FOUND'];
    const saveNeededStatus = ['LOCAL_CHANGED'];

    if (healthySyncStatuses.includes(res.syncStatus)) {
      console.info('healthy sync status detected: ', res.syncStatus);
    } else if (notFoundStatus.includes(res.syncStatus)) {
      promptUserWithModal(syncIssueTitle, noRemoteFileButtons, syncIssueNotFoundBody);
    } else if (saveNeededStatus.includes(res.syncStatus)) {
      console.info('detected that we have changes that have not been delocalized.');
      // It is possible saving is the right call here (aka $("#save-notbook > button").click()), but we already do that on a periodic tick
      // adding it here could possibly cause confusion
    } else {
      promptUserWithModal(syncIssueTitle, syncIssueButtons(res), syncIssueBody);
    }
  }

  function getLock(metaRes) {
    const payload = {
      ...basePayload,
      method: 'POST',
      body: JSON.stringify({localPath: Jupyter.notebook.notebook_path})
    };

    fetch(params.lockUrl, payload)
        .then((res) => {
          handleLockStatus(res, metaRes);
        })
        .catch((err) => {
          console.error(err);
        });
  }

  function toggleMetaFailureBanner(shouldShow) {
    const bannerId = 'notification_metaFailure';
    const bannerText = 'Failed to check notebook status, changes may not be saved to workspace. Retrying...';

    removeElementById(bannerId);

    if (shouldShow) {
      const bannerStyling = 'btn btn-xs navbar-btn btn-danger';

      $('#notification_area').append(
          $('<div>').attr({
            'id': bannerId,
            'class': bannerStyling
          })
              .append($('<span>').html('<i class="fa fa-exclamation-triangle"></i> ' + bannerText))
      );
    }
  }

  const lockConflictBody = `<p>This file is currently being edited by another user. ` +
      `Please allow 2-3 minutes after this user has closed the file for it to become available for editing.</p>` +
      `<br/><p>You can make a copy, or run it in Playground Mode to explore and execute its contents without saving any changes.`;

  function handleLockStatus(res, metaRes) {
    if (!res.ok) {
      const status = res.status;
      const errorText = res.statusText;

      if (status == 409) {
        res.json().then((res) => {
          promptUserWithModal(lockConflictTitle, lockIssueButtons(metaRes), lockConflictBody);
        });
      }
      // for the lock endpoint, we consider all non 'ok' statuses an error
      throw new Error(errorText);
    }
  }


  function promptUserWithModal(title, buttons, htmlBody) {
    if (modalOpen) return;

    modalOpen = true;

    // we need to require here because otherwise we haven't checked if the module is loaded
    // (i.e. if we are in terminal view)
    const dialog = require('base/js/dialog');

    dialog.modal({
      body: $('<p>').html(htmlBody),
      title: title,
      buttons: buttons,
      notebook: Jupyter.notebook,
      keyboard_manager: Jupyter.notebook.keyboard_manager
    })
        .on('hidden.bs.modal', () => modalOpen = false)
        .attr('id', 'leoUserModal')
        .find('.close').remove(); // TODO: test going back
  }

  async function openPlaygroundMode(metaRes) {
    const originalNotebookName = Jupyter.notebook.notebook_name;

    const safeModeDir = metaRes.storageLink.localSafeModeBaseDirectory;

    if (Jupyter.notebook.notebook_path.includes(safeModeDir)) {
      console.warn('Attempted to navigate to enter safe mode while already in safe mode. Exitting.');
      return; // we're here already
    }

    // create a new file with the contents
    const postPayload = {
      ...basePayload,
      method: 'POST',
      body: JSON.stringify({
        copy_from: Jupyter.notebook.notebook_path
      })
    };

    const patchPayload = {
      headers: headers,
      method: 'PATCH',
      body: JSON.stringify({
        path: safeModeDir + '/' + originalNotebookName
      })
    };

    fetch(params.jupyterServerApi + safeModeDir, postPayload)
        .then((res) => handleJupyterServerResponse(res))
        .then((res) => {
          // then we rename the file, as POST does not allow us to specify the file name
          fetch(params.jupyterServerApi + res.path, patchPayload)
              .then((res) => {
                // navigate to new file
                window.location.href = params.jupyterFsHref + safeModeDir + '/' + originalNotebookName;
              });
        });
  }

  function notebookSaveAs() {
    // we need to require here because otherwise we haven't checked if the module is loaded
    // (i.e. if we are in terminal view)
    const utils = require('base/js/utils');
    // guarantees a path in [0] and file name in [1]. [0] is "" if just a file is passed
    const originalPathSplit = utils.url_path_split(Jupyter.notebook.notebook_path);
    const newNotebookPath = originalPathSplit[0];

    // create a new file with the contents
    const payload = {
      ...basePayload,
      method: 'POST',
      body: JSON.stringify({
        copy_from: Jupyter.notebook.notebook_path
      })
    };

    fetch(params.jupyterServerApi + newNotebookPath, payload)
        .then((res) => handleJupyterServerResponse(res))
        .then((res) => {
          // navigate to new file. we rely on the jupyter post api to supply the name of the file we have created as it ensures it does not exist
          // POST also does not allow for the specification of a file name
          window.location.href = params.jupyterFsHref + res.path;
        });
  }

  function handleJupyterServerResponse(res) {
    if (!res.ok) {
      throw new Error('failed to perform requested action, the jupyter server is unavailable');
    }
    return res.json();
  }

  function removeElementById(id) {
    if (!$('#' + id).length == 0) {
      $('#' + id).remove();
    }
  }

  // shows the user whether they are in playground mode or edit mode
  function renderModeBanner(isEditMode) {
    removeElementById(modeBannerId); // we always remove the banner because we re-render each loop

    let bannerText;
    let toolTipText;
    let bannerStyling;

    const baseStyling = 'btn btn-xs navbar-btn';

    if (isEditMode) {
      bannerText = 'Edit Mode';
      toolTipText = '<p>You have locked this file for editing and your changes are being automatically saved to the workspace.</p>';
      bannerStyling = 'notification_widget ' + baseStyling;
    } else {
      bannerText = 'Playground Mode (Edits not saved)';
      toolTipText = '<p>Playground mode allows you to explore, change, and run the notebook, but changes are not saved to the workspace.</p>';
      bannerStyling = 'btn-warning ' + baseStyling;
    }

    $('#notification_area').append(
        $('<div>').attr({
          'id': modeBannerId,
          'class': bannerStyling,
          'data-toggle': 'tooltip',
          'data-html': 'true',
          'title': toolTipText
        })
            .tooltip({
              'content': function() {
                return $(this).prop('title');
              },
              'placement': 'bottom'
            })
            .append(
                $('<span>').html(bannerText)
                    .append(' <i class="fa fa-question-circle" aria-hidden="true"></i>')
            )
    );
  }

  async function updateLocalCopyWithRemote(meta) {
    const entries = {
      action: 'localize',
      entries: [{
        sourceUri: meta.storageLink.cloudStorageDirectory + '/' + Jupyter.notebook.notebook_name,
        localDestinationPath: Jupyter.notebook.notebook_path
      }]
    };

    const payload = {
      ...basePayload,
      method: 'POST',
      body: JSON.stringify(entries)
    };

    await fetch(params.localizeUrl, payload);

    location.reload(true);
  }

  init();
});


<|start_filename|>hub/Dockerfile<|end_filename|>
FROM ubuntu:14.04

RUN apt-get -y update 

RUN apt-get -y install software-properties-common

RUN add-apt-repository -y ppa:cpick/hub

RUN apt-get -y update

RUN apt-get -y install hub

RUN git config --system core.logallrefupdates false

RUN mkdir -p /app/leo
WORKDIR /app/leo

RUN useradd -m dsde-jenkins
USER dsde-jenkins

RUN mkdir /home/dsde-jenkins/.config

CMD ["/bin/bash"]



<|start_filename|>terra-jupyter-base/custom/.eslintrc.js<|end_filename|>
module.exports = {
    'env': {
        'browser': true,
        'es6': true,
    },
    'extends': [
        'google',
    ],
    'globals': {
        'Atomics': 'readonly',
        'SharedArrayBuffer': 'readonly',
    },
    'parserOptions': {
        'ecmaVersion': 2018,
        'sourceType': 'module',
    },
    'rules': {
        'comma-dangle': ['error', 'never'],
        //80 is the standard, but this required the least refactoring.
        'max-len': ['error', { 'code': 150 }],
        //TODO: remove the following rules and fix the offenses
        'require-jsdoc': ['error', {
            'require': {
                'FunctionDeclaration': false,
                'MethodDefinition': false,
                'ClassDeclaration': false,
                'ArrowFunctionExpression': false,
                'FunctionExpression': false
            }
        }]
    },
};