'use strict'; exports = module.exports = { getFeatures, getApps, getApp, getAppVersion, registerWithLoginCredentials, updateCloudron, purchaseApp, unpurchaseApp, getWebToken, getSubscription, isFreePlan, getAppUpdate, getBoxUpdate, createTicket }; const apps = require('./apps.js'), assert = require('assert'), 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'), sysinfo = require('./sysinfo.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, profileConfig: 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; } async function login(email, password, totpToken) { assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof password, 'string'); assert.strictEqual(typeof totpToken, 'string'); const [error, response] = await safe(superagent.post(`${settings.apiServerOrigin()}/api/v1/login`) .send({ email, password, totpToken }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `login status code: ${response.status}`); if (!response.body.accessToken) throw new BoxError(BoxError.EXTERNAL_ERROR, `login invalid response: ${response.text}`); return response.body; // { userId, accessToken } } async function registerUser(email, password) { assert.strictEqual(typeof email, 'string'); assert.strictEqual(typeof password, 'string'); const [error, response] = await safe(superagent.post(`${settings.apiServerOrigin()}/api/v1/register_user`) .send({ email, password }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 409) throw new BoxError(BoxError.ALREADY_EXISTS, 'account already exists'); if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `register status code: ${response.status}`); } async function getWebToken() { if (settings.isDemo()) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode'); const token = await settings.getAppstoreWebToken(); if (!token) throw new BoxError(BoxError.NOT_FOUND); // user will have to re-login with password somehow return token; } async function getSubscription() { const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const [error, response] = await safe(superagent.get(`${settings.apiServerOrigin()}/api/v1/subscription`) .query({ accessToken: token }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR); if (response.status === 502) throw new BoxError(BoxError.EXTERNAL_ERROR, `Stripe error: ${error.message}`); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${error.message}`); // update the features cache gFeatures = response.body.features; safe.fs.writeFileSync(paths.FEATURES_INFO_FILE, JSON.stringify(gFeatures), 'utf8'); return response.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 async function purchaseApp(data) { assert.strictEqual(typeof data, 'object'); // { appstoreId, manifestId, appId } assert(data.appstoreId || data.manifestId); assert.strictEqual(typeof data.appId, 'string'); const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const [error, response] = await safe(superagent.post(`${settings.apiServerOrigin()}/api/v1/cloudronapps`) .send(data) .query({ accessToken: token }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND); // appstoreId does not exist if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); // 200 if already purchased, 201 is newly purchased if (response.status !== 201 && response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('App purchase failed. %s %j', response.status, response.body)); } async function unpurchaseApp(appId, data) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof data, 'object'); // { appstoreId, manifestId } assert(data.appstoreId || data.manifestId); const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const url = `${settings.apiServerOrigin()}/api/v1/cloudronapps/${appId}`; let [error, response] = await safe(superagent.get(url) .query({ accessToken: token }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 404) return; // was never purchased if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); if (response.status !== 201 && response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', response.status, response.body)); [error, response] = await safe(superagent.del(url) .send(data) .query({ accessToken: token }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status !== 204) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('App unpurchase failed. %s %j', response.status, response.body)); } async function getBoxUpdate(options) { assert.strictEqual(typeof options, 'object'); const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const query = { accessToken: token, boxVersion: constants.VERSION, automatic: options.automatic }; const [error, response] = await safe(superagent.get(`${settings.apiServerOrigin()}/api/v1/boxupdate`) .query(query) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); if (response.status === 204) return; // no update if (response.status !== 200 || !response.body) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', response.status, response.text)); const updateInfo = response.body; if (!semver.valid(updateInfo.version) || semver.gt(constants.VERSION, updateInfo.version)) { throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Update version invalid or is a downgrade: %s %s', response.status, response.text)); } // updateInfo: { version, changelog, sourceTarballUrl, sourceTarballSigUrl, boxVersionsUrl, boxVersionsSigUrl } if (!updateInfo.version || typeof updateInfo.version !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad version): %s %s', response.status, response.text)); if (!updateInfo.changelog || !Array.isArray(updateInfo.changelog)) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad version): %s %s', response.status, response.text)); if (!updateInfo.sourceTarballUrl || typeof updateInfo.sourceTarballUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad sourceTarballUrl): %s %s', response.status, response.text)); if (!updateInfo.sourceTarballSigUrl || typeof updateInfo.sourceTarballSigUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad sourceTarballSigUrl): %s %s', response.status, response.text)); if (!updateInfo.boxVersionsUrl || typeof updateInfo.boxVersionsUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad boxVersionsUrl): %s %s', response.status, response.text)); if (!updateInfo.boxVersionsSigUrl || typeof updateInfo.boxVersionsSigUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response (bad boxVersionsSigUrl): %s %s', response.status, response.text)); return updateInfo; } async function getAppUpdate(app, options) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const query = { accessToken: token, boxVersion: constants.VERSION, appId: app.appStoreId, appVersion: app.manifest.version, automatic: options.automatic }; const [error, response] = await safe(superagent.get(`${settings.apiServerOrigin()}/api/v1/appupdate`) .query(query) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); if (response.status === 204) return; // no update if (response.status !== 200 || !response.body) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', response.status, response.text)); const updateInfo = response.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); throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Malformed update: %s %s', response.status, response.text)); } updateInfo.unstable = !!updateInfo.unstable; // { id, creationDate, manifest, unstable } return updateInfo; } async function registerCloudron(data) { assert.strictEqual(typeof data, 'object'); const { domain, accessToken, version } = data; const [error, response] = await safe(superagent.post(`${settings.apiServerOrigin()}/api/v1/register_cloudron`) .send({ domain, accessToken, version }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.statusCode} ${error.message}`); // cloudronId, token if (!response.body.cloudronId) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no cloudron id'); if (!response.body.cloudronToken) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no token'); await settings.setCloudronId(response.body.cloudronId); await settings.setAppstoreApiToken(response.body.cloudronToken); await settings.setAppstoreWebToken(accessToken); debug(`registerCloudron: Cloudron registered with id ${response.body.cloudronId}`); } async function updateCloudron(data) { assert.strictEqual(typeof data, 'object'); const { domain } = data; const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const query = { accessToken: token }; const [error, response] = await safe(superagent.post(`${settings.apiServerOrigin()}/api/v1/update_cloudron`) .query(query) .send({ domain }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', response.status, response.text)); debug(`updateCloudron: Cloudron updated with data ${JSON.stringify(data)}`); } async function registerWithLoginCredentials(options) { assert.strictEqual(typeof options, 'object'); const token = await settings.getAppstoreApiToken(); if (token) throw new BoxError(BoxError.CONFLICT, 'Cloudron is already registered'); if (options.signup) await registerUser(options.email, options.password); const result = await login(options.email, options.password, options.totpToken || ''); await registerCloudron({ domain: settings.dashboardDomain(), accessToken: result.accessToken, version: constants.VERSION }); } async function createTicket(info, auditSource) { 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'); const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); if (info.enableSshSupport) { await safe(support.enableRemoteSupport(true, auditSource)); info.ipv4 = await sysinfo.getServerIPv4(); } info.app = info.appId ? await apps.get(info.appId) : null; info.supportEmail = constants.SUPPORT_EMAIL; // destination address for tickets const request = superagent.post(`${settings.apiServerOrigin()}/api/v1/ticket`) .query({ accessToken: token }) .timeout(30 * 1000) .ok(() => true); // either send as JSON through body or as multipart, depending on attachments if (info.app) { request.field('infoJSON', JSON.stringify(info)); const logPaths = await apps.getLogPaths(info.app); for (const logPath of logPaths) { const logs = safe.child_process.execSync(`tail --lines=1000 ${logPath}`); if (logs) request.attach(path.basename(logPath), logs, path.basename(logPath)); } } else { request.send(info); } const [error, response] = await safe(request); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', response.status, response.text)); await eventlog.add(eventlog.ACTION_SUPPORT_TICKET, auditSource, info); return { message: `An email was sent to ${constants.SUPPORT_EMAIL}. We will get back shortly!` }; } async function getApps() { const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const unstable = await settings.getUnstableAppsConfig(); const [error, response] = await safe(superagent.get(`${settings.apiServerOrigin()}/api/v1/apps`) .query({ accessToken: token, boxVersion: constants.VERSION, unstable: unstable }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('App listing failed. %s %j', response.status, response.body)); if (!response.body.apps) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('Bad response: %s %s', response.status, response.text)); const listingConfig = await settings.getAppstoreListingConfig(); const filteredApps = response.body.apps.filter(app => isAppAllowed(app.id, listingConfig)); return filteredApps; } async function getAppVersion(appId, version) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof version, 'string'); const listingConfig = await settings.getAppstoreListingConfig(); if (!isAppAllowed(appId, listingConfig)) throw new BoxError(BoxError.FEATURE_DISABLED); const token = await settings.getAppstoreApiToken(); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); let url = `${settings.apiServerOrigin()}/api/v1/apps/${appId}`; if (version !== 'latest') url += `/versions/${version}`; const [error, response] = await safe(superagent.get(url) .query({ accessToken: token }) .timeout(30 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 403 || response.statusCode === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS); if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND); if (response.status === 402) throw new BoxError(BoxError.LICENSE_ERROR, response.body.message); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, util.format('App fetch failed. %s %j', response.status, response.body)); return response.body; } async function getApp(appId) { assert.strictEqual(typeof appId, 'string'); return await getAppVersion(appId, 'latest'); }