'use strict'; exports = module.exports = { getFeatures, getApiServerOrigin, getWebServerOrigin, getConsoleServerOrigin, downloadManifest, getApps, getApp, getAppVersion, downloadIcon, registerCloudronWithSetupToken, registerCloudronWithLogin, updateCloudron, purchaseApp, unpurchaseApp, getSubscription, isFreePlan, getAppUpdate, getBoxUpdate, createTicket, // exported for tests _setApiServerOrigin: setApiServerOrigin, _unregister: unregister }; const apps = require('./apps.js'), assert = require('assert'), BoxError = require('./boxerror.js'), constants = require('./constants.js'), dashboard = require('./dashboard.js'), debug = require('debug')('box:appstore'), eventlog = require('./eventlog.js'), network = require('./network.js'), path = require('path'), paths = require('./paths.js'), promiseRetry = require('./promise-retry.js'), safe = require('safetydance'), semver = require('semver'), settings = require('./settings.js'), shell = require('./shell.js'), superagent = require('superagent'), support = require('./support.js'); // 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; } async function getApiServerOrigin() { return await settings.get(settings.API_SERVER_ORIGIN_KEY) || 'https://api.cloudron.io'; } async function setApiServerOrigin(origin) { assert.strictEqual(typeof origin, 'string'); await settings.set(settings.API_SERVER_ORIGIN_KEY, origin); } async function getWebServerOrigin() { return await settings.get(settings.WEB_SERVER_ORIGIN_KEY) || 'https://cloudron.io'; } async function getConsoleServerOrigin() { return await settings.get(settings.CONSOLE_SERVER_ORIGIN_KEY) || 'https://console.cloudron.io'; } 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(`${await getApiServerOrigin()}/api/v1/login`) .send({ email, password, totpToken }) .timeout(60 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS, response.body.message); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Login error. status code: ${response.status}`); if (!response.body.accessToken) throw new BoxError(BoxError.EXTERNAL_ERROR, `Login error. 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(`${await getApiServerOrigin()}/api/v1/register_user`) .send({ email, password, utmSource: 'cloudron-dashboard' }) .timeout(60 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 409) throw new BoxError(BoxError.ALREADY_EXISTS, 'Registration error: account already exists'); if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Registration error. invalid response: ${response.status}`); } async function getSubscription() { const token = await settings.get(settings.APPSTORE_API_TOKEN_KEY); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const [error, response] = await safe(superagent.get(`${await getApiServerOrigin()}/api/v1/subscription`) .query({ accessToken: token }) .timeout(60 * 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 === 502) throw new BoxError(BoxError.EXTERNAL_ERROR, `Stripe error: ${response.status} ${JSON.stringify(response.body)}`); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unknown error: ${response.status} ${JSON.stringify(response.body)}`); // 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.get(settings.APPSTORE_API_TOKEN_KEY); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const [error, response] = await safe(superagent.post(`${await getApiServerOrigin()}/api/v1/cloudronapps`) .send(data) .query({ accessToken: token }) .timeout(60 * 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, `App purchase failed. ${response.status} ${JSON.stringify(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.get(settings.APPSTORE_API_TOKEN_KEY); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const url = `${await getApiServerOrigin()}/api/v1/cloudronapps/${appId}`; let [error, response] = await safe(superagent.get(url) .query({ accessToken: token }) .timeout(60 * 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 !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App unpurchase failed to get app. status:${response.status}`); [error, response] = await safe(superagent.del(url) .send(data) .query({ accessToken: token }) .timeout(60 * 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, `App unpurchase failed. status:${response.status}`); } async function getBoxUpdate(options) { assert.strictEqual(typeof options, 'object'); const token = await settings.get(settings.APPSTORE_API_TOKEN_KEY); 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(`${await getApiServerOrigin()}/api/v1/boxupdate`) .query(query) .timeout(60 * 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) return; // no update if (response.status !== 200 || !response.body) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${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, `Update version invalid or is a downgrade: ${response.status} ${response.text}`); } // updateInfo: { version, changelog, sourceTarballUrl, sourceTarballSigUrl, boxVersionsUrl, boxVersionsSigUrl } if (!updateInfo.version || typeof updateInfo.version !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response (bad version): ${response.status} ${response.text}`); if (!updateInfo.changelog || !Array.isArray(updateInfo.changelog)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response (bad changelog): ${response.status} ${response.text}`); if (!updateInfo.sourceTarballUrl || typeof updateInfo.sourceTarballUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response (bad sourceTarballUrl): ${response.status} ${response.text}`); if (!updateInfo.sourceTarballSigUrl || typeof updateInfo.sourceTarballSigUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response (bad sourceTarballSigUrl): ${response.status} ${response.text}`); if (!updateInfo.boxVersionsUrl || typeof updateInfo.boxVersionsUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response (bad boxVersionsUrl): ${response.status} ${response.text}`); if (!updateInfo.boxVersionsSigUrl || typeof updateInfo.boxVersionsSigUrl !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response (bad boxVersionsSigUrl): ${response.status} ${response.text}`); if (typeof updateInfo.unstable !== 'boolean') throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response (bad unstable): ${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.get(settings.APPSTORE_API_TOKEN_KEY); 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(`${await getApiServerOrigin()}/api/v1/appupdate`) .query(query) .timeout(60 * 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 === 204) return; // no update if (response.status !== 200 || !response.body) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${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, `Malformed update: ${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, setupToken, accessToken, version, existingApps } = data; const [error, response] = await safe(superagent.post(`${await getApiServerOrigin()}/api/v1/register_cloudron`) .send({ domain, setupToken, accessToken, version, existingApps }) .timeout(60 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message); if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, 'Setup token invalid'); if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.statusCode} ${error.message}`); 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.set(settings.CLOUDRON_ID_KEY, response.body.cloudronId); await settings.set(settings.APPSTORE_API_TOKEN_KEY, response.body.cloudronToken); debug(`registerCloudron: Cloudron registered with id ${response.body.cloudronId}`); // app could already have been installed if we deleted the cloudron.io record and user re-registers for (const app of await apps.list()) { await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' }); } } async function updateCloudron(data) { assert.strictEqual(typeof data, 'object'); const { domain } = data; const token = await settings.get(settings.APPSTORE_API_TOKEN_KEY); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const query = { accessToken: token }; const [error, response] = await safe(superagent.post(`${await getApiServerOrigin()}/api/v1/update_cloudron`) .query(query) .send({ domain }) .timeout(60 * 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 !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`); debug(`updateCloudron: Cloudron updated with data ${JSON.stringify(data)}`); } async function registerCloudronWithSetupToken(options) { assert.strictEqual(typeof options, 'object'); const { domain } = await dashboard.getLocation(); await registerCloudron({ domain, setupToken: options.setupToken, version: constants.VERSION }); } async function registerCloudronWithLogin(options) { assert.strictEqual(typeof options, 'object'); if (options.signup) await registerUser(options.email, options.password); const result = await login(options.email, options.password, options.totpToken || ''); const { domain } = await dashboard.getLocation(); await registerCloudron({ domain, accessToken: result.accessToken, version: constants.VERSION }); } async function unregister() { await settings.set(settings.CLOUDRON_ID_KEY, ''); await settings.set(settings.APPSTORE_API_TOKEN_KEY, ''); } 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.get(settings.APPSTORE_API_TOKEN_KEY); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); if (info.enableSshSupport) { await safe(support.enableRemoteSupport(true, auditSource)); info.ipv4 = await network.getIPv4(); } info.app = info.appId ? await apps.get(info.appId) : null; info.supportEmail = constants.SUPPORT_EMAIL; // destination address for tickets const request = superagent.post(`${await getApiServerOrigin()}/api/v1/ticket`) .query({ accessToken: token }) .timeout(60 * 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 [error, logs] = await safe(shell.exec('createTicket', `tail --lines=1000 ${logPath}`, {})); if (!error && 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 !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${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 downloadManifest(appStoreId, manifest) { if (!appStoreId && !manifest) throw new BoxError(BoxError.BAD_FIELD, 'Neither manifest nor appStoreId provided'); if (!appStoreId) return { appStoreId: '', manifest }; const parts = appStoreId.split('@'); const url = await getApiServerOrigin() + '/api/v1/apps/' + parts[0] + (parts[1] ? '/versions/' + parts[1] : ''); debug(`downloading manifest from ${url}`); const [error, response] = await safe(superagent.get(url).timeout(60 * 1000).ok(() => true)); if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Network error downloading manifest:' + error.message); if (response.status !== 200) throw new BoxError(BoxError.NOT_FOUND, `Failed to get app info from store. status: ${response.status} text: ${response.text}`); if (!response.body.manifest || typeof response.body.manifest !== 'object') throw new BoxError(BoxError.NOT_FOUND, `Missing manifest. Failed to get app info from store. status: ${response.status} text: ${response.text}`); return { appStoreId: parts[0], manifest: response.body.manifest }; } async function getApps() { const token = await settings.get(settings.APPSTORE_API_TOKEN_KEY); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); const [error, response] = await safe(superagent.get(`${await getApiServerOrigin()}/api/v1/apps`) .query({ accessToken: token, boxVersion: constants.VERSION, unstable: true }) .timeout(60 * 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 !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App listing failed. ${response.status} ${JSON.stringify(response.body)}`); if (!response.body.apps) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`); return response.body.apps; } async function getAppVersion(appId, version) { assert.strictEqual(typeof appId, 'string'); assert.strictEqual(typeof version, 'string'); const token = await settings.get(settings.APPSTORE_API_TOKEN_KEY); if (!token) throw new BoxError(BoxError.LICENSE_ERROR, 'Missing token'); let url = `${await getApiServerOrigin()}/api/v1/apps/${appId}`; if (version !== 'latest') url += `/versions/${version}`; const [error, response] = await safe(superagent.get(url) .query({ accessToken: token }) .timeout(60 * 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 !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `App fetch failed. ${response.status} ${JSON.stringify(response.body)}`); return response.body; } async function getApp(appId) { assert.strictEqual(typeof appId, 'string'); return await getAppVersion(appId, 'latest'); } async function downloadIcon(appStoreId, version) { const iconUrl = `${await getApiServerOrigin()}/api/v1/apps/${appStoreId}/versions/${version}/icon`; return await promiseRetry({ times: 10, interval: 5000, debug }, async function () { const [networkError, response] = await safe(superagent.get(iconUrl) .buffer(true) .timeout(60 * 1000) .ok(() => true)); if (networkError) throw new BoxError(BoxError.NETWORK_ERROR, `Network error downloading icon : ${networkError.message}`); if (response.status !== 200) return; // ignore error. this can also happen for apps installed with cloudron-cli return response.body; }); }