diff --git a/dashboard/src/components/AppInstallDialog.vue b/dashboard/src/components/AppInstallDialog.vue index 80ff0ad64..d47113e3f 100644 --- a/dashboard/src/components/AppInstallDialog.vue +++ b/dashboard/src/components/AppInstallDialog.vue @@ -7,6 +7,8 @@ import { prettyDate, prettyBinarySize } from '@cloudron/pankow/utils'; import AccessControl from './AccessControl.vue'; import PortBindings from './PortBindings.vue'; import AppsModel from '../models/AppsModel.js'; +import CommunityModel from '../models/CommunityModel.js'; +import AppstoreModel from '../models/AppstoreModel.js'; import DomainsModel from '../models/DomainsModel.js'; import UsersModel from '../models/UsersModel.js'; import GroupsModel from '../models/GroupsModel.js'; @@ -18,7 +20,9 @@ const STEP = Object.freeze({ INSTALL: Symbol('install'), }); +const appstoreModel = AppstoreModel.create(); const appsModel = AppsModel.create(); +const communityModel = CommunityModel.create(); const domainsModel = DomainsModel.create(); const usersModel = UsersModel.create(); const groupsModel = GroupsModel.create(); @@ -29,7 +33,7 @@ const dashboardDomain = inject('dashboardDomain'); // reactive const busy = ref(false); const formError = ref({}); -const app = ref({}); +const appData = ref({}); const manifest = ref({}); const step = ref(STEP.DETAILS); const dialog = useTemplateRef('dialogHandle'); @@ -155,7 +159,7 @@ async function onSubmit(overwriteDns) { if (manifest.value.id === PROXY_APP_ID) config.upstreamUri = upstreamUri.value; - const [error, result] = await appsModel.install(manifest.value, config); + const [error, result] = await appsModel.install(appData.value, config); if (!error) { dialog.value.close(); @@ -209,14 +213,36 @@ function onScreenshotNext() { } defineExpose({ - open: function(appData, appCountExceeded, domainList) { + open: async function(upstreamRef, appCountExceeded, domainList) { busy.value = false; - step.value = STEP.DETAILS; + step.value = STEP.LOADING; formError.value = {}; - app.value = appData; + // give it some time to fetch before showing loading + const openTimer = setTimeout(dialog.value.open, 200); + + if (upstreamRef.appStoreId) { + const [id, version] = upstreamRef.appStoreId.split('@'); + const [error, result] = await appstoreModel.get(id, version); + if (error) { + clearTimeout(openTimer); + dialog.value.close(); + throw new Error('App not found'); + } + appData.value = { ...result, ...upstreamRef }; + } else if (upstreamRef.versionsUrl) { + const [url, version] = upstreamRef.versionsUrl.split('@'); + const [error, result] = await communityModel.getApp(url, version); + if (error) { + clearTimeout(openTimer); + dialog.value.close(); + throw new Error(error.body?.message || 'Failed to fetch community app'); + } + appData.value = { ...result, ...upstreamRef }; + } + appMaxCountExceeded.value = appCountExceeded; - manifest.value = appData.manifest; + manifest.value = appData.value.manifest; location.value = ''; accessRestrictionOption.value = ACL_OPTIONS.ANY; accessRestrictionAcl.value = { users: [], groups: [] }; @@ -233,8 +259,8 @@ defineExpose({ // preselect with dashboard domain domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain; - tcpPorts.value = appData.manifest.tcpPorts; - udpPorts.value = appData.manifest.udpPorts; + tcpPorts.value = manifest.value.tcpPorts; + udpPorts.value = manifest.value.udpPorts; // ensure we have value property for (const p in tcpPorts.value) { @@ -246,7 +272,7 @@ defineExpose({ udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true; } - secondaryDomains.value = appData.manifest.httpPorts; + secondaryDomains.value = manifest.value.httpPorts; for (const p in secondaryDomains.value) { const port = secondaryDomains.value[p]; port.value = port.defaultValue; @@ -254,7 +280,7 @@ defineExpose({ } currentScreenshotPos = 0; - dialog.value.open(); + step.value = STEP.DETAILS; }, close() { dialog.value.close(); @@ -270,13 +296,13 @@ defineExpose({
-
-
{{ manifest.title }}
-
{{ manifest.title }} {{ app.manifest.upstreamVersion }} - {{ $t('app.updates.info.packageVersion') }} {{ app.manifest.version }}
-
{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(app.creationDate) }) }}
+
+
{{ manifest.title }}
+
{{ manifest.title }} {{ appData.manifest.upstreamVersion }} - {{ $t('app.updates.info.packageVersion') }} {{ appData.manifest.version }}
+
{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(appData.creationDate) }) }}
{{ $t('appstore.installDialog.memoryRequirement', { size: prettyBinarySize(manifest.memoryLimit || (256 * 1024 * 1024)) }) }}
- +
diff --git a/dashboard/src/components/CommunityAppDialog.vue b/dashboard/src/components/CommunityAppDialog.vue index deecc7228..387a44c46 100644 --- a/dashboard/src/components/CommunityAppDialog.vue +++ b/dashboard/src/components/CommunityAppDialog.vue @@ -2,33 +2,25 @@ import { ref, useTemplateRef } from 'vue'; import { Dialog, TextInput, FormGroup } from '@cloudron/pankow'; -import CommunityModel from '../models/CommunityModel.js'; - -const communityModel = CommunityModel.create(); const emit = defineEmits([ 'success' ]); const dialog = useTemplateRef('dialog'); -const busy = ref(false); const formError = ref({}); const url = ref(''); const version = ref('latest'); -async function onSubmit() { - busy.value = true; +function onSubmit() { formError.value = {}; - const [error, result] = await communityModel.getApp(url.value, version.value); - if (error) { - formError.value.generic = error.body?.message || 'Failed to fetch community app'; - busy.value = false; - return console.error(error); + if (!url.value || !version.value) { + formError.value.generic = 'URL and version are required'; + return; } - emit('success', result); + emit('success', { url: url.value, version: version.value }); dialog.value.close(); - busy.value = false; } defineExpose({ @@ -36,7 +28,6 @@ defineExpose({ url.value = ''; version.value = 'latest'; formError.value = {}; - busy.value = false; dialog.value.open(); } }); @@ -47,29 +38,25 @@ defineExpose({
-
- + -
{{ formError.generic }}
+
{{ formError.generic }}
- - - - + + + + - - - - -
+ + + +
diff --git a/dashboard/src/components/app/Info.vue b/dashboard/src/components/app/Info.vue index 3b6ef22e6..c4a852861 100644 --- a/dashboard/src/components/app/Info.vue +++ b/dashboard/src/components/app/Info.vue @@ -31,7 +31,6 @@ async function onAckChecklistItem(item, key) { hasOldChecklist.value = true; } -// Notes async function onSubmit() { busy.value = true; @@ -81,6 +80,7 @@ onMounted(() => {
{{ $t('app.updates.info.description') }}
{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}
+
{{ app.versionsUrl }} {{ app.manifest.upstreamVersion }}
{{ app.manifest.dockerImage }}
diff --git a/dashboard/src/components/app/Updates.vue b/dashboard/src/components/app/Updates.vue index a527b7347..87eb13401 100644 --- a/dashboard/src/components/app/Updates.vue +++ b/dashboard/src/components/app/Updates.vue @@ -66,7 +66,11 @@ async function onUpdate() { busyUpdate.value = true; updateError.value = ''; - const [error, result] = await appsModel.update(props.app.id, props.app.updateInfo.manifest, skipBackup.value); + let appData = ''; + if (props.app.appStoreId) appData = { appStoreId: `${props.app.appStoreId}@${props.app.updateInfo.manifest.version}` }; + if (props.app.versionsUrl) appData = { versionsUrl: `${props.app.versionsUrl}@${props.app.updateInfo.manifest.version}` }; + + const [error, result] = await appsModel.update(props.app.id, appData, skipBackup.value); if (error) { busyUpdate.value = false; if (error.status === 400) updateError.value = error.body ? error.body.message : 'Internal error'; @@ -118,20 +122,20 @@ onMounted(async () => {
-
{{ $t('app.updates.info.customAppUpdateInfo') }}
-
-
- +
+
{{ $t('app.updates.info.customAppUpdateInfo') }}
+
+
-
+

- +
diff --git a/dashboard/src/models/AppsModel.js b/dashboard/src/models/AppsModel.js index 1af1bb38c..84a79ed51 100644 --- a/dashboard/src/models/AppsModel.js +++ b/dashboard/src/models/AppsModel.js @@ -172,9 +172,8 @@ function create() { return { name: 'AppsModel', getTask, - async install(manifest, config) { + async install(appData, config) { const data = { - appStoreId: manifest.id + '@' + manifest.version, subdomain: config.subdomain, domain: config.domain, secondaryDomains: config.secondaryDomains, @@ -188,6 +187,13 @@ function create() { backupId: config.backupId // when restoring from archive }; + // Support both appstore apps (manifest) and community apps (versionsUrl) + if (appData.versionsUrl) { + data.versionsUrl = appData.versionsUrl; + } else if (appData.manifest) { + data.appStoreId = appData.appStoreId; + } + let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps`, data, { access_token: accessToken }); @@ -329,12 +335,18 @@ function create() { if (result.status !== 200) return [result]; return [null, result.body.update]; }, - async update(id, manifest, skipBackup = false) { + async update(id, appData, skipBackup = false) { const data = { - appStoreId: `${manifest.id}@${manifest.version}`, skipBackup: !!skipBackup, }; + // Support both appstore apps (manifest) and community apps (versionsUrl) + if (appData.versionsUrl) { + data.versionsUrl = appData.versionsUrl; + } else if (appData.manifest) { + data.appStoreId = appData.appStoreId; + } + let result; try { result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/update`, data, { access_token: accessToken }); diff --git a/dashboard/src/models/CommunityModel.js b/dashboard/src/models/CommunityModel.js index a024d2726..7f260761b 100644 --- a/dashboard/src/models/CommunityModel.js +++ b/dashboard/src/models/CommunityModel.js @@ -3,23 +3,22 @@ import { fetcher } from '@cloudron/pankow'; import { API_ORIGIN } from '../constants.js'; function create() { - const accessToken = localStorage.token; + const accessToken = localStorage.token; - return { - async getApp(url, version) { - let result; - try { - result = await fetcher.get(`${API_ORIGIN}/api/v1/community/app`, { access_token: accessToken, url, version }); - } catch (e) { - return [e]; - } - - if (result.status !== 200) return [result]; - return [null, result.body]; - } - }; + return { + async getApp(url, version) { + let result; + try { + result = await fetcher.get(`${API_ORIGIN}/api/v1/community/app`, { access_token: accessToken, url, version }); + } catch (e) { + return [e]; + } + if (result.status !== 200) return [result]; + return [null, result.body]; + } + }; } export default { - create, + create, }; diff --git a/dashboard/src/views/AppstoreView.vue b/dashboard/src/views/AppstoreView.vue index 4bd2c45dc..5b556a68c 100644 --- a/dashboard/src/views/AppstoreView.vue +++ b/dashboard/src/views/AppstoreView.vue @@ -144,17 +144,17 @@ async function onHashChange() { const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?'))); const version = params.get('version') || 'latest'; - const [error, appData] = await appstoreModel.get(appId, version); - if (error) { - console.error(error); - return inputDialog.value.info({ - title: t('appstore.appNotFoundDialog.title'), - message: t('appstore.appNotFoundDialog.description', { appId, version }), - confirmLabel: t('main.dialog.close'), - }); - } + // const [error, appData] = await appstoreModel.get(appId, version); + // if (error) { + // console.error(error); + // return inputDialog.value.info({ + // title: t('appstore.appNotFoundDialog.title'), + // message: t('appstore.appNotFoundDialog.description', { appId, version }), + // confirmLabel: t('main.dialog.close'), + // }); + // } - appInstallDialog.value.open(appData, installedApps.value.length >= features.value.appMaxCount, domains.value); + appInstallDialog.value.open({ appStoreId: `${appId}@${version}` }, installedApps.value.length >= features.value.appMaxCount, domains.value); } } @@ -194,8 +194,10 @@ function onInstallCommunityApp() { communityAppDialog.value.open(); } -function onCommunityAppSuccess(appData) { - appInstallDialog.value.open(appData, installedApps.value.length >= features.value.appMaxCount, domains.value); +function onCommunityAppSuccess({ url, version }) { + // Construct versionsUrl in url@version format + const versionsUrl = `${url}@${version}`; + appInstallDialog.value.open({ versionsUrl }, installedApps.value.length >= features.value.appMaxCount, domains.value); } onActivated(async () => { diff --git a/migrations/20260205155407-apps-add-versionsUrl.js b/migrations/20260205155407-apps-add-versionsUrl.js new file mode 100644 index 000000000..621aee689 --- /dev/null +++ b/migrations/20260205155407-apps-add-versionsUrl.js @@ -0,0 +1,15 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN versionsUrl VARCHAR(512) DEFAULT ""', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN versionsUrl', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index d095ca075..452a0b004 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS tokens( CREATE TABLE IF NOT EXISTS apps( id VARCHAR(128) NOT NULL UNIQUE, appStoreId VARCHAR(128) NOT NULL, // empty for custom apps + versionsUrl VARCHAR(512) DEFAULT "", // community apps installationState VARCHAR(512) NOT NULL, // the active task on the app runState VARCHAR(512) NOT NULL, // if the app is stopped health VARCHAR(128), diff --git a/src/apps.js b/src/apps.js index 66703bbe9..259f250bc 100644 --- a/src/apps.js +++ b/src/apps.js @@ -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 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; diff --git a/src/community.js b/src/community.js index c698a7202..271491394 100644 --- a/src/community.js +++ b/src/community.js @@ -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' + }; +} diff --git a/src/routes/apps.js b/src/routes/apps.js index 46a8d4f11..d780eb3d0 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -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(); diff --git a/src/updater.js b/src/updater.js index 5586eda9f..e3bfc42c8 100644 --- a/src/updater.js +++ b/src/updater.js @@ -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) {