Files
cloudron-box/src/appstore.js
Girish Ramakrishnan 3787f90283 appstore: bump timeout to 60s instead of 30s
this timeout is hit on some servers (which have some networking
issue). unfortunately, this triggers a bug in superagent -
https://github.com/ladjs/superagent/issues/1801
2024-04-23 11:41:51 +02:00

496 lines
21 KiB
JavaScript

'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;
});
}