community: store versionsUrl in the database

This commit is contained in:
Girish Ramakrishnan
2026-02-05 17:29:00 +01:00
parent 91b8f1a457
commit d6eb6d3e3e
13 changed files with 249 additions and 118 deletions
+20 -17
View File
@@ -190,7 +190,7 @@ const appTaskManager = require('./apptaskmanager.js'),
_ = require('./underscore.js');
// NOTE: when adding fields here, update the clone and unarchive logic as well
const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.errorJson', 'apps.runState',
const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.versionsUrl', 'apps.installationState', 'apps.errorJson', 'apps.runState',
'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.accessRestrictionJson', 'apps.memoryLimit', 'apps.cpuQuota',
'apps.label', 'apps.notes', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', 'apps.operatorsJson',
'apps.sso', 'apps.devicesJson', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.crontab',
@@ -592,12 +592,12 @@ function pickFields(app, accessLevel) {
let result;
if (accessLevel === exports.ACCESS_LEVEL_USER) {
result = _.pick(app, [
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction',
'id', 'appStoreId', 'versionsUrl', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction',
'secondaryDomains', 'redirectDomains', 'aliasDomains', 'sso', 'subdomain', 'domain', 'fqdn', 'certificate',
'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'upstreamUri']);
} else { // admin or operator
result = _.pick(app, [
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId',
'id', 'appStoreId', 'versionsUrl', 'installationState', 'error', 'runState', 'health', 'taskId',
'subdomain', 'domain', 'fqdn', 'certificate', 'crontab', 'upstreamUri',
'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuQuota', 'operators',
'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags',
@@ -884,9 +884,10 @@ async function checkForPortBindingConflict(portBindings, options) {
}
}
async function add(id, appStoreId, manifest, subdomain, domain, portBindings, data) {
async function add(id, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, data) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof appStoreId, 'string');
assert.strictEqual(typeof versionsUrl, 'string');
assert(manifest && typeof manifest === 'object');
assert.strictEqual(typeof manifest.version, 'string');
assert.strictEqual(typeof subdomain, 'string');
@@ -930,11 +931,11 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
const queries = [];
queries.push({
query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, runState, accessRestrictionJson, operatorsJson, memoryLimit, cpuQuota, '
query: 'INSERT INTO apps (id, appStoreId, versionsUrl, manifestJson, installationState, runState, accessRestrictionJson, operatorsJson, memoryLimit, cpuQuota, '
+ 'sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, checklistJson, servicesConfigJson, icon, '
+ 'enableMailbox, mailboxDisplayName, upstreamUri, enableTurn, enableRedis, devicesJson, notes, crontab, enableBackup, enableAutomaticUpdate) '
+ ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
args: [ id, appStoreId, manifestJson, installationState, runState, accessRestrictionJson, operatorsJson, memoryLimit, cpuQuota,
+ ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
args: [ id, appStoreId, versionsUrl, manifestJson, installationState, runState, accessRestrictionJson, operatorsJson, memoryLimit, cpuQuota,
sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, checklistJson, servicesConfigJson, icon,
enableMailbox, mailboxDisplayName, upstreamUri, enableTurn, enableRedis, devicesJson, notes, crontab,
enableBackup, enableAutomaticUpdate
@@ -1400,7 +1401,8 @@ async function install(data, auditSource) {
overwriteDns = 'overwriteDns' in data ? data.overwriteDns : false,
skipDnsSetup = 'skipDnsSetup' in data ? data.skipDnsSetup : false,
enableTurn = 'enableTurn' in data ? data.enableTurn : true,
appStoreId = data.appStoreId,
appStoreId = data.appStoreId || '',
versionsUrl = data.versionsUrl || '',
upstreamUri = data.upstreamUri || '',
manifest = data.manifest,
notes = data.notes || null,
@@ -1520,7 +1522,7 @@ async function install(data, auditSource) {
installationState: exports.ISTATE_PENDING_INSTALL
};
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, portBindings, app));
const [addError] = await safe(add(appId, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, app));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
@@ -1532,7 +1534,7 @@ async function install(data, auditSource) {
const taskId = await addTask(appId, app.installationState, task, auditSource);
const newApp = Object.assign({}, _.omit(app, ['icon']), { appStoreId, manifest, subdomain, domain, portBindings });
const newApp = Object.assign({}, _.omit(app, ['icon']), { appStoreId, versionsUrl, manifest, subdomain, domain, portBindings });
newApp.fqdn = dns.fqdn(newApp.subdomain, newApp.domain);
newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
@@ -2129,8 +2131,9 @@ async function updateApp(app, data, auditSource) {
error = await checkManifest(manifest);
if (error) throw error;
const updateConfig = { skipBackup, manifest }; // this will clear appStoreId when updating from a repo and set it if passed in for update route
const updateConfig = { skipBackup, manifest }; // this will clear appStoreId/versionsUrl when updating from a repo and set it if passed in for update route
if ('appStoreId' in data) updateConfig.appStoreId = data.appStoreId;
if ('versionsUrl' in data) updateConfig.versionsUrl = data.versionsUrl;
// prevent user from installing a app with different manifest id over an existing app
// this allows cloudron install -f --app <appid> for an app installed from the appStore
@@ -2141,8 +2144,8 @@ async function updateApp(app, data, auditSource) {
// suffix '0' if prerelease is missing for semver.lte to work as expected
const currentVersion = semver.prerelease(app.manifest.version) ? app.manifest.version : `${app.manifest.version}-0`;
const updateVersion = semver.prerelease(updateConfig.manifest.version) ? updateConfig.manifest.version : `${updateConfig.manifest.version}-0`;
if (app.appStoreId !== '' && semver.lte(updateVersion, currentVersion)) {
if (!data.force) throw new BoxError(BoxError.BAD_FIELD, 'Downgrades are not permitted for apps installed from AppStore. force to override');
if ((app.appStoreId !== '' || app.versionsUrl !== '') && semver.lte(updateVersion, currentVersion)) {
if (!data.force) throw new BoxError(BoxError.BAD_FIELD, 'Downgrades are not permitted for apps installed from AppStore or Community. force to override');
}
if ('icon' in data) {
@@ -2438,7 +2441,7 @@ async function clone(app, data, user, auditSource) {
if (!backup.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not detect restore manifest');
if (backup.encryptionVersion === 1) throw new BoxError(BoxError.BAD_FIELD, 'This encrypted backup was created with an older Cloudron version and cannot be cloned');
const manifest = backup.manifest, appStoreId = app.appStoreId;
const manifest = backup.manifest, appStoreId = app.appStoreId, versionsUrl = app.versionsUrl;
let error = validateSecondaryDomains(data.secondaryDomains || {}, manifest);
if (error) throw error;
@@ -2483,7 +2486,7 @@ async function clone(app, data, user, auditSource) {
label: dolly.label ? `${dolly.label}-clone` : '',
});
const [addError] = await safe(add(newAppId, appStoreId, manifest, subdomain, domain, portBindings, obj));
const [addError] = await safe(add(newAppId, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, obj));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
@@ -2518,7 +2521,7 @@ async function unarchive(archive, data, auditSource) {
domain = data.domain.toLowerCase(),
overwriteDns = 'overwriteDns' in data ? data.overwriteDns : false;
const manifest = backup.manifest, appStoreId = backup.manifest.id;
const manifest = backup.manifest, appStoreId = backup.manifest.id, versionsUrl = backup.appConfig?.versionsUrl || '';
let error = validateSecondaryDomains(data.secondaryDomains || {}, manifest);
if (error) throw error;
@@ -2558,7 +2561,7 @@ async function unarchive(archive, data, auditSource) {
});
obj.icon = (await archives.getIcons(archive.id))?.icon;
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, portBindings, obj));
const [addError] = await safe(add(appId, appStoreId, versionsUrl, manifest, subdomain, domain, portBindings, obj));
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
if (addError) throw addError;
+61 -1
View File
@@ -1,11 +1,14 @@
'use strict';
exports = module.exports = {
getAppVersion
getAppVersion,
downloadManifest,
getAppUpdate
};
const assert = require('node:assert'),
BoxError = require('./boxerror.js'),
debug = require('debug')('box:community'),
manifestFormat = require('@cloudron/manifest-format'),
safe = require('safetydance'),
superagent = require('@cloudron/superagent');
@@ -37,3 +40,60 @@ async function getAppVersion(url, version) {
...versionData // { manifest, publishState, creationDate, ts }
};
}
async function downloadManifest(versionsUrl) {
assert.strictEqual(typeof versionsUrl, 'string');
const atIndex = versionsUrl.lastIndexOf('@');
if (atIndex === -1) throw new BoxError(BoxError.BAD_FIELD, 'version is required in versionsUrl (format: url@version)');
const url = versionsUrl.substring(0, atIndex);
const version = versionsUrl.substring(atIndex + 1);
if (!url.startsWith('https://')) throw new BoxError(BoxError.BAD_FIELD, 'versionsUrl must use https');
if (!version) throw new BoxError(BoxError.BAD_FIELD, 'version is required in versionsUrl (format: url@version)');
debug(`downloading manifest from ${url} version ${version}`);
const [error, response] = await safe(superagent.get(url).timeout(60 * 1000).ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, 'Network error downloading manifest: ' + error.message);
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND, 'CloudronVersions.json not found');
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Fetch failed: ${response.status}`);
const versions = response.body;
if (!versions || typeof versions !== 'object') throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid CloudronVersions.json format');
const sortedVersions = Object.keys(versions).sort(manifestFormat.packageVersionCompare);
const versionData = version === 'latest' ? versions[sortedVersions.at(-1)] : versions[version];
if (!versionData) throw new BoxError(BoxError.NOT_FOUND, `Version ${version} not found`);
if (!versionData.manifest || typeof versionData.manifest !== 'object') throw new BoxError(BoxError.EXTERNAL_ERROR, 'Missing manifest in version data');
return { versionsUrl: url, manifest: versionData.manifest };
}
async function getAppUpdate(app, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
const [error, response] = await safe(superagent.get(app.versionsUrl).timeout(60 * 1000).ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, 'Network error downloading manifest: ' + error.message);
if (response.status === 404) throw new BoxError(BoxError.NOT_FOUND, 'CloudronVersions.json not found');
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, `Fetch failed: ${response.status}`);
const versions = response.body;
if (!versions || typeof versions !== 'object') throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid CloudronVersions.json format');
const sortedVersions = Object.keys(versions).sort(manifestFormat.packageVersionCompare);
const idx = sortedVersions.findIndex(v => v === app.manifest.version);
if (idx === -1) throw new BoxError(BoxError.EXTERNAL_ERROR, 'No such version')
if (idx === sortedVersions.length-1) return null; // no update
const nextVersion = versions[sortedVersions[idx+1]];
return {
id: app.manifest.id,
creationDate: nextVersion.creationDate,
manifest: nextVersion.manifest,
unstable: nextVersion.publishState === 'approved'
};
}
+27 -12
View File
@@ -78,6 +78,7 @@ const apps = require('../apps.js'),
AuditSource = require('../auditsource.js'),
backupSites = require('../backupsites.js'),
BoxError = require('../boxerror.js'),
community = require('../community.js'),
constants = require('../constants.js'),
debug = require('debug')('box:routes/apps'),
HttpError = require('@cloudron/connect-lastmile').HttpError,
@@ -145,7 +146,8 @@ async function install(req, res, next) {
// atleast one
if ('manifest' in data && typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest must be an object'));
if ('appStoreId' in data && typeof data.appStoreId !== 'string') return next(new HttpError(400, 'appStoreId must be a string'));
if (!data.manifest && !data.appStoreId) return next(new HttpError(400, 'appStoreId or manifest is required'));
if ('versionsUrl' in data && typeof data.versionsUrl !== 'string') return next(new HttpError(400, 'versionsUrl must be a string'));
if (!data.manifest && !data.appStoreId && !data.versionsUrl) return next(new HttpError(400, 'appStoreId, versionsUrl, or manifest is required'));
// required
if (typeof data.subdomain !== 'string') return next(new HttpError(400, 'subdomain is required'));
@@ -196,15 +198,22 @@ async function install(req, res, next) {
if ('cpuQuota' in data && data.cpuQuota !== 'number') return next(new HttpError(400, 'cpuQuota is not a number'));
if ('operators' in data && typeof data.operators !== 'object') return next(new HttpError(400, 'operators must be an object'));
let [error, result] = await safe(appstore.downloadManifest(data.appStoreId, data.manifest));
let error, result;
if (data.versionsUrl) {
[error, result] = await safe(community.downloadManifest(data.versionsUrl));
data.manifest = result.manifest;
data.versionsUrl = result.versionsUrl; // without version
} else {
[error, result] = await safe(appstore.downloadManifest(data.appStoreId, data.manifest));
data.manifest = result.manifest;
data.appStoreId = result.appStoreId; // without version
}
if (error) return next(BoxError.toHttpError(error));
if (result.appStoreId === constants.PROXY_APP_APPSTORE_ID && typeof data.upstreamUri !== 'string') return next(new HttpError(400, 'upstreamUri must be a non empty string'));
if (safe.query(result.manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to install app with docker addon'));
data.appStoreId = result.appStoreId;
data.manifest = result.manifest;
data.sourceArchiveFilePath = req.files && req.files.sourceArchive?.path || null;
// if we have a source archive upload, craft a custom docker image URI for later
@@ -699,20 +708,26 @@ async function update(req, res, next) {
// atleast one
if ('manifest' in data && typeof data.manifest !== 'object') return next(new HttpError(400, 'manifest must be an object'));
if ('appStoreId' in data && typeof data.appStoreId !== 'string') return next(new HttpError(400, 'appStoreId must be a string'));
if (!data.manifest && !data.appStoreId) return next(new HttpError(400, 'appStoreId or manifest is required'));
if ('versionsUrl' in data && typeof data.versionsUrl !== 'string') return next(new HttpError(400, 'versionsUrl must be a string'));
if (!data.manifest && !data.appStoreId && !data.versionsUrl) return next(new HttpError(400, 'appStoreId, versionsUrl, or manifest is required'));
if ('skipBackup' in data && typeof data.skipBackup !== 'boolean') return next(new HttpError(400, 'skipBackup must be a boolean'));
if ('force' in data && typeof data.force !== 'boolean') return next(new HttpError(400, 'force must be a boolean'));
let [error, result] = await safe(appstore.downloadManifest(data.appStoreId, data.manifest));
let error, result;
if (data.versionsUrl) {
[error, result] = await safe(community.downloadManifest(data.versionsUrl));
data.manifest = result.manifest;
data.versionsUrl = result.versionsUrl; // without version
} else {
[error, result] = await safe(appstore.downloadManifest(data.appStoreId, data.manifest));
data.manifest = result.manifest;
data.appStoreId = result.appStoreId; // without version
}
if (error) return next(BoxError.toHttpError(error));
const { appStoreId, manifest } = result;
if (safe.query(data.manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to update app with docker addon'));
if (safe.query(manifest, 'addons.docker') && req.user.role !== users.ROLE_OWNER) return next(new HttpError(403, '"owner" role is required to update app with docker addon'));
data.appStoreId = appStoreId;
data.manifest = manifest;
data.sourceArchiveFilePath = req.files && req.files.sourceArchive?.path || null;
// if we have a source archive upload, craft a custom docker image URI for later
@@ -1065,7 +1080,7 @@ async function listEventlog(req, res, next) {
async function checkUpdate(req, res, next) {
assert.strictEqual(typeof req.resources.app, 'object');
if (!req.resources.app.appStoreId) return next(new HttpError(400, 'Custom apps have no updates'));
if (!req.resources.app.appStoreId && !req.resources.app.versionsUrl) return next(new HttpError(400, 'Custom apps have no updates'));
// it can take a while sometimes to get all the app updates one by one
req.clearTimeout();
+12 -5
View File
@@ -25,6 +25,7 @@ const apps = require('./apps.js'),
BoxError = require('./boxerror.js'),
backupSites = require('./backupsites.js'),
backuptask = require('./backuptask.js'),
community = require('./community.js'),
constants = require('./constants.js'),
cron = require('./cron.js'),
{ CronTime } = require('cron'),
@@ -313,11 +314,17 @@ async function checkAppUpdate(app, options) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof options, 'object');
if (app.appStoreId === '') return null; // appStoreId can be '' for dev apps
const updateInfo = await appstore.getAppUpdate(app, options);
await apps.update(app.id, { updateInfo });
return updateInfo;
if (app.appStoreId) {
const updateInfo = await appstore.getAppUpdate(app, options);
await apps.update(app.id, { updateInfo });
return updateInfo;
} else if (app.versionsUrl) {
const updateInfo = await community.getAppUpdate(app, options);
await apps.update(app.id, { updateInfo });
return updateInfo;
} else {
return null;
}
}
async function checkBoxUpdate(options) {