UNPKG

@atlassian/atlassian-connect-express

Version:

Library for building Atlassian Add-ons on top of Express

503 lines (461 loc) 15.4 kB
const os = require("os"); const request = require("request"); const URI = require("urijs"); const _ = require("lodash"); const errmsg = require("../errors").errmsg; const validator = require("@atlassian/atlassian-connect-validator"); const util = require("util"); function validateTunnel(addon) { const hasRemoteHosts = _.some(addon.config.hosts(), host => { return !/localhost/.test(host); }); if (!hasRemoteHosts) { return Promise.resolve(); } const providedTunnelUrl = addon.config.localBaseUrl(); const url = new URI(providedTunnelUrl); // Basic validation for remote registration if ( !url.protocol() || /localhost/.test(url.host()) || providedTunnelUrl === `http://${os.hostname()}:${addon.config.port()}` ) { const err = new Error( "localBaseUrl must be a publicly reachable URL. Set AC_LOCAL_BASE_URL or configure localBaseUrl in config.json to your tunnel/public URL." ); addon.logger.error(err.message); return Promise.reject(err); } addon.logger.info(`Using provided public base URL: ${url.toString()}`); addon.reloadDescriptor(); return Promise.resolve(); } exports.shouldRegister = function () { return /force-reg/.test(process.env.AC_OPTS) || this.settings.isMemoryStore(); }; exports.shouldDeregister = function () { return ( /force-dereg/.test(process.env.AC_OPTS) || this.settings.isMemoryStore() || this.config.environment() === "development" ); }; exports.register = function (isReregistration) { const self = this; return new Promise((resolve, reject) => { if (!self.descriptorExists) { self.logger.warn( `Auto-registration disabled since no descriptor file "${this.descriptorFilename}" was found.` ); return resolve(); } if (/no-reg/.test(process.env.AC_OPTS)) { self.logger.warn("Auto-registration disabled with AC_OPTS=no-reg"); return resolve(); } self._registrations = {}; const hostRegUrls = self.config.hosts(); validateTunnel(self).then( () => { if (hostRegUrls && hostRegUrls.length > 0) { if (!isReregistration) { self.logger.info("Registering add-on..."); const handleKillSignal = function (signal) { process.once(signal, () => { console.log(`\nReceived signal ${signal}`); function forwardSignal() { process.kill(process.pid, signal); } self.deregister().then( () => { self.emit("addon_deregistered"); forwardSignal(); }, function () { self.logger.error.apply(self.logger, arguments); forwardSignal(); } ); }); }; handleKillSignal("SIGTERM"); handleKillSignal("SIGINT"); // nodemon sends the SIGUSR2 signal // see https://github.com/remy/nodemon#controlling-shutdown-of-your-script handleKillSignal("SIGUSR2"); } const forceRegistration = self.shouldRegister() || isReregistration; Promise.all( hostRegUrls.map(_.bind(register, self, forceRegistration)) ).then(() => { const count = _.keys(self._registrations).length; if (count === 0) { self.logger.warn( "Add-on not registered; no compatible hosts detected" ); } resolve(); self.emit("addon_registered"); }); } }, err => { reject(err); } ); }); }; exports.deregister = function () { const self = this; const hostRegUrls = _.keys(self._registrations); let promise; if (hostRegUrls.length > 0 && self.shouldDeregister()) { self.logger.info("Deregistering add-on..."); promise = Promise.all(hostRegUrls.map(_.bind(deregister, self))); } else { promise = Promise.resolve(); } return promise; }; exports.validateDescriptor = function () { const self = this; return new Promise((resolve, reject) => { self.logger.info( "Trying to validate the app descriptor. The app will still continue to run even on validation errors and warnings. This is just to inform you of potential mistakes in descriptor" ); const product = self.config.product(); const productName = product.isJIRA ? "jira" : "confluence"; const descriptor = self.descriptor; getGlobalProductSchema(productName).then( schema => { const results = []; validator.validateDescriptor(descriptor, schema, (errors, warnings) => { if (errors) { results.push( getValidationFailCause("error", "Validation errors", errors) ); } if (warnings) { results.push( getValidationFailCause( "warning", "Unexpected attributes: The descriptor is valid, but double-check for typos and elements in the wrong place", warnings ) ); } if (results.length > 0) { self.logger.info( `Error validating app descriptor: [${JSON.stringify( results, null, 2 )}] . Please check and resolve the problem/s. ` + `The app will still continue to run but there might be problems installing this` ); } else { self.logger.info("App descriptor is valid"); } resolve(results); }); }, error => { return reject(error); } ); }); }; let productSchema = {}; function getGlobalProductSchema(productName) { const self = this; return new Promise((resolve, reject) => { if (!_.isEmpty(productSchema)) { return resolve(productSchema); } const docsSchemaUrlFormat = process.env.SCHEMA_HOST || "https://developer.atlassian.com/static/connect/docs/latest/schema/%s-global-schema.json"; const url = util.format(docsSchemaUrlFormat, productName); request.get( { url, json: true }, (error, response, body) => { const statusCode = !!error || !response ? 500 : response.statusCode; if (statusCode < 200 || statusCode > 299) { self.logger.error( `Could not download schema from ${url} : ${error || statusCode}` ); return reject(error); } else { productSchema = body; resolve(productSchema); } } ); }); } function getValidationFailCause(type, message, cause) { const validationResults = []; cause.forEach(result => validationResults.push( _.pick(result, ["module", "value", "validValues", "description"]) ) ); return { type, message, validationResults }; } async function register(forceRegistration, hostRegUrl) { const self = this; await exports.checkIfDevelopmentMode(hostRegUrl); const descriptorUrl = new URI(self.config.localBaseUrl()) .segment(self.descriptorFilename) .toString(); return new Promise(resolve => { function done(maybeResult) { const hostBaseUrl = stripCredentials(hostRegUrl); self.logger.info(`Registered with host at ${hostBaseUrl}`); self._registrations[hostRegUrl] = true; if (maybeResult) { self.logger.info(maybeResult); } resolve(); } function fail(args) { self.logger.warn( registrationError("register", hostRegUrl, args[0], args[1]) ); resolve(); // reject will cause Promise error handler in index.js to blow up // resolve is fine since it will not be adding the client key and not count this as an install } registerUpm( hostRegUrl, descriptorUrl, self.descriptor.key, forceRegistration ).then(done, fail); }); } function registerUpm(hostRegUrl, descriptorUrl, pluginKey, forceRegistration) { const reqObject = getUrlRequestObject(hostRegUrl, "/rest/plugins/1.0/"); reqObject.jar = false; return new Promise((resolve, reject) => { request.get(reqObject, (err, res, body) => { function doReg() { const upmToken = res.headers["upm-token"]; const reqObject = getUrlRequestObject( hostRegUrl, "/rest/plugins/1.0/", { token: upmToken } ); reqObject.headers = { "content-type": "application/vnd.atl.plugins.remote.install+json" }; reqObject.body = JSON.stringify({ pluginUri: descriptorUrl }); reqObject.jar = false; request.post(reqObject, (err, res) => { if (err || (res && res.statusCode !== 202)) { return reject([err, res]); } const body = JSON.parse(res.body); waitForRegistrationResult(hostRegUrl, body).then(resolve, reject); }); } if (err || (res && (res.statusCode < 200 || res.statusCode > 299))) { return reject([err, res]); } if (forceRegistration) { doReg(); } else { body = JSON.parse(body); if (body && body.plugins) { let registered = false; body.plugins.forEach(plugin => { if (plugin.key === pluginKey) { resolve( `Add-on ${pluginKey} is already installed on ${stripCredentials( hostRegUrl )}` ); registered = true; } }); if (!registered) { doReg(); } } } }); }); } function waitForRegistrationResult(hostRegUrl, body) { const startTime = Date.now(); const timeout = 30000; // 30 Second Timeout let reqObject = getUrlRequestObject(hostRegUrl, body.links.self); const callForRegistrationResult = function (lastBody) { const waitTime = lastBody.pingAfter || 200; return new Promise((resolve, reject) => { if (Date.now() - startTime > timeout) { reject(["Add-on installation timed out"]); return; } setTimeout(() => { request.get(reqObject, (err, res) => { if (res && res.statusCode === 303) { // Force the redirect to use the canonical domain const redirectTarget = new URI(res.headers.location).pathname(); reqObject = getUrlRequestObject(hostRegUrl, redirectTarget); return callForRegistrationResult({ pingAfter: 0 }).then( resolve, reject ); } if (err || (res && (res.statusCode < 200 || res.statusCode > 299))) { return reject([err, res]); } const results = JSON.parse(res.body); // UPM installed payload changes on successful install if (results.status && results.status.done) { // if results.status.done is true, then the build has failed as the payload of a // successful install does not contain the status object reject([results.status.errorMessage, res]); } else if (results.key) { // Key will only exist if the install succeeds let returnString = ""; if (!results.enabled) { // If the add-on was disabled before being installed, it will go back to being disabled returnString = `Add-on is disabled on ${stripCredentials( hostRegUrl )}. Enable manually via upm`; } resolve(returnString); } else { // Still waiting on the finished event. Kinda hoping that this doesnt cause infinite looping if the payload changes :/ callForRegistrationResult(results).then(resolve, reject); } }); }, waitTime); }); }; return callForRegistrationResult(body); } function deregister(hostRegUrl) { const self = this; return new Promise(resolve => { function done() { const hostBaseUrl = stripCredentials(hostRegUrl); self.logger.info(`Unregistered on host ${hostBaseUrl}`); delete self._registrations[hostRegUrl]; resolve(); } function fail(args) { self.logger.warn( registrationError("deregister", hostRegUrl, args[0], args[1]) ); resolve(); } if (self._registrations[hostRegUrl]) { deregisterUpm(self, hostRegUrl).then(done, fail); } else { resolve(); } }); } function deregisterUpm(self, hostRegUrl) { return new Promise((resolve, reject) => { const reqObject = getUrlRequestObject( hostRegUrl, `/rest/plugins/1.0/${self.key}-key` ); reqObject.jar = false; request.del(reqObject, (err, res) => { if (err || (res && (res.statusCode < 200 || res.statusCode > 299))) { return reject([err, res]); } resolve(); }); }); } function registrationError(action, hostUrl, err, res) { const hostBaseUrl = stripCredentials(hostUrl); const args = [`Failed to ${action} with host ${hostBaseUrl}`]; if (res && res.statusCode) { args[0] = `${args[0]} (${res.statusCode})`; } if (err) { if (typeof err === "string") { args.push(err); } else { args.push(errmsg(err)); } } if (res && res.body && !/^\s*<[^h]*html[^>]*>/i.test(res.body)) { args.push(res.body); } return args.join("\n"); } function stripCredentials(url) { url = new URI(url); url.username(""); url.password(""); return url.toString(); } function getUrlRequestObject(hostRegUrl, path, queryParams) { const uri = URI(hostRegUrl); const username = uri.username(); const password = uri.password(); uri.username(""); uri.password(""); // Remove any trailing slash from the uri // and any double product context from the path uri.pathname( uri.pathname().replace(/\/$/, "") + path.substring(path.indexOf("/rest/plugins/1.0")) ); if (queryParams) { uri.query(queryParams); } return { uri: uri.toString(), auth: { user: username, pass: password }, followRedirect: false }; } exports.checkIfDevelopmentMode = async hostRegUrl => { return new Promise((resolve, reject) => { const reqObject = getUrlRequestObject( hostRegUrl, "/rest/plugins/1.0/settings" ); request.get(reqObject, (err, res) => { if (err || (res && (res.statusCode < 200 || res.statusCode > 299))) { return reject([err, res]); } const body = JSON.parse(res.body); const connectDeveloperListingsEnabled = body.settings.find( setting => setting.key === "connectDeveloperListingsEnabled" ); if (typeof connectDeveloperListingsEnabled !== "undefined") { if (!connectDeveloperListingsEnabled.value) { throw new Error( ` The site you are attempting to register the add-on to does not have development mode enabled. Please refer to the following documentation to enable development mode: https://developer.atlassian.com/cloud/confluence/getting-started-with-connect/#step-2--enable-development-mode-in-your-site ` ); } } return resolve(); }); }); };