diff --git a/CHANGES b/CHANGES index a017c6768..6c738f97b 100644 --- a/CHANGES +++ b/CHANGES @@ -1563,7 +1563,7 @@ * Add notification for cert renewal and backup failures * Fix issue where mail container was not updated with the latest certificate -[3.5.5] +[4.0.0] * (mail) Bump mail_max_userip_connections to 50 * Fix issue where DKIM was not setup correctly during a restore * (mysql) Remove any stale lock file on restart @@ -1571,4 +1571,5 @@ * Cleanup task logs * Fix issue where dashboard location might conflict with existing app location * Ad graphite to services +* Add labels and tags to apps diff --git a/migrations/20190322144543-apps-add-label.js b/migrations/20190322144543-apps-add-label.js new file mode 100644 index 000000000..959bcb226 --- /dev/null +++ b/migrations/20190322144543-apps-add-label.js @@ -0,0 +1,15 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN label VARCHAR(128)', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN label', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/20190322144544-apps-add-tagsJson.js b/migrations/20190322144544-apps-add-tagsJson.js new file mode 100644 index 000000000..4ed505431 --- /dev/null +++ b/migrations/20190322144544-apps-add-tagsJson.js @@ -0,0 +1,15 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN tagsJson VARCHAR(2048)', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN tagsJson ', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/src/appdb.js b/src/appdb.js index bbd13d976..61709e672 100644 --- a/src/appdb.js +++ b/src/appdb.js @@ -68,6 +68,7 @@ var assert = require('assert'), var APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.installationProgress', 'apps.runState', 'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.httpPort', 'subdomains.subdomain AS location', 'subdomains.domain', 'apps.accessRestrictionJson', 'apps.restoreConfigJson', 'apps.oldConfigJson', 'apps.updateConfigJson', 'apps.memoryLimit', + 'apps.label', 'apps.tagsJson', 'apps.xFrameOptions', 'apps.sso', 'apps.debugModeJson', 'apps.robotsTxt', 'apps.enableBackup', 'apps.creationTime', 'apps.updateTime', 'apps.ownerId', 'apps.mailboxName', 'apps.enableAutomaticUpdate', 'apps.dataDir', 'apps.ts', 'apps.healthTime' ].join(','); @@ -95,6 +96,10 @@ function postProcess(result) { result.restoreConfig = safe.JSON.parse(result.restoreConfigJson); delete result.restoreConfigJson; + assert(result.tagsJson === null || typeof result.tagsJson === 'string'); + result.tags = safe.JSON.parse(result.tagsJson) || []; + delete result.tagsJson; + assert(result.hostPorts === null || typeof result.hostPorts === 'string'); assert(result.environmentVariables === null || typeof result.environmentVariables === 'string'); @@ -272,24 +277,28 @@ function add(id, appStoreId, manifest, location, domain, ownerId, portBindings, var manifestJson = JSON.stringify(manifest); - var accessRestriction = data.accessRestriction || null; - var accessRestrictionJson = JSON.stringify(accessRestriction); - var memoryLimit = data.memoryLimit || 0; - var xFrameOptions = data.xFrameOptions || ''; - var installationState = data.installationState || exports.ISTATE_PENDING_INSTALL; - var restoreConfigJson = data.restoreConfig ? JSON.stringify(data.restoreConfig) : null; // used when cloning - var sso = 'sso' in data ? data.sso : null; - var robotsTxt = 'robotsTxt' in data ? data.robotsTxt : null; - var debugModeJson = data.debugMode ? JSON.stringify(data.debugMode) : null; - var env = data.env || {}; + const accessRestriction = data.accessRestriction || null; + const accessRestrictionJson = JSON.stringify(accessRestriction); + const memoryLimit = data.memoryLimit || 0; + const xFrameOptions = data.xFrameOptions || ''; + const installationState = data.installationState || exports.ISTATE_PENDING_INSTALL; + const restoreConfigJson = data.restoreConfig ? JSON.stringify(data.restoreConfig) : null; // used when cloning + const sso = 'sso' in data ? data.sso : null; + const robotsTxt = 'robotsTxt' in data ? data.robotsTxt : null; + const debugModeJson = data.debugMode ? JSON.stringify(data.debugMode) : null; + const env = data.env || {}; + const label = data.label || null; + const tagsJson = data.tags ? JSON.stringify(data.tags) : null; const mailboxName = data.mailboxName; var queries = []; queries.push({ - query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions, restoreConfigJson, sso, debugModeJson, robotsTxt, ownerId, mailboxName) ' + - ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - args: [ id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions, restoreConfigJson, sso, debugModeJson, robotsTxt, ownerId, mailboxName ] + query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions,' + + 'restoreConfigJson, sso, debugModeJson, robotsTxt, ownerId, mailboxName, label, tagsJson) ' + + + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + args: [ id, appStoreId, manifestJson, installationState, accessRestrictionJson, memoryLimit, xFrameOptions, restoreConfigJson, + sso, debugModeJson, robotsTxt, ownerId, mailboxName, label, tagsJson ] }); queries.push({ diff --git a/src/apps.js b/src/apps.js index 38c315a1c..6fefaf61f 100644 --- a/src/apps.js +++ b/src/apps.js @@ -283,11 +283,33 @@ function validateRobotsTxt(robotsTxt) { } function validateBackupFormat(format) { + assert.strictEqual(typeof format, 'string'); + if (format === 'tgz' || format == 'rsync') return null; return new AppsError(AppsError.BAD_FIELD, 'Invalid backup format'); } +function validateLabel(label) { + if (label === null) return null; + + if (label.length > 128) return new AppsError(AppsError.BAD_FIELD, 'label must be less than 128'); + + return null; +} + +function validateTags(tags) { + assert(Array.isArray(tags), 'tags must be an array'); + + if (tags.length > 64) return new AppsError(AppsError.BAD_FIELD, 'Can only set up to 64 tags'); + + for (const tag of tags) { + if (tag.length > 128) return new AppsError(AppsError.BAD_FIELD, 'tag must be less than 128'); + } + + return null; +} + function validateEnv(env) { for (let key in env) { if (key.length > 512) return new AppsError(AppsError.BAD_FIELD, 'Max env var key length is 512'); @@ -575,7 +597,9 @@ function install(data, user, auditSource, callback) { ownerId = data.ownerId, alternateDomains = data.alternateDomains || [], env = data.env || {}, - mailboxName = data.mailboxName || ''; + mailboxName = data.mailboxName || '', + label = data.label || null, + tags = data.tags || null; assert(data.appStoreId || data.manifest); // atleast one of them is required @@ -609,6 +633,12 @@ function install(data, user, auditSource, callback) { error = validateBackupFormat(backupFormat); if (error) return callback(error); + error = validateLabel(label); + if (error) return callback(error); + + error = validateTags(tags); + if (error) return callback(error); + if ('sso' in data && !('optionalSso' in manifest)) return callback(new AppsError(AppsError.BAD_FIELD, 'sso can only be specified for apps with optionalSso')); // if sso was unspecified, enable it by default if possible if (sso === null) sso = !!manifest.addons['ldap'] || !!manifest.addons['oauth']; @@ -796,6 +826,18 @@ function configure(appId, data, user, auditSource, callback) { values.dataDir = data.dataDir; } + if ('label' in data) { + error = validateLabel(data.label); + if (error) return callback(error); + values.label = data.label; + } + + if ('tags' in data) { + error = validateTags(data.tags); + if (error) return callback(error); + values.tags = data.tags; + } + domains.get(domain, function (error, domainObject) { if (error && error.reason === DomainsError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such domain')); if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, 'Could not get domain info:' + error.message)); diff --git a/src/routes/apps.js b/src/routes/apps.js index 3979dd1b6..eb1246c58 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -122,6 +122,12 @@ function installApp(req, res, next) { if (data.backupId && typeof data.backupId !== 'string') return next(new HttpError(400, 'backupId must be string or null')); if (data.backupFormat && typeof data.backupFormat !== 'string') return next(new HttpError(400, 'backupFormat must be string or null')); + if ('label' in data && typeof data.label !== 'string') return next(new HttpError(400, 'label must be a string')); + if ('tags' in data) { + if (!Array.isArray(data.tags)) return next(new HttpError(400, 'tags must be a string array')); + if (data.tags.some(d => typeof d !== 'string')) return next(new HttpError(400, 'tags must be in array of strings')); + } + // falsy values in cert and key unset the cert if (data.key && typeof data.cert !== 'string') return next(new HttpError(400, 'cert must be a string')); if (data.cert && typeof data.key !== 'string') return next(new HttpError(400, 'key must be a string')); @@ -210,6 +216,12 @@ function configureApp(req, res, next) { if (Object.keys(data.env).some(function (key) { return typeof data.env[key] !== 'string'; })) return next(new HttpError(400, 'env must contain values as strings')); } + if (data.label && typeof data.label !== 'string') return next(new HttpError(400, 'label must be a non-empty string')); + if (data.tags) { + if (!Array.isArray(data.tags)) return next(new HttpError(400, 'tags must be a string array')); + if (data.tags.some(d => typeof d !== 'string')) return next(new HttpError(400, 'tags must be in array of strings')); + } + if ('dataDir' in data && typeof data.dataDir !== 'string') return next(new HttpError(400, 'dataDir must be a string')); debug('Configuring app id:%s data:%j', req.params.id, data);