448 lines
19 KiB
JavaScript
448 lines
19 KiB
JavaScript
'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');
|
|
}
|