Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Get extension working in vscode.dev #124

Closed
wants to merge 16 commits into from
5 changes: 3 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionDevelopmentKind=web"
]
}
]
}
}
8,133 changes: 0 additions & 8,133 deletions package-lock.json

This file was deleted.

15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"Snippets"
],
"icon": "images/hubspot-logo.png",
"main": "./dist/index.js",
"browser": "./dist/web/extension.js",
"activationEvents": [
"onLanguage:html-hubl",
"onLanguage:css-hubl",
Expand Down Expand Up @@ -191,16 +191,17 @@
}
},
"dependencies": {
"@hubspot/cli-lib": "^3.0.7",
"fs-extra": "^9.0.1",
"node-fetch": "^2.6.0",
"set-value": ">=4.0.1"
"axios": "^0.24.0",
"buffer": "^6.0.3",
"cross-fetch": "^3.1.4",
"moment": "^2.29.1",
"vscode-uri": "^3.0.2",
"yaml": "^1.10.2"
},
"devDependencies": {
"@types/node": "^15.12.4",
"@types/vscode": "1.30.0",
"@types/vscode": "^1.60.0",
"eslint": "^7.5.0",
"node-loader": "^2.0.0",
"prettier": "^2.0.5",
"ts-loader": "^9.2.3",
"typescript": "^4.3.4",
Expand Down
65 changes: 65 additions & 0 deletions src/core/api/dfs/v1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as http from '../../http';
import { getRequestOptions } from '../../requestOptions';

const FILE_MAPPER_API_PATH = 'content/filemapper/v1';

/**
* Track CMS CLI usage
*
* @async
* @returns {Promise}
*/
async function trackUsage(eventName, eventClass, meta = {}, accountId) {
const usageEvent = {
accountId,
eventName,
eventClass,
meta,
};
const EVENT_TYPES = {
VSCODE_EXTENSION_INTERACTION: 'vscode-extension-interaction',
CLI_INTERACTION: 'cli-interaction',
};

let analyticsEndpoint;

switch (eventName) {
case EVENT_TYPES.CLI_INTERACTION:
analyticsEndpoint = 'cms-cli-usage';
break;
case EVENT_TYPES.VSCODE_EXTENSION_INTERACTION:
analyticsEndpoint = 'vscode-extension-usage';
break;
default:
console.debug(
`Usage tracking event '${eventName}' is not a valid event type.`
);
}

const path = `${FILE_MAPPER_API_PATH}/${analyticsEndpoint}`;

console.debug('Sending usage event to authenticated endpoint');

try {
return http.post(accountId, {
uri: `${path}/authenticated`,
body: usageEvent,
});
} catch (err) {
console.error(err);
}
// TODO: Add env to config
const env = 'prod';
const requestOptions = getRequestOptions(
{ env },
{
uri: path,
body: usageEvent,
resolveWithFullResponse: true,
}
);
console.debug('Sending usage event to unauthenticated endpoint');
return http.post(requestOptions);
}

export { trackUsage };
22 changes: 22 additions & 0 deletions src/core/api/validate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as http from '../http';

const HUBL_VALIDATE_API_PATH = 'cos-rendering/v1/internal/validate';

/**
* @async
* @param {number} accountId
* @param {string} sourceCode
* @param {object} hublValidationOptions
* @returns {Promise}
*/
async function validateHubl(accountId, sourceCode, hublValidationOptions) {
return http.post(accountId, {
uri: HUBL_VALIDATE_API_PATH,
body: {
template_source: sourceCode,
...hublValidationOptions,
},
});
}

export { validateHubl };
18 changes: 18 additions & 0 deletions src/core/auth/authenticated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// const http = require('../../http');
import fetch from 'cross-fetch';

const getRequestUri = (options) => {
return 'https://api.hubapi.com' + options.uri;
};

async function fetchScopeData(accountId, scopeGroup) {
return fetch(getRequestUri('localdevauth/v1/auth/check-scopes'), {
query: {
scopeGroup,
},
});
}

module.exports = {
fetchScopeData,
};
154 changes: 154 additions & 0 deletions src/core/auth/personalAccessKey.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import moment from 'moment';
import { getConfig, getAccountConfig, setConfig } from '../config';

// const { getValidEnv } = require('./lib/environment');
const {
PERSONAL_ACCESS_KEY_AUTH_METHOD,
ENVIRONMENTS,
} = require('../constants');
// const { logErrorInstance } = require('./errorHandlers/standardErrors');
const { fetchAccessToken } = require('./unauthenticated');

const refreshRequests = new Map();

function getRefreshKey(personalAccessKey, expiration) {
return `${personalAccessKey}-${expiration || 'fresh'}`;
}

async function getAccessToken(
personalAccessKey,
env = ENVIRONMENTS.PROD,
accountId
) {
let response;
try {
const _response = await fetchAccessToken(personalAccessKey, env, accountId);
response = await _response.json();
} catch (e) {
console.log(e);
if (e.response) {
const errorOutput = `Error while retrieving new access token: ${e.response.statusText}.`;
if (e.response.status === 401) {
throw new Error(
`Your personal access key is invalid. Please run "hs auth personalaccesskey" to reauthenticate. See https://designers.hubspot.com/docs/personal-access-keys for more information.`
);
} else {
throw new Error(errorOutput);
}
} else {
throw e;
}
}

return {
portalId: response.hubId,
accessToken: response.oauthAccessToken,
expiresAt: moment(response.expiresAtMillis),
scopeGroups: response.scopeGroups,
encodedOauthRefreshToken: response.encodedOauthRefreshToken,
};
}

async function refreshAccessToken(
accountId,
personalAccessKey,
env = ENVIRONMENTS.PROD
) {
const { accessToken, expiresAt } = await getAccessToken(
personalAccessKey,
env,
accountId
);
const config = getConfig();

setConfig({
...config,
portalId: accountId,
tokenInfo: {
accessToken,
expiresAt: expiresAt.toISOString(),
},
});

return accessToken;
}

async function getNewAccessToken(accountId, personalAccessKey, expiresAt, env) {
const key = getRefreshKey(personalAccessKey, expiresAt);
if (refreshRequests.has(key)) {
return refreshRequests.get(key);
}
let accessToken;
try {
const refreshAccessPromise = refreshAccessToken(
accountId,
personalAccessKey,
env
);
if (key) {
refreshRequests.set(key, refreshAccessPromise);
}
accessToken = await refreshAccessPromise;
} catch (e) {
if (key) {
refreshRequests.delete(key);
}
throw e;
}
return accessToken;
}

async function accessTokenForPersonalAccessKey(accountId) {
const { auth, personalAccessKey, env } = getAccountConfig(accountId);
const authTokenInfo = auth && auth.tokenInfo;
const authDataExists = authTokenInfo && auth.tokenInfo.accessToken;

if (
!authDataExists ||
moment().add(5, 'minutes').isAfter(moment(authTokenInfo.expiresAt))
) {
return getNewAccessToken(
accountId,
personalAccessKey,
authTokenInfo && authTokenInfo.expiresAt,
env
);
}

return auth.tokenInfo.accessToken;
}

/**
* Adds a account to the config using authType: personalAccessKey
*
* @param {object} configData Data containing personalAccessKey and name properties
* @param {string} configData.personalAccessKey Personal access key string to place in config
* @param {string} configData.name Unique name to identify this config entry
* @param {boolean} makeDefault option to make the account being added to the config the default account
*/
const updateConfigWithPersonalAccessKey = async (configData, makeDefault) => {
const { personalAccessKey, name, env } = configData;
const accountEnv = env || getEnv(name);

let token;
try {
token = await getAccessToken(personalAccessKey, accountEnv);
} catch (err) {
logErrorInstance(err);
return;
}
const { portalId, accessToken, expiresAt } = token;

const updatedConfig = setConfig({
portalId,
personalAccessKey,
name,
// environment: getValidEnv(accountEnv, true),
authType: PERSONAL_ACCESS_KEY_AUTH_METHOD.value,
tokenInfo: { accessToken, expiresAt },
});

return updatedConfig;
};

export { accessTokenForPersonalAccessKey, updateConfigWithPersonalAccessKey };
33 changes: 33 additions & 0 deletions src/core/auth/unauthenticated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'cross-fetch/polyfill';
import { getRequestOptions } from '../requestOptions';
import { ENVIRONMENTS } from '../constants';

const LOCALDEVAUTH_API_AUTH_PATH = 'localdevauth/v1/auth';

async function fetchAccessToken(
personalAccessKey,
env = ENVIRONMENTS.PROD,
portalId
) {
const params = portalId ? { portalId } : {};

var config = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cross-Origin-Resource-Policy': '*',
},
body: JSON.stringify({
encodedOAuthRefreshToken: personalAccessKey,
}),
};

return fetch(
`https://api.hubapi.com/localdevauth/v1/auth/refresh?${new URLSearchParams(
params
).toString()}`,
config
);
}

export { fetchAccessToken };
33 changes: 33 additions & 0 deletions src/core/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
let _config;

const setConfig = (config) => {
_config = config;
};

const getConfig = () => {
return _config;
};

const getAccountConfig = (accountId) => {
return _config.portals.find((portal) => portal.portalId === accountId);
};

const validateConfig = () => {
if (!_config) {
console.error('Config file is empty');
return false;
}

if (!_config.defaultPortal) {
console.error('This extension requires a "defaultPortal"');
return false;
}

if (!Array.isArray(_config.portals)) {
console.error('This extension requires at least one configured account');
return false;
}
return true;
};

export { getConfig, getAccountConfig, setConfig, validateConfig };
11 changes: 11 additions & 0 deletions src/core/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const PERSONAL_ACCESS_KEY_AUTH_METHOD = {
value: 'personalaccesskey',
name: 'Personal Access Key',
};

const ENVIRONMENTS = {
PROD: 'prod',
QA: 'qa',
};

export { PERSONAL_ACCESS_KEY_AUTH_METHOD, ENVIRONMENTS };
Loading