import apps from './apps.js'; import assert from 'node:assert'; import backupSites from './backupsites.js'; import BoxError from './boxerror.js'; import constants from './constants.js'; import dashboard from './dashboard.js'; import logger from './logger.js'; import domains from './domains.js'; import dockerRegistries from './dockerregistries.js'; import directoryServer from './directoryserver.js'; import externalLdap from './externalldap.js'; import groups from './groups.js'; import mail from './mail.js'; import manifestFormat from '@cloudron/manifest-format'; import oidcClients from './oidcclients.js'; import paths from './paths.js'; import promiseRetry from './promise-retry.js'; import safe from 'safetydance'; import semver from 'semver'; import settings from './settings.js'; import superagent from '@cloudron/superagent'; import system from './system.js'; import users from './users.js'; import volumes from './volumes.js'; const { log, trace } = logger('appstore'); // 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 = { appUpdates: false, appMaxCount: 2, userMaxCount: 5, domainMaxCount: 1, mailboxMaxCount: 5, branding: false, externalLdap: false, privateDockerRegistry: false, userGroups: false, emailServer: false, profileConfig: false, multipleBackupTargets: false, encryptedBackups: false, // TODO how to go about that in the UI? 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 getState() { const mailDomains = await mail.listDomains(); const mailStats = await Promise.all(mailDomains.map(d => mail.getStats(d.domain))); const allUsers = await users.list(); const roleCounts = allUsers.reduce((acc, u) => { acc[u.role] = (acc[u.role] || 0) + 1; return acc; }, {}); const state = { provider: system.getProvider(), users: { count: allUsers.length, roleCounts }, groupCount: (await groups.list()).length, domains: (await domains.list()).map(d => d.provider), mail: { incomingCount: mailDomains.filter(md => md.enabled).length, catchAllCount: mailDomains.filter(md => md.catchAll.length).length, bannerCount: mailDomains.filter(md => md.banner.text || md.banner.html).length, mailboxCount: mailStats.reduce((acc, cur) => acc + cur.mailboxCount, 0), mailingListCount: mailStats.reduce((acc, cur) => acc + cur.mailingListCount, 0), pop3Count: mailStats.reduce((acc, cur) => acc + cur.pop3Count, 0), aliasCount: mailStats.reduce((acc, cur) => acc + cur.aliasCount, 0) }, apps: (await apps.list()).map(a => { return { id: a.manifest.id, community: !!a.versionsUrl }; }), dockerRegistries: (await dockerRegistries.list()).map(r => r.provider), backupSites: (await backupSites.list()).map(s => { return { provider: s.provider, format: s.format, encryption: !!s.encryption }; }), externalLdap: (await externalLdap.getConfig()).provider, volumes: (await volumes.list()).map(v => v.mountType), directoryServer: (await directoryServer.getConfig()).enabled, oidcClientCount: (await oidcClients.list()).length }; return state; } 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 [stateError, state] = await safe(getState()); if (stateError) log('getSubscription: error getting current state', stateError); const [error, response] = await safe(superagent.post(`${await getApiServerOrigin()}/api/v1/subscription3`) .query({ accessToken: token }) .send({ state }) .timeout(60 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, '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, null, 2), 'utf8'); // { email, emailVerified, cloudronId, cloudronCreatedAt, plan: { id, name }, canceled_at, status, externalCustomer, features: {} } return response.body; } // cron hook async function checkSubscription() { const [error, result] = await safe(getSubscription()); if (error) log('checkSubscription error:', error); else log(`checkSubscription: Cloudron ${result.cloudronId} is on the ${result.plan.name} plan.`); } 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, stableOnly: options.stableOnly }; 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.LICENSE_ERROR, 'Invalid appstore token'); if (response.status === 204) return null; // 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)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Offered version ${updateInfo.version} is invalid`); if (semver.gt(constants.VERSION, updateInfo.version)) throw new BoxError(BoxError.EXTERNAL_ERROR, `Offered version ${updateInfo.version} would be a downgrade`); // 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, stableOnly: options.stableOnly }; 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.LICENSE_ERROR, 'Invalid appstore token'); if (response.status === 204) return null; // 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'))) { log('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 updateCloudron(data) { assert.strictEqual(typeof data, 'object'); const { domain, version } = 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, version }) .timeout(60 * 1000) .ok(() => true)); if (error) throw new BoxError(BoxError.NETWORK_ERROR, error); if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, 'Invalid appstore token'); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Bad response: ${response.status} ${response.text}`); log(`updateCloudron: Cloudron updated with data ${JSON.stringify(data)}`); } async function registerCloudron3() { const { domain } = await dashboard.getLocation(); const version = constants.VERSION; const token = await settings.get(settings.APPSTORE_API_TOKEN_KEY); if (token) { // when installed using setupToken, this updates the domain record when called during provisioning log('registerCloudron3: already registered. Just updating the record.'); await getSubscription(); return await updateCloudron({ domain, version }); } 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); log(`registerCloudron3: Cloudron registered with id ${response.body.cloudronId}`); await getSubscription(); } async function unregister() { await settings.set(settings.CLOUDRON_ID_KEY, ''); await settings.set(settings.APPSTORE_API_TOKEN_KEY, ''); } async function unlinkAccount() { log('unlinkAccount: Unlinking existing account.'); if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode'); await unregister(); return await registerCloudron3(); } 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 : ''); log(`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.LICENSE_ERROR, '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.LICENSE_ERROR, '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; // { id, creationDate, publishState, manifest, iconUrl } } 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: log }, async function () { const [networkError, response] = await safe(superagent.get(iconUrl) .timeout(60 * 1000) .ok(() => true)); if (networkError) throw new BoxError(BoxError.NETWORK_ERROR, `Network error downloading icon: ${networkError.message}`); if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Icon download failed. ${response.status} ${JSON.stringify(response.body)}`); 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; }); } const _setApiServerOrigin = setApiServerOrigin; export default { getFeatures, getApiServerOrigin, getWebServerOrigin, getConsoleServerOrigin, downloadManifest, getApps, getApp, getAppVersion, downloadIcon, registerCloudron3, updateCloudron, unlinkAccount, getSubscription, checkSubscription, // cron hook, isFreePlan, getAppUpdate, getBoxUpdate, _setApiServerOrigin, _unregister: unregister, };