353 lines
15 KiB
JavaScript
353 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
getFeatures,
|
|
|
|
getApiServerOrigin,
|
|
getWebServerOrigin,
|
|
getConsoleServerOrigin,
|
|
|
|
downloadManifest,
|
|
getApps,
|
|
getApp,
|
|
getAppVersion,
|
|
downloadIcon,
|
|
|
|
registerCloudron3,
|
|
updateCloudron,
|
|
|
|
getSubscription,
|
|
isFreePlan,
|
|
|
|
getAppUpdate,
|
|
getBoxUpdate,
|
|
|
|
// exported for tests
|
|
_setApiServerOrigin: setApiServerOrigin,
|
|
_unregister: unregister
|
|
};
|
|
|
|
const assert = require('assert'),
|
|
BoxError = require('./boxerror.js'),
|
|
constants = require('./constants.js'),
|
|
debug = require('debug')('box:appstore'),
|
|
manifestFormat = require('cloudron-manifestformat'),
|
|
paths = require('./paths.js'),
|
|
promiseRetry = require('./promise-retry.js'),
|
|
safe = require('safetydance'),
|
|
semver = require('semver'),
|
|
settings = require('./settings.js'),
|
|
superagent = require('./superagent.js');
|
|
|
|
// These are the default options and will be adjusted once a subscription state is obtained
|
|
// Keep in sync with appstore/routes/cloudrons.js
|
|
const DEFAULT_FEATURES = {
|
|
appMaxCount: 2,
|
|
userMaxCount: 5,
|
|
domainMaxCount: 1,
|
|
mailboxMaxCount: 5,
|
|
branding: false,
|
|
externalLdap: false,
|
|
privateDockerRegistry: false,
|
|
userGroups: false,
|
|
emailServer: false,
|
|
profileConfig: false,
|
|
|
|
// TODO how to go about that in the UI?
|
|
backupSchedules: false,
|
|
userRoles: false,
|
|
appProxy: false,
|
|
eventlogRetention: false,
|
|
hsts: false,
|
|
|
|
// TODO remove usage of old features below
|
|
support: false,
|
|
emailPremium: false,
|
|
};
|
|
let gFeatures = null;
|
|
|
|
function getFeatures() {
|
|
if (gFeatures === null) {
|
|
gFeatures = Object.assign({}, DEFAULT_FEATURES);
|
|
|
|
const tmp = safe.JSON.parse(safe.fs.readFileSync(paths.FEATURES_INFO_FILE, 'utf8'));
|
|
if (!tmp) {
|
|
return DEFAULT_FEATURES;
|
|
}
|
|
|
|
for (const f in DEFAULT_FEATURES) {
|
|
if (f in tmp) gFeatures[f] = tmp[f];
|
|
if (tmp[f] === null) gFeatures[f] = 100000; // null essentially means unlimited
|
|
}
|
|
}
|
|
|
|
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 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);
|
|
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid appstore token');
|
|
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
|
|
getFeatures();
|
|
for (const f in gFeatures) {
|
|
if (typeof response.body.features[f] !== 'undefined') gFeatures[f] = response.body.features[f];
|
|
if (response.body.features[f] === null) gFeatures[f] = 100000; // null essentially means unlimited
|
|
}
|
|
safe.fs.writeFileSync(paths.FEATURES_INFO_FILE, JSON.stringify(gFeatures), 'utf8');
|
|
|
|
return response.body;
|
|
}
|
|
|
|
function isFreePlan(subscription) {
|
|
return !subscription || subscription.plan.id === 'free';
|
|
}
|
|
|
|
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);
|
|
if (response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid appstore token');
|
|
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, 'Invalid appstore token');
|
|
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 registerCloudron3(domain, version) {
|
|
assert.strictEqual(typeof domain, 'string');
|
|
assert.strictEqual(typeof version, 'string');
|
|
|
|
const [error, response] = await safe(superagent.post(`${await getApiServerOrigin()}/api/v1/register_cloudron3`)
|
|
.send({ domain, version })
|
|
.timeout(60 * 1000)
|
|
.ok(() => true));
|
|
|
|
if (error) throw new BoxError(BoxError.NETWORK_ERROR, `Network error reaching appstore: ${error.message}`);
|
|
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.status} ${response.text}`);
|
|
|
|
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(`registerCloudron3: Cloudron registered with id ${response.body.cloudronId}`);
|
|
}
|
|
|
|
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, 'Invalid appstore token');
|
|
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 unregister() {
|
|
await settings.set(settings.CLOUDRON_ID_KEY, '');
|
|
await settings.set(settings.APPSTORE_API_TOKEN_KEY, '');
|
|
}
|
|
|
|
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 [id, version] = appStoreId.split('@');
|
|
if (!manifestFormat.isId(id)) throw new BoxError(BoxError.BAD_FIELD, 'appStoreId is not valid');
|
|
if (version && !semver.valid(version)) throw new BoxError(BoxError.BAD_FIELD, 'package version is not valid semver');
|
|
|
|
const url = await getApiServerOrigin() + '/api/v1/apps/' + id + (version ? '/versions/' + version : '');
|
|
|
|
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: id, 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);
|
|
if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid appstore token');
|
|
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);
|
|
if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.INVALID_CREDENTIALS, 'Invalid appstore token');
|
|
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND, `Could not find app ${appId}`);
|
|
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)
|
|
.timeout(60 * 1000)
|
|
.ok(() => true));
|
|
|
|
if (networkError) throw new BoxError(BoxError.NETWORK_ERROR, Object.assign(networkError, { message: 'Downloading icon' }));
|
|
if (response.status !== 200) return; // ignore error. this can also happen for apps installed with cloudron-cli
|
|
|
|
const contentType = response.headers['content-type'];
|
|
if (!contentType || contentType.indexOf('image') === -1) throw new BoxError(BoxError.EXTERNAL_ERROR, 'AppStore returned invalid icon for app');
|
|
|
|
return response.body;
|
|
});
|
|
}
|