'use strict'; exports = module.exports = { getFeatures, getApps, getApp, getAppVersion, registerWithLoginCredentials, updateCloudron, purchaseApp, unpurchaseApp, getUserToken, getSubscription, isFreePlan, getAppUpdate, getBoxUpdate, createTicket }; var apps = require('./apps.js'), assert = require('assert'), async = require('async'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), debug = require('debug')('box:appstore'), eventlog = require('./eventlog.js'), path = require('path'), paths = require('./paths.js'), safe = require('safetydance'), semver = require('semver'), settings = require('./settings.js'), superagent = require('superagent'), support = require('./support.js'), util = require('util'); // These are the default options and will be adjusted once a subscription state is obtained // Keep in sync with appstore/routes/cloudrons.js let gFeatures = { userMaxCount: 5, userGroups: false, userRoles: false, domainMaxCount: 1, externalLdap: false, privateDockerRegistry: false, branding: false, support: false, directoryConfig: false, mailboxMaxCount: 5, emailPremium: false }; // attempt to load feature cache in case appstore would be down let tmp = safe.JSON.parse(safe.fs.readFileSync(paths.FEATURES_INFO_FILE, 'utf8')); if (tmp) gFeatures = tmp; function getFeatures() { return gFeatures; } function isAppAllowed(appstoreId, listingConfig) { assert.strictEqual(typeof listingConfig, 'object'); assert.strictEqual(typeof appstoreId, 'string'); if (listingConfig.blacklist && listingConfig.blacklist.includes(appstoreId)) return false; if (listingConfig.whitelist) return listingConfig.whitelist.includes(appstoreId); return true; } function getCloudronToken(callback) { assert.strictEqual(typeof callback, 'function'); settings.getCloudronToken(function (error, token) { if (error) return callback(error); if (!token) return callback(new BoxError(BoxError.LICENSE_ERROR, 'Missing token')); callback(null, token); }); } function login(email, password, totpToken, callback) { assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof totpToken, 'string'); assert.strictEqual(typeof callback, 'function'); var data = { email: email, password: password, totpToken: totpToken }; const url = settings.apiServerOrigin() + '/api/v1/login'; superagent.post(url).send(data).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `login status code: ${result.statusCode}`)); callback(null, result.body); // { userId, accessToken } }); } function registerUser(email, password, callback) { assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof callback, 'function'); var data = { email: email, password: password, }; const url = settings.apiServerOrigin() + '/api/v1/register_user'; superagent.post(url).send(data).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 409) return callback(new BoxError(BoxError.ALREADY_EXISTS, error.message)); if (result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `register status code: ${result.statusCode}`)); callback(null); }); } function getUserToken(callback) { assert.strictEqual(typeof callback, 'function'); if (settings.isDemo()) return callback(new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode')); getCloudronToken(function (error, token) { if (error) return callback(error); const url = `${settings.apiServerOrigin()}/api/v1/user_token`; superagent.post(url).send({}).query({ accessToken: token }).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `getUserToken status code: ${result.status}`)); callback(null, result.body.accessToken); }); }); } function getSubscription(callback) { assert.strictEqual(typeof callback, 'function'); getCloudronToken(function (error, token) { if (error) return callback(error); const url = settings.apiServerOrigin() + '/api/v1/subscription'; superagent.get(url).query({ accessToken: token }).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR)); if (result.statusCode === 502) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Stripe error: ${error.message}`)); if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${error.message}`)); // update the features cache gFeatures = result.body.features; safe.fs.writeFileSync(paths.FEATURES_INFO_FILE, JSON.stringify(gFeatures), 'utf8'); callback(null, result.body); }); }); } function isFreePlan(subscription) { return !subscription || subscription.plan.id === 'free'; } // See app.js install it will create a db record first but remove it again if appstore purchase fails function purchaseApp(data, callback) { assert.strictEqual(typeof data, 'object'); // { appstoreId, manifestId, appId } assert(data.appstoreId || data.manifestId); assert.strictEqual(typeof data.appId, 'string'); assert.strictEqual(typeof callback, 'function'); getCloudronToken(function (error, token) { if (error) return callback(error); const url = `${settings.apiServerOrigin()}/api/v1/cloudronapps`; superagent.post(url).send(data).query({ accessToken: token }).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 404) return callback(new BoxError(BoxError.NOT_FOUND)); // appstoreId does not exist if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 402) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); // 200 if already purchased, 201 is newly purchased if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', result.status, result.body))); callback(null); }); }); } function unpurchaseApp(appId, data, callback) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof data, 'object'); // { appstoreId, manifestId } assert(data.appstoreId || data.manifestId); assert.strictEqual(typeof callback, 'function'); getCloudronToken(function (error, token) { if (error) return callback(error); const url = `${settings.apiServerOrigin()}/api/v1/cloudronapps/${appId}`; superagent.get(url).query({ accessToken: token }).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 404) return callback(null); // was never purchased if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode !== 201 && result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', result.status, result.body))); superagent.del(url).send(data).query({ accessToken: token }).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error)); if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode !== 204) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', result.status, result.body))); callback(null); }); }); }); } function getBoxUpdate(options, callback) { assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); getCloudronToken(function (error, token) { if (error) return callback(error); const url = `${settings.apiServerOrigin()}/api/v1/boxupdate`; const query = { accessToken: token, boxVersion: constants.VERSION, automatic: options.automatic }; superagent.get(url).query(query).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode === 204) return callback(null, null); // no update if (result.statusCode !== 200 || !result.body) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', result.statusCode, result.text))); var updateInfo = result.body; if (!semver.valid(updateInfo.version) || semver.gt(constants.VERSION, updateInfo.version)) { return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Update version invalid or is a downgrade: %s %s', result.statusCode, result.text))); } // updateInfo: { version, changelog, sourceTarballUrl, sourceTarballSigUrl, boxVersionsUrl, boxVersionsSigUrl } if (!updateInfo.version || typeof updateInfo.version !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad version): %s %s', result.statusCode, result.text))); if (!updateInfo.changelog || !Array.isArray(updateInfo.changelog)) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad version): %s %s', result.statusCode, result.text))); if (!updateInfo.sourceTarballUrl || typeof updateInfo.sourceTarballUrl !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad sourceTarballUrl): %s %s', result.statusCode, result.text))); if (!updateInfo.sourceTarballSigUrl || typeof updateInfo.sourceTarballSigUrl !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad sourceTarballSigUrl): %s %s', result.statusCode, result.text))); if (!updateInfo.boxVersionsUrl || typeof updateInfo.boxVersionsUrl !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad boxVersionsUrl): %s %s', result.statusCode, result.text))); if (!updateInfo.boxVersionsSigUrl || typeof updateInfo.boxVersionsSigUrl !== 'string') return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad boxVersionsSigUrl): %s %s', result.statusCode, result.text))); callback(null, updateInfo); }); }); } function getAppUpdate(app, options, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); getCloudronToken(function (error, token) { if (error) return callback(error); const url = `${settings.apiServerOrigin()}/api/v1/appupdate`; const query = { accessToken: token, boxVersion: constants.VERSION, appId: app.appStoreId, appVersion: app.manifest.version, automatic: options.automatic }; superagent.get(url).query(query).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error)); if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode === 204) return callback(null); // no update if (result.statusCode !== 200 || !result.body) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', result.statusCode, result.text))); const updateInfo = result.body; // for the appstore, x.y.z is the same as x.y.z-0 but in semver, x.y.z > x.y.z-0 const curAppVersion = semver.prerelease(app.manifest.version) ? app.manifest.version : `${app.manifest.version}-0`; // do some sanity checks if (!safe.query(updateInfo, 'manifest.version') || semver.gt(curAppVersion, safe.query(updateInfo, 'manifest.version'))) { debug('Skipping malformed update of app %s version: %s. got %j', app.id, curAppVersion, updateInfo); return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Malformed update: %s %s', result.statusCode, result.text))); } updateInfo.unstable = !!updateInfo.unstable; // { id, creationDate, manifest, unstable } callback(null, updateInfo); }); }); } function registerCloudron(data, callback) { assert.strictEqual(typeof data, 'object'); assert.strictEqual(typeof callback, 'function'); const url = `${settings.apiServerOrigin()}/api/v1/register_cloudron`; superagent.post(url).send(data).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${result.statusCode} ${error.message}`)); // cloudronId, token, licenseKey if (!result.body.cloudronId) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no cloudron id')); if (!result.body.cloudronToken) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no token')); if (!result.body.licenseKey) return callback(new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no license')); async.series([ settings.setCloudronId.bind(null, result.body.cloudronId), settings.setCloudronToken.bind(null, result.body.cloudronToken), settings.setLicenseKey.bind(null, result.body.licenseKey), ], function (error) { if (error) return callback(error); debug(`registerCloudron: Cloudron registered with id ${result.body.cloudronId}`); callback(); }); }); } function updateCloudron(data, callback) { assert.strictEqual(typeof data, 'object'); assert.strictEqual(typeof callback, 'function'); getCloudronToken(function (error, token) { if (error && error.reason === BoxError.LICENSE_ERROR) return callback(null); // missing token. not registered yet if (error) return callback(error); const url = `${settings.apiServerOrigin()}/api/v1/update_cloudron`; const query = { accessToken: token }; superagent.post(url).query(query).send(data).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error)); if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', result.statusCode, result.text))); debug(`updateCloudron: Cloudron updated with data ${JSON.stringify(data)}`); callback(); }); }); } function registerWithLoginCredentials(options, callback) { assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); function maybeSignup(done) { if (!options.signup) return done(); registerUser(options.email, options.password, done); } getCloudronToken(function (error, token) { if (token) return callback(new BoxError(BoxError.CONFLICT, 'Cloudron is already registered')); maybeSignup(function (error) { if (error) return callback(error); login(options.email, options.password, options.totpToken || '', function (error, result) { if (error) return callback(error); registerCloudron({ domain: settings.dashboardDomain(), accessToken: result.accessToken, version: constants.VERSION }, callback); }); }); }); } function createTicket(info, auditSource, callback) { assert.strictEqual(typeof info, 'object'); assert.strictEqual(typeof info.email, 'string'); assert.strictEqual(typeof info.displayName, 'string'); assert.strictEqual(typeof info.type, 'string'); assert.strictEqual(typeof info.subject, 'string'); assert.strictEqual(typeof info.description, 'string'); assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof callback, 'function'); function collectAppInfoIfNeeded(callback) { if (!info.appId) return callback(); apps.get(info.appId, callback); } function enableSshIfNeeded(callback) { if (!info.enableSshSupport) return callback(); support.enableRemoteSupport(true, auditSource, function (error) { // ensure we can at least get the ticket through if (error) debug('Unable to enable SSH support.', error); callback(); }); } getCloudronToken(function (error, token) { if (error) return callback(error); enableSshIfNeeded(function (error) { if (error) return callback(error); collectAppInfoIfNeeded(function (error, app) { if (error) return callback(error); if (app) info.app = app; info.supportEmail = constants.SUPPORT_EMAIL; // destination address for tickets var req = superagent.post(`${settings.apiServerOrigin()}/api/v1/ticket`) .query({ accessToken: token }) .timeout(30 * 1000); // either send as JSON through body or as multipart, depending on attachments if (info.app) { req.field('infoJSON', JSON.stringify(info)); apps.getLocalLogfilePaths(info.app).forEach(function (filePath) { var logs = safe.child_process.execSync(`tail --lines=1000 ${filePath}`); if (logs) req.attach(path.basename(filePath), logs, path.basename(filePath)); }); } else { req.send(info); } req.end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode !== 201) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', result.statusCode, result.text))); eventlog.add(eventlog.ACTION_SUPPORT_TICKET, auditSource, info); callback(null, { message: `An email was sent to ${constants.SUPPORT_EMAIL}. We will get back shortly!` }); }); }); }); }); } function getApps(callback) { assert.strictEqual(typeof callback, 'function'); getCloudronToken(async function (error, token) { if (error) return callback(error); const [settingsError, unstable] = await settings.getUnstableAppsConfig(); if (settingsError) return callback(settingsError); const url = `${settings.apiServerOrigin()}/api/v1/apps`; superagent.get(url).query({ accessToken: token, boxVersion: constants.VERSION, unstable: unstable }).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 403 || result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('App listing failed. %s %j', result.status, result.body))); if (!result.body.apps) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', result.statusCode, result.text))); settings.getAppstoreListingConfig(function (error, listingConfig) { if (error) return callback(error); const filteredApps = result.body.apps.filter(app => isAppAllowed(app.id, listingConfig)); callback(null, filteredApps); }); }); }); } function getAppVersion(appId, version, callback) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof version, 'string'); assert.strictEqual(typeof callback, 'function'); settings.getAppstoreListingConfig(function (error, listingConfig) { if (error) return callback(error); if (!isAppAllowed(appId, listingConfig)) return callback(new BoxError(BoxError.FEATURE_DISABLED)); getCloudronToken(function (error, token) { if (error) return callback(error); let url = `${settings.apiServerOrigin()}/api/v1/apps/${appId}`; if (version !== 'latest') url += `/versions/${version}`; superagent.get(url).query({ accessToken: token }).timeout(30 * 1000).end(function (error, result) { if (error && !error.response) return callback(new BoxError(BoxError.NETWORK_ERROR, error.message)); if (result.statusCode === 403 || result.statusCode === 401) return callback(new BoxError(BoxError.INVALID_CREDENTIALS)); if (result.statusCode === 404) return callback(new BoxError(BoxError.NOT_FOUND)); if (result.statusCode === 422) return callback(new BoxError(BoxError.LICENSE_ERROR, result.body.message)); if (result.statusCode !== 200) return callback(new BoxError(BoxError.EXTERNAL_ERROR, util.format('App fetch failed. %s %j', result.status, result.body))); callback(null, result.body); }); }); }); } function getApp(appId, callback) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof callback, 'function'); getAppVersion(appId, 'latest', callback); }