diff --git a/migrations/20210430200947-apps-add-icon.js b/migrations/20210430200947-apps-add-icon.js new file mode 100644 index 000000000..623add1db --- /dev/null +++ b/migrations/20210430200947-apps-add-icon.js @@ -0,0 +1,38 @@ +'use strict'; + +const async = require('async'), + fs = require('fs'), + path = require('path'); + +const APPICONS_DIR = '/home/yellowtent/boxdata/appicons'; + +exports.up = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE apps ADD COLUMN icon MEDIUMBLOB'), + db.runSql.bind(db, 'ALTER TABLE apps ADD COLUMN appStoreIcon MEDIUMBLOB'), + function migrateIcons(next) { + fs.readdir(APPICONS_DIR, function (error, filenames) { + if (error && error.code === 'ENOENT') return next(); + if (error) return next(error); + + async.eachSeries(filenames, function (filename, iteratorCallback) { + const icon = fs.readFileSync(path.join(APPICONS_DIR, filename)); + const appId = filename.split('.')[0]; + + if (filename.endsWith('.user.png')) { + db.runSql('UPDATE apps SET icon=? WHERE id=?', [ icon, appId ], iteratorCallback); + } else { + db.runSql('UPDATE apps SET appStoreIcon=? WHERE id=?', [ icon, appId ], iteratorCallback); + } + }, next); + }); + } + ], callback); +}; + +exports.down = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE apps DROP COLUMN icon'), + db.runSql.bind(db, 'ALTER TABLE apps DROP COLUMN appStoreIcon'), + ], callback); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index b2b026f1c..f4c6552da 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -91,6 +91,8 @@ 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, + icon MEDIUMBLOB, FOREIGN KEY(mailboxDomain) REFERENCES domains(domain), FOREIGN KEY(taskId) REFERENCES tasks(id), diff --git a/src/appdb.js b/src/appdb.js index 978a1da74..7736c915f 100644 --- a/src/appdb.js +++ b/src/appdb.js @@ -19,6 +19,8 @@ exports = module.exports = { getAppIdByAddonConfigValue, getByIpAddress, + getIcons, + setHealth, setTask, getAppStoreIds, @@ -43,7 +45,7 @@ const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationS 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.creationTime', 'apps.updateTime', 'apps.enableMailbox', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableAutomaticUpdate', - 'apps.dataDir', 'apps.ts', 'apps.healthTime' ].join(','); + 'apps.dataDir', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(','); const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId' ].join(','); @@ -83,11 +85,13 @@ function postProcess(result) { if (result.accessRestriction && !result.accessRestriction.users) result.accessRestriction.users = []; delete result.accessRestrictionJson; - result.sso = !!result.sso; // make it bool - result.enableBackup = !!result.enableBackup; // make it bool - result.enableAutomaticUpdate = !!result.enableAutomaticUpdate; // make it bool - result.enableMailbox = !!result.enableMailbox; // make it bool + result.sso = !!result.sso; + result.enableBackup = !!result.enableBackup; + result.enableAutomaticUpdate = !!result.enableAutomaticUpdate; + result.enableMailbox = !!result.enableMailbox; result.proxyAuth = !!result.proxyAuth; + result.hasIcon = !!result.hasIcon; + result.hasAppStoreIcon = !!result.hasAppStoreIcon; assert(result.debugModeJson === null || typeof result.debugModeJson === 'string'); result.debugMode = safe.JSON.parse(result.debugModeJson); @@ -217,15 +221,16 @@ function add(id, appStoreId, manifest, location, domain, portBindings, data, cal const mailboxDomain = data.mailboxDomain || null; const reverseProxyConfigJson = data.reverseProxyConfig ? JSON.stringify(data.reverseProxyConfig) : null; const servicesConfigJson = data.servicesConfig ? JSON.stringify(data.servicesConfig) : null; + const icon = data.icon || null; - var queries = []; + let queries = []; queries.push({ query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, runState, accessRestrictionJson, memoryLimit, cpuShares, ' - + 'sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, servicesConfigJson) ' - + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + + 'sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, servicesConfigJson, icon) ' + + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', args: [ id, appStoreId, manifestJson, installationState, runState, accessRestrictionJson, memoryLimit, cpuShares, - sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, servicesConfigJson ] + sso, debugModeJson, mailboxName, mailboxDomain, label, tagsJson, reverseProxyConfigJson, servicesConfigJson, icon ] }); queries.push({ @@ -293,7 +298,7 @@ function getPortBindings(id, callback) { if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); var portBindings = { }; - for (var i = 0; i < results.length; i++) { + for (let i = 0; i < results.length; i++) { portBindings[results[i].environmentVariable] = { hostPort: results[i].hostPort, type: results[i].type }; } @@ -301,6 +306,17 @@ function getPortBindings(id, callback) { }); } +function getIcons(id, callback) { + assert.strictEqual(typeof id, 'string'); + assert.strictEqual(typeof callback, 'function'); + + database.query('SELECT icon, appStoreIcon FROM apps WHERE id = ?', [ id ], function (error, results) { + if (error) return callback(new BoxError(BoxError.DATABASE_ERROR, error)); + + callback(null, { icon: results[0].icon, appStoreIcon: results[0].appStoreIcon }); + }); +} + function delPortBinding(hostPort, type, callback) { assert.strictEqual(typeof hostPort, 'number'); assert.strictEqual(typeof type, 'string'); @@ -414,7 +430,7 @@ function updateWithConstraints(id, app, constraints, callback) { } var fields = [ ], values = [ ]; - for (var p in app) { + for (let p in app) { if (p === 'manifest' || p === 'tags' || p === 'accessRestriction' || p === 'debugMode' || p === 'error' || p === 'reverseProxyConfig' || p === 'servicesConfig') { fields.push(`${p}Json = ?`); values.push(JSON.stringify(app[p])); diff --git a/src/apps.js b/src/apps.js index ed4d6ea5d..ae0771b5e 100644 --- a/src/apps.js +++ b/src/apps.js @@ -62,7 +62,7 @@ exports = module.exports = { restartAppsUsingAddons, getDataDir, - getIconPath, + getIcon, getMemoryLimit, downloadFile, @@ -421,34 +421,20 @@ function removeRestrictedFields(app) { 'location', 'domain', 'fqdn', 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup'); } -function getIconUrlSync(app) { - const iconUrl = '/api/v1/apps/' + app.id + '/icon'; - - const userIconPath = `${paths.APP_ICONS_DIR}/${app.id}.user.png`; - if (safe.fs.existsSync(userIconPath)) return iconUrl; - - const appstoreIconPath = `${paths.APP_ICONS_DIR}/${app.id}.png`; - if (safe.fs.existsSync(appstoreIconPath)) return iconUrl; - - return null; -} - -function getIconPath(app, options, callback) { +function getIcon(app, options, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof options, 'object'); assert.strictEqual(typeof callback, 'function'); - const appId = app.id; + appdb.getIcons(app.id, function (error, icons) { + if (error) return callback(error); - if (!options.original) { - const userIconPath = `${paths.APP_ICONS_DIR}/${appId}.user.png`; - if (safe.fs.existsSync(userIconPath)) return callback(null, userIconPath); - } + if (!options.original && icons.icon) return callback(null, icons.icon); - const appstoreIconPath = `${paths.APP_ICONS_DIR}/${appId}.png`; - if (safe.fs.existsSync(appstoreIconPath)) return callback(null, appstoreIconPath); + if (icons.appStoreIcon) return callback(null, icons.appStoreIcon); - callback(new BoxError(BoxError.NOT_FOUND, 'No icon')); + callback(new BoxError(BoxError.NOT_FOUND, 'No icon')); + }); } function getMemoryLimit(app) { @@ -471,8 +457,7 @@ function postProcess(app, domainObjectMap) { result[portName] = app.portBindings[portName].hostPort; } app.portBindings = result; - - app.iconUrl = getIconUrlSync(app); + app.iconUrl = app.hasIcon || app.hasAppStoreIcon ? `/api/v1/apps/${app.id}/icon` : null; app.fqdn = domains.fqdn(app.location, domainObjectMap[app.domain]); app.alternateDomains.forEach(function (ad) { ad.fqdn = domains.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); app.aliasDomains.forEach(function (ad) { ad.fqdn = domains.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); @@ -799,10 +784,7 @@ function install(data, auditSource, callback) { if (icon) { if (!validator.isBase64(icon)) return callback(new BoxError(BoxError.BAD_FIELD, 'icon is not base64', { field: 'icon' })); - - if (!safe.fs.writeFileSync(path.join(paths.APP_ICONS_DIR, appId + '.png'), Buffer.from(icon, 'base64'))) { - return callback(new BoxError(BoxError.FS_ERROR, 'Error saving icon:' + safe.error.message)); - } + icon = Buffer.from(icon, 'base64'); } const locations = [{ subdomain: location, domain, type: 'primary' }] @@ -833,6 +815,7 @@ function install(data, auditSource, callback) { env, label, tags, + icon, runState: exports.RSTATE_RUNNING, installationState: exports.ISTATE_PENDING_INSTALL }; @@ -940,17 +923,15 @@ function setIcon(app, icon, auditSource, callback) { if (icon) { if (!validator.isBase64(icon)) return callback(new BoxError(BoxError.BAD_FIELD, 'icon is not base64', { field: 'icon' })); - - if (!safe.fs.writeFileSync(path.join(paths.APP_ICONS_DIR, appId + '.user.png'), Buffer.from(icon, 'base64'))) { - return callback(new BoxError(BoxError.FS_ERROR, 'Error saving icon:' + safe.error.message)); - } - } else { - safe.fs.unlinkSync(path.join(paths.APP_ICONS_DIR, appId + '.user.png')); + icon = Buffer.from(icon, 'base64'); } - eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, icon }); + appdb.update(appId, { icon }, function (error) { + if (error) return callback(error); - callback(); + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, iconChanged: true }); + callback(); + }); } function setMemoryLimit(app, memoryLimit, auditSource, callback) { @@ -1347,13 +1328,9 @@ function update(app, data, auditSource, callback) { if ('icon' in data) { if (data.icon) { if (!validator.isBase64(data.icon)) return callback(new BoxError(BoxError.BAD_FIELD, 'icon is not base64', { field: 'icon' })); - - if (!safe.fs.writeFileSync(path.join(paths.APP_ICONS_DIR, appId + '.user.png'), Buffer.from(data.icon, 'base64'))) { - return callback(new BoxError(BoxError.FS_ERROR, 'Error saving icon:' + safe.error.message)); - } - } else { - safe.fs.unlinkSync(path.join(paths.APP_ICONS_DIR, appId + '.user.png')); + data.icon = Buffer.from(data.icon, 'base64'); } + values.icon = data.icon; } // do not update apps in debug mode diff --git a/src/apptask.js b/src/apptask.js index fc904ce90..7b5e853a2 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -306,29 +306,11 @@ function downloadIcon(app, callback) { if (error && !error.response) return retryCallback(new BoxError(BoxError.NETWORK_ERROR, `Network error downloading icon : ${error.message}`)); if (res.statusCode !== 200) return retryCallback(null); // ignore error. this can also happen for apps installed with cloudron-cli - const iconPath = path.join(paths.APP_ICONS_DIR, app.id + '.png'); - if (!safe.fs.writeFileSync(iconPath, res.body)) return retryCallback(new BoxError(BoxError.FS_ERROR, `Error saving icon to ${iconPath}: ${safe.error.message}`)); - - retryCallback(null); + updateApp(app, { appStoreIcon: res.body }, retryCallback); }); }, callback); } -function removeIcon(app, callback) { - assert.strictEqual(typeof app, 'object'); - assert.strictEqual(typeof callback, 'function'); - - if (!safe.fs.unlinkSync(path.join(paths.APP_ICONS_DIR, app.id + '.png'))) { - if (safe.error.code !== 'ENOENT') debugApp(app, 'cannot remove icon : %s', safe.error); - } - - if (!safe.fs.unlinkSync(path.join(paths.APP_ICONS_DIR, app.id + '.user.png'))) { - if (safe.error.code !== 'ENOENT') debugApp(app, 'cannot remove user icon : %s', safe.error); - } - - callback(null); -} - function waitForDnsPropagation(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); @@ -953,9 +935,6 @@ function uninstall(app, args, progressCallback, callback) { progressCallback.bind(null, { percent: 70, message: 'Unregistering domains' }), domains.unregisterLocations.bind(null, [ { subdomain: app.location, domain: app.domain } ].concat(app.alternateDomains).concat(app.aliasDomains), progressCallback), - progressCallback.bind(null, { percent: 80, message: 'Cleanup icon' }), - removeIcon.bind(null, app), - progressCallback.bind(null, { percent: 90, message: 'Cleanup logs' }), cleanupLogs.bind(null, app), diff --git a/src/routes/apps.js b/src/routes/apps.js index 1aabd3c3b..8aec08a2a 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -93,10 +93,10 @@ function getApps(req, res, next) { function getAppIcon(req, res, next) { assert.strictEqual(typeof req.resource, 'object'); - apps.getIconPath(req.resource, { original: req.query.original }, function (error, iconPath) { + apps.getIcon(req.resource, { original: req.query.original }, function (error, icon) { if (error) return next(BoxError.toHttpError(error)); - res.sendFile(iconPath); + res.send(icon); }); } @@ -214,7 +214,7 @@ function setIcon(req, res, next) { if (req.body.icon !== null && typeof req.body.icon !== 'string') return next(new HttpError(400, 'icon is null or a base-64 image string')); - apps.setIcon(req.resource, req.body.icon, auditSource.fromRequest(req), function (error) { + apps.setIcon(req.resource, req.body.icon || null /* empty string means null */, auditSource.fromRequest(req), function (error) { if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, {})); diff --git a/src/test/database-test.js b/src/test/database-test.js index c273f19cd..c92a79f60 100644 --- a/src/test/database-test.js +++ b/src/test/database-test.js @@ -424,7 +424,9 @@ describe('database', function () { taskId: null, mounts: [], proxyAuth: false, - servicesConfig: {} + servicesConfig: {}, + hasIcon: false, + hasAppStoreIcon: false }; it('cannot delete referenced domain', function (done) { @@ -903,7 +905,9 @@ describe('database', function () { taskId: null, mounts: [], proxyAuth: false, - servicesConfig: {} + servicesConfig: {}, + hasIcon: false, + hasAppStoreIcon: false }; var APP_1 = { @@ -939,7 +943,9 @@ describe('database', function () { taskId: null, mounts: [], proxyAuth: false, - servicesConfig: {} + servicesConfig: {}, + hasIcon: false, + hasAppStoreIcon: false }; before(function (done) {