diff --git a/migrations/20260206174408-apps-rename-appStoreIcon-to-packageIcon.js b/migrations/20260206174408-apps-rename-appStoreIcon-to-packageIcon.js new file mode 100644 index 000000000..f353c2eb8 --- /dev/null +++ b/migrations/20260206174408-apps-rename-appStoreIcon-to-packageIcon.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.up = async function (db) { + await db.runSql('ALTER TABLE apps RENAME COLUMN appStoreIcon TO packageIcon'); + await db.runSql('ALTER TABLE archives RENAME COLUMN appStoreIcon TO packageIcon'); +}; + +exports.down = async function () { +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 452a0b004..c198ec91c 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -104,7 +104,7 @@ CREATE TABLE IF NOT EXISTS apps( errorJson TEXT, servicesConfigJson TEXT, // app services configuration containerIp VARCHAR(16) UNIQUE, // this is not-null because of ip allocation fails, user can 'repair' - appStoreIcon MEDIUMBLOB, + packageIcon MEDIUMBLOB, icon MEDIUMBLOB, crontab TEXT, upstreamUri VARCHAR(256) DEFAULT "", @@ -171,7 +171,7 @@ CREATE TABLE IF NOT EXISTS archives( id VARCHAR(128) NOT NULL UNIQUE, backupId VARCHAR(128) NOT NULL, creationTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - appStoreIcon MEDIUMBLOB, + packageIcon MEDIUMBLOB, icon MEDIUMBLOB, FOREIGN KEY(backupId) REFERENCES backups(id), diff --git a/src/apps.js b/src/apps.js index f1ae8ed90..e4213acca 100644 --- a/src/apps.js +++ b/src/apps.js @@ -196,7 +196,7 @@ const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.versionsUrl', 'apps.sso', 'apps.devicesJson', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.crontab', 'apps.creationTime', 'apps.updateTime', 'apps.enableAutomaticUpdate', 'apps.upstreamUri', 'apps.checklistJson', 'apps.updateInfoJson', 'apps.enableMailbox', 'apps.mailboxDisplayName', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableInbox', 'apps.inboxName', 'apps.inboxDomain', - 'apps.enableTurn', 'apps.enableRedis', 'apps.storageVolumeId', 'apps.storageVolumePrefix', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(','); + 'apps.enableTurn', 'apps.enableRedis', 'apps.storageVolumeId', 'apps.storageVolumePrefix', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.packageIcon IS NOT NULL) AS hasPackageIcon' ].join(','); // const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId', 'count' ].join(','); const LOCATION_FIELDS = [ 'appId', 'subdomain', 'domain', 'type', 'certificateJson' ]; @@ -623,7 +623,7 @@ async function getIcon(app, options) { if (!icons) throw new BoxError(BoxError.NOT_FOUND, 'No such app'); if (!options.original && icons.icon) return icons.icon; - if (icons.appStoreIcon) return icons.appStoreIcon; + if (icons.packageIcon) return icons.packageIcon; return null; } @@ -699,7 +699,7 @@ function postProcess(result) { result.enableInbox = !!result.enableInbox; result.proxyAuth = !!result.proxyAuth; result.hasIcon = !!result.hasIcon; - result.hasAppStoreIcon = !!result.hasAppStoreIcon; + result.hasPackageIcon = !!result.hasPackageIcon; assert(result.debugModeJson === null || typeof result.debugModeJson === 'string'); result.debugMode = safe.JSON.parse(result.debugModeJson); @@ -800,7 +800,7 @@ function attachProperties(app, domainObjectMap) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof domainObjectMap, 'object'); - app.iconUrl = app.hasIcon || app.hasAppStoreIcon ? `/api/v1/apps/${app.id}/icon` : null; + app.iconUrl = app.hasIcon || app.hasPackageIcon ? `/api/v1/apps/${app.id}/icon` : null; app.fqdn = dns.fqdn(app.subdomain, app.domain); app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); }); app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); }); @@ -997,9 +997,9 @@ async function add(id, appStoreId, versionsUrl, manifest, subdomain, domain, por async function getIcons(id) { assert.strictEqual(typeof id, 'string'); - const results = await database.query('SELECT icon, appStoreIcon FROM apps WHERE id = ?', [ id ]); + const results = await database.query('SELECT icon, packageIcon FROM apps WHERE id = ?', [ id ]); if (results.length === 0) return null; - return { icon: results[0].icon, appStoreIcon: results[0].appStoreIcon }; + return { icon: results[0].icon, packageIcon: results[0].packageIcon }; } async function updateWithConstraints(id, app, constraints) { @@ -2615,7 +2615,7 @@ async function archive(app, backupId, auditSource) { if (result[0].id !== backupId) throw new BoxError(BoxError.BAD_STATE, 'Latest backup id has changed'); const icons = await getIcons(app.id); - const archiveId = await archives.add(backupId, { icon: icons.icon, appStoreIcon: icons.appStoreIcon, appConfig: app }, auditSource); + const archiveId = await archives.add(backupId, { icon: icons.icon, packageIcon: icons.packageIcon, appConfig: app }, auditSource); const { taskId } = await uninstall(app, auditSource); return { taskId, id: archiveId }; } diff --git a/src/apptask.js b/src/apptask.js index f6fdd575e..d0da29a5b 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -18,6 +18,7 @@ const apps = require('./apps.js'), backupSites = require('./backupsites.js'), backuptask = require('./backuptask.js'), BoxError = require('./boxerror.js'), + community = require('./community.js'), constants = require('./constants.js'), debug = require('debug')('box:apptask'), df = require('./df.js'), @@ -207,12 +208,16 @@ async function verifyManifest(manifest) { async function downloadIcon(app) { assert.strictEqual(typeof app, 'object'); - if (!app.appStoreId) return; // nothing to download if we dont have an appStoreId + let packageIcon = null; + if (app.versionsUrl && app.manifest.iconUrl) { + debug(`downloadIcon: Downloading community icon ${app.manifest.iconUrl}`); + packageIcon = await community.downloadIcon(app.manifest); + } else if (app.appStoreId) { + debug(`downloadIcon: Downloading icon of ${app.appStoreId}@${app.manifest.version}`); + packageIcon = await appstore.downloadIcon(app.appStoreId, app.manifest.version); + } - debug(`downloadIcon: Downloading icon of ${app.appStoreId}@${app.manifest.version}`); - - const appStoreIcon = await appstore.downloadIcon(app.appStoreId, app.manifest.version); - await updateApp(app, { appStoreIcon }); + await updateApp(app, { packageIcon }); } async function downloadImage(manifest) { diff --git a/src/archives.js b/src/archives.js index a346542dd..283e1e9ac 100644 --- a/src/archives.js +++ b/src/archives.js @@ -17,7 +17,7 @@ const assert = require('node:assert'), eventlog = require('./eventlog.js'), safe = require('safetydance'); -const ARCHIVE_FIELDS = [ 'archives.id', 'backupId', 'archives.creationTime', 'backups.remotePath', 'backups.siteId', 'backups.manifestJson', 'backups.appConfigJson', '(archives.icon IS NOT NULL) AS hasIcon', '(archives.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ]; +const ARCHIVE_FIELDS = [ 'archives.id', 'backupId', 'archives.creationTime', 'backups.remotePath', 'backups.siteId', 'backups.manifestJson', 'backups.appConfigJson', '(archives.icon IS NOT NULL) AS hasIcon', '(archives.packageIcon IS NOT NULL) AS hasPackageIcon' ]; function postProcess(result) { assert.strictEqual(typeof result, 'object'); @@ -28,7 +28,7 @@ function postProcess(result) { result.manifest = result.manifestJson ? safe.JSON.parse(result.manifestJson) : null; delete result.manifestJson; - result.iconUrl = result.hasIcon || result.hasAppStoreIcon ? `/api/v1/archives/${result.id}/icon` : null; + result.iconUrl = result.hasIcon || result.hasPackageIcon ? `/api/v1/archives/${result.id}/icon` : null; return result; } @@ -45,9 +45,9 @@ async function get(id) { async function getIcons(id) { assert.strictEqual(typeof id, 'string'); - const results = await database.query('SELECT icon, appStoreIcon FROM archives WHERE id=?', [ id ]); + const results = await database.query('SELECT icon, packageIcon FROM archives WHERE id=?', [ id ]); if (results.length === 0) return null; - return { icon: results[0].icon, appStoreIcon: results[0].appStoreIcon }; + return { icon: results[0].icon, packageIcon: results[0].packageIcon }; } async function getIcon(id, options) { @@ -58,7 +58,7 @@ async function getIcon(id, options) { if (!icons) throw new BoxError(BoxError.NOT_FOUND, 'No such backup'); if (!options.original && icons.icon) return icons.icon; - if (icons.appStoreIcon) return icons.appStoreIcon; + if (icons.packageIcon) return icons.packageIcon; return null; } @@ -70,8 +70,8 @@ async function add(backupId, data, auditSource) { const id = crypto.randomUUID(); - const [error] = await safe(database.query('INSERT INTO archives (id, backupId, icon, appStoreIcon) VALUES (?, ?, ?, ?)', - [ id, backupId, data.icon, data.appStoreIcon ])); + const [error] = await safe(database.query('INSERT INTO archives (id, backupId, icon, packageIcon) VALUES (?, ?, ?, ?)', + [ id, backupId, data.icon, data.packageIcon ])); if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'Backup not found'); if (error && error.sqlCode === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'Archive already exists'); diff --git a/src/community.js b/src/community.js index d78854180..12ffd1fe1 100644 --- a/src/community.js +++ b/src/community.js @@ -3,13 +3,15 @@ exports = module.exports = { getAppVersion, downloadManifest, - getAppUpdate + getAppUpdate, + downloadIcon }; const assert = require('node:assert'), BoxError = require('./boxerror.js'), debug = require('debug')('box:community'), manifestFormat = require('@cloudron/manifest-format'), + promiseRetry = require('./promise-retry.js'), safe = require('safetydance'), superagent = require('@cloudron/superagent'); @@ -97,3 +99,18 @@ async function getAppUpdate(app, options) { ...nextVersion // { manifest, publishState, creationDate, ts } }; } + +async function downloadIcon(manifest) { + return await promiseRetry({ times: 10, interval: 5000, debug }, async function () { + const [networkError, response] = await safe(superagent.get(manifest.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)}`); + + if (!Buffer.isBuffer(response.body)) throw new BoxError(BoxError.EXTERNAL_ERROR, 'invalid icon returned for app'); + + return response.body; + }); +} diff --git a/src/routes/test/common.js b/src/routes/test/common.js index b1cb97134..2e34be50c 100644 --- a/src/routes/test/common.js +++ b/src/routes/test/common.js @@ -178,7 +178,7 @@ async function setup() { user.token = token2.accessToken; // create app object - await apps.add(exports.app.id, exports.app.appStoreId, exports.app.manifest, exports.app.subdomain, exports.app.domain, exports.app.portBindings, exports.app); + await apps.add(exports.app.id, exports.app.appStoreId, '', exports.app.manifest, exports.app.subdomain, exports.app.domain, exports.app.portBindings, exports.app); await settings._set(settings.APPSTORE_API_TOKEN_KEY, exports.appstoreToken); // appstore token diff --git a/src/test/apps-test.js b/src/test/apps-test.js index 4c5f7a050..704338e2b 100644 --- a/src/test/apps-test.js +++ b/src/test/apps-test.js @@ -22,7 +22,7 @@ describe('Apps', function () { describe('checkForPortBindingConflict', function () { before(async function () { - await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, [{ hostPort: 40000, type: 'tcp', count: 100 }, { hostPort: 50000, type: 'udp', count: 1 }], app); + await apps.add(app.id, app.appStoreId, '', app.manifest, app.subdomain, app.domain, [{ hostPort: 40000, type: 'tcp', count: 100 }, { hostPort: 50000, type: 'udp', count: 1 }], app); }); after(async function () { @@ -259,16 +259,16 @@ describe('Apps', function () { }); it('can add app', async function () { - await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app); + await apps.add(app.id, app.appStoreId, '', app.manifest, app.subdomain, app.domain, app.portBindings, app); }); it('cannot add with same app id', async function () { - const [error] = await safe(apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app)); + const [error] = await safe(apps.add(app.id, app.appStoreId, '', app.manifest, app.subdomain, app.domain, app.portBindings, app)); expect(error.reason).to.be(BoxError.ALREADY_EXISTS); }); it('cannot add with same app id', async function () { - const [error] = await safe(apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app)); + const [error] = await safe(apps.add(app.id, app.appStoreId, '', app.manifest, app.subdomain, app.domain, app.portBindings, app)); expect(error.reason).to.be(BoxError.ALREADY_EXISTS); }); @@ -334,7 +334,7 @@ describe('Apps', function () { describe('setHealth', function () { before(async function () { - await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app); + await apps.add(app.id, app.appStoreId, '', app.manifest, app.subdomain, app.domain, app.portBindings, app); }); it('can set app as healthy', async function () { @@ -360,7 +360,7 @@ describe('Apps', function () { const newUpstreamUri = 'https://foobar.com:443'; before(async function () { - await apps.add(proxyApp.id, proxyApp.appStoreId, proxyApp.manifest, proxyApp.subdomain, proxyApp.domain, proxyApp.portBindings, proxyApp); + await apps.add(proxyApp.id, proxyApp.appStoreId, '', proxyApp.manifest, proxyApp.subdomain, proxyApp.domain, proxyApp.portBindings, proxyApp); }); it('cannot set invalid upstream uri', async function () { @@ -382,8 +382,8 @@ describe('Apps', function () { before(async function () { await apps.update(app.id, { installationState: apps.ISTATE_INSTALLED }); - await apps.add(app1.id, app1.appStoreId, app1.manifest, app1.subdomain, app1.domain, app1.portBindings, app1); - await apps.add(app2.id, app2.appStoreId, app2.manifest, app2.subdomain, app2.domain, app2.portBindings, app2); + await apps.add(app1.id, app1.appStoreId, '', app1.manifest, app1.subdomain, app1.domain, app1.portBindings, app1); + await apps.add(app2.id, app2.appStoreId, '', app2.manifest, app2.subdomain, app2.domain, app2.portBindings, app2); }); after(async function () { @@ -407,8 +407,8 @@ describe('Apps', function () { before(async function () { await apps.update(app.id, { installationState: apps.ISTATE_INSTALLED }); - await apps.add(app1.id, app1.appStoreId, app1.manifest, app1.subdomain, app1.domain, app1.portBindings, app1); - await apps.add(app2.id, app2.appStoreId, app2.manifest, app2.subdomain, app2.domain, app2.portBindings, app2); + await apps.add(app1.id, app1.appStoreId, '', app1.manifest, app1.subdomain, app1.domain, app1.portBindings, app1); + await apps.add(app2.id, app2.appStoreId, '', app2.manifest, app2.subdomain, app2.domain, app2.portBindings, app2); }); after(async function () { diff --git a/src/test/common.js b/src/test/common.js index 0630e501e..1e3fb1bb1 100644 --- a/src/test/common.js +++ b/src/test/common.js @@ -237,7 +237,7 @@ async function setup() { await domainSetup(); const ownerId = await users.createOwner(admin.email, admin.username, admin.password, admin.displayName, auditSource); admin.id = ownerId; - await apps.add(app.id, app.appStoreId, app.manifest, app.subdomain, app.domain, app.portBindings, app); + await apps.add(app.id, app.appStoreId, '', app.manifest, app.subdomain, app.domain, app.portBindings, app); await settings._set(settings.APPSTORE_API_TOKEN_KEY, exports.appstoreToken); // appstore token const userId = await users.add(user.email, user, auditSource); user.id = userId; diff --git a/src/test/domains-test.js b/src/test/domains-test.js index 57988a4c7..bf1fd356c 100644 --- a/src/test/domains-test.js +++ b/src/test/domains-test.js @@ -114,7 +114,7 @@ describe('Domains', function () { it('cannot delete referenced domain', async function () { const appCopy = Object.assign({}, app, { id: 'into', subdomain: 'xx', domain: DOMAIN_0.domain, portBindings: {} }); - await apps.add(appCopy.id, appCopy.appStoreId, appCopy.manifest, appCopy.subdomain, appCopy.domain, appCopy.portBindings, appCopy); + await apps.add(appCopy.id, appCopy.appStoreId, '', appCopy.manifest, appCopy.subdomain, appCopy.domain, appCopy.portBindings, appCopy); const [error] = await safe(domains.del(DOMAIN_0.domain, auditSource)); expect(error.reason).to.equal(BoxError.CONFLICT);