'use strict'; /* global angular */ /* global $ */ /* global asyncSeries */ /* global asyncForEach */ /* global RSTATES */ /* global ISTATES */ /* global ERROR */ angular.module('Application').controller('AppController', ['$scope', '$location', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $timeout, $interval, $route, $routeParams, Client) { Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); }); // List is from http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region $scope.s3Regions = [ { name: 'Asia Pacific (Mumbai)', value: 'ap-south-1' }, { name: 'Asia Pacific (Osaka-Local)', value: 'ap-northeast-3' }, { name: 'Asia Pacific (Seoul)', value: 'ap-northeast-2' }, { name: 'Asia Pacific (Singapore)', value: 'ap-southeast-1' }, { name: 'Asia Pacific (Sydney)', value: 'ap-southeast-2' }, { name: 'Asia Pacific (Tokyo)', value: 'ap-northeast-1' }, { name: 'Canada (Central)', value: 'ca-central-1' }, { name: 'EU (Frankfurt)', value: 'eu-central-1' }, { name: 'EU (Ireland)', value: 'eu-west-1' }, { name: 'EU (London)', value: 'eu-west-2' }, { name: 'EU (Paris)', value: 'eu-west-3' }, { name: 'EU (Stockholm)', value: 'eu-north-1' }, { name: 'South America (São Paulo)', value: 'sa-east-1' }, { name: 'US East (N. Virginia)', value: 'us-east-1' }, { name: 'US East (Ohio)', value: 'us-east-2' }, { name: 'US West (N. California)', value: 'us-west-1' }, { name: 'US West (Oregon)', value: 'us-west-2' }, ]; $scope.wasabiRegions = [ { name: 'EU Central 1', value: 'https://s3.eu-central-1.wasabisys.com' }, { name: 'US East 1', value: 'https://s3.wasabisys.com' }, { name: 'US West 1', value: 'https://s3.us-west-1.wasabisys.com' } ]; $scope.doSpacesRegions = [ { name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' }, { name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' }, { name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' }, { name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' }, { name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' } ]; $scope.exoscaleSosRegions = [ { name: 'AT-VIE-1', value: 'https://sos-at-vie-1.exo.io' }, { name: 'CH-DK-2', value: 'https://sos-ch-dk-2.exo.io' }, { name: 'CH-GVA-2', value: 'https://sos-ch-gva-2.exo.io' }, { name: 'DE-FRA-1', value: 'https://sos-de-fra-1.exo.io' }, ]; // https://www.scaleway.com/docs/object-storage-feature/ $scope.scalewayRegions = [ { name: 'FR-PAR', value: 'https://s3.fr-par.scw.cloud', region: 'fr-par' }, // default { name: 'NL-AMS', value: 'https://s3.nl-ams.scw.cloud', region: 'nl-ams' } ]; $scope.storageProvider = [ { name: 'Amazon S3', value: 's3' }, { name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' }, { name: 'Exoscale SOS', value: 'exoscale-sos' }, { name: 'Filesystem', value: 'filesystem' }, { name: 'Google Cloud Storage', value: 'gcs' }, { name: 'Minio', value: 'minio' }, { name: 'Scaleway Object Storage', value: 'scaleway-objectstorage' }, // { name: 'No-op (Only for testing)', value: 'noop' }, { name: 'S3 API Compatible (v4)', value: 's3-v4-compat' }, { name: 'Wasabi', value: 'wasabi' } ]; $scope.formats = [ { name: 'Tarball (zipped)', value: 'tgz' }, { name: 'rsync', value: 'rsync' } ]; // Avoid full reload on path change // https://stackoverflow.com/a/22614334 // reloadOnUrl: false in $routeProvider did not work! var lastRoute = $route.current; $scope.$on('$locationChangeSuccess', function (/* event */) { if (lastRoute.$$route.originalPath === $route.current.$$route.originalPath) { $route.current = lastRoute; } }); var appId = $routeParams.appId; if (!appId) return $location.path('/apps'); $scope.view = ''; $scope.app = null; $scope.config = Client.getConfig(); $scope.user = Client.getUserInfo(); $scope.domains = []; $scope.groups = []; $scope.users = []; $scope.HOST_PORT_MIN = 1024; $scope.HOST_PORT_MAX = 65535; $scope.ROBOTS_DISABLE_INDEXING_TEMPLATE = '# Disable search engine indexing\n\nUser-agent: *\nDisallow: /'; $scope.setView = function (view, skipViewShow) { if ($scope.view === view) return; $route.updateParams({ view: view }); if (!skipViewShow) $scope[view].show(); $scope.view = view; }; $scope.postInstallMessage = { confirmed: false, openApp: false, show: function (openApp) { $scope.postInstallMessage.confirmed = false; $scope.postInstallMessage.openApp = !!openApp; if (!$scope.app.manifest.postInstallMessage) return; $('#postInstallModal').modal('show'); }, submit: function () { if (!$scope.postInstallMessage.confirmed) return; $scope.app.pendingPostInstallConfirmation = false; delete localStorage['confirmPostInstall_' + $scope.app.id]; $('#postInstallModal').modal('hide'); } }; $scope.display = { busy: false, error: {}, success: false, tags: '', label: '', icon: { data: null }, iconUrl: function () { if (!$scope.app) return ''; if ($scope.display.icon.data === '__original__') { // user clicked reset return $scope.app.iconUrl + '&original=true'; } else if ($scope.display.icon.data) { // user uploaded icon return $scope.display.icon.data; } else { // current icon return $scope.app.iconUrl; } }, resetCustomIcon: function () { $scope.display.icon.data = '__original__'; }, showCustomIconSelector: function () { $('#iconFileInput').click(); }, show: function () { var app = $scope.app; $scope.display.error = {}; // translate for tag-input $scope.display.tags = app.tags ? app.tags.join(',') : ''; $scope.display.label = $scope.app.label || ''; $scope.display.icon = { data: null }; }, submit: function () { $scope.display.busy = true; $scope.display.error = {}; function done(error) { if (error) Client.error(error); $scope.displayForm.$setPristine(); $scope.display.success = true; refreshApp($scope.app.id, function (error) { if (error) Client.error(error); $scope.display.show(); // "refresh" view with latest data $timeout(function () { $scope.display.busy = false; }, 1000); }); } var NOOP = function (next) { return next(); }; var configureLabel = $scope.display.label === $scope.app.label ? NOOP : Client.configureApp.bind(null, $scope.app.id, 'label', { label: $scope.display.label }); configureLabel(function (error) { if (error) return done(error); var tags = $scope.display.tags.split(',').map(function (t) { return t.trim(); }).filter(function (t) { return !!t; }); var configureTags = angular.equals(tags, $scope.app.tags) ? NOOP : Client.configureApp.bind(null, $scope.app.id, 'tags', { tags: tags }); configureTags(function (error) { if (error) return done(error); // skip if icon is unchanged if ($scope.display.icon.data === null) return done(); var icon; if ($scope.display.icon.data === '__original__') { // user reset the icon icon = ''; } else if ($scope.display.icon.data) { // user loaded custom icon icon = $scope.display.icon.data.replace(/^data:image\/[a-z]+;base64,/, ''); } Client.configureApp($scope.app.id, 'icon', { icon: icon }, function (error) { if (error) return done(error); done(); }); }); }); } }; $scope.location = { busy: false, error: {}, domainCollisions: [], domain: null, location: '', alternateDomains: [], portBindings: {}, portBindingsEnabled: {}, portBindingsInfo: {}, addAlternateDomain: function (event) { event.preventDefault(); $scope.location.alternateDomains.push({ domain: $scope.domains[0], subdomain: '' }); }, delAlternateDomain: function (event, index) { event.preventDefault(); $scope.location.alternateDomains.splice(index, 1); }, show: function () { var app = $scope.app; $scope.location.error = {}; $scope.location.domainCollisions = []; $scope.location.location = app.location; $scope.location.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0]; $scope.location.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information $scope.location.alternateDomains = app.alternateDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };}); // fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port for (var env in $scope.location.portBindingsInfo) { if (app.portBindings && app.portBindings[env]) { $scope.location.portBindings[env] = app.portBindings[env]; $scope.location.portBindingsEnabled[env] = true; } else { $scope.location.portBindings[env] = $scope.location.portBindingsInfo[env].defaultValue || 0; $scope.location.portBindingsEnabled[env] = false; } } }, submit: function (overwriteDns) { $('#domainCollisionsModal').modal('hide'); $scope.location.busy = true; $scope.location.error = {}; $scope.location.domainCollisions = []; // only use enabled ports from portBindings var portBindings = {}; for (var env in $scope.location.portBindings) { if ($scope.location.portBindingsEnabled[env]) { portBindings[env] = $scope.location.portBindings[env]; } } var data = { overwriteDns: !!overwriteDns, location: $scope.location.location, domain: $scope.location.domain.domain, portBindings: portBindings, alternateDomains: $scope.location.alternateDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}) }; // pre-flight only for changed domains var domains = []; if ($scope.app.domain !== data.domain || $scope.app.location !== data.location) domains.push({ subdomain: data.location, domain: data.domain }); data.alternateDomains.forEach(function (a) { if ($scope.app.alternateDomains.some(function (d) { return d.domain === a.domain && d.subdomain === a.subdomain; })) return; domains.push({ subdomain: a.subdomain, domain: a.domain }); }); asyncForEach(domains, function (domain, callback) { if (overwriteDns) return callback(); Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) { if (error) return callback(error); if (result.error) { if (data.domain === domain.domain && data.location === domain.subdomain) { $scope.location.error.location = domain.domain + ' ' + result.error.message; } else { $scope.location.error.alternateDomains = domain.domain + ' ' + result.error.message; } $scope.location.busy = false; return; } if (result.needsOverwrite) $scope.location.domainCollisions.push(domain); callback(); }); }, function (error) { if (error) { $scope.location.busy = false; return Client.error(error); } if ($scope.location.domainCollisions.length) { $scope.location.busy = false; return $('#domainCollisionsModal').modal('show'); } Client.configureApp($scope.app.id, 'location', data, function (error) { if (error && (error.statusCode === 409 || error.statusCode === 400)) { if ((error.subdomain && error.domain) || error.field === 'location') { if (data.domain === error.domain && data.location === error.subdomain) { // the primary $scope.location.error.location = error.message; $scope.locationForm.$setPristine(); } else { $scope.location.error.alternateDomains = error.message; } } else if (error.portName || error.field === 'portBindings') { $scope.location.error.port = error.message; } $scope.location.busy = false; return; } if (error) return Client.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $scope.locationForm.$setPristine(); $timeout(function () { $scope.location.busy = false; }, 1000); }); }); }); } }; $scope.access = { busy: false, error: {}, success: false, ftp: false, ssoAuth: false, accessRestrictionOption: 'any', accessRestriction: { users: [], groups: [] }, isAccessRestrictionValid: function () { var tmp = $scope.access.accessRestriction; return !!(tmp.users.length || tmp.groups.length); }, show: function () { var app = $scope.app; $scope.access.error = {}; $scope.access.ftp = app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp; $scope.access.ssoAuth = (app.manifest.addons['ldap'] || app.manifest.addons['oauth']) && app.sso; $scope.access.accessRestrictionOption = app.accessRestriction ? 'groups' : 'any'; $scope.access.accessRestriction = { users: [], groups: [] }; if (app.accessRestriction) { var userSet = { }; app.accessRestriction.users.forEach(function (uid) { userSet[uid] = true; }); $scope.users.forEach(function (u) { if (userSet[u.id] === true) $scope.access.accessRestriction.users.push(u); }); var groupSet = { }; app.accessRestriction.groups.forEach(function (gid) { groupSet[gid] = true; }); $scope.groups.forEach(function (g) { if (groupSet[g.id] === true) $scope.access.accessRestriction.groups.push(g); }); } }, submit: function () { $scope.access.busy = true; $scope.access.error = {}; var accessRestriction = null; if ($scope.access.accessRestrictionOption === 'groups') { accessRestriction = { users: [], groups: [] }; accessRestriction.users = $scope.access.accessRestriction.users.map(function (u) { return u.id; }); accessRestriction.groups = $scope.access.accessRestriction.groups.map(function (g) { return g.id; }); } Client.configureApp($scope.app.id, 'access_restriction', { accessRestriction: accessRestriction }, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.access.success = true; $scope.access.busy = false; }, 1000); }); } }; $scope.resources = { busy: false, busyCpuShares: false, busyDataDir: false, error: {}, currentMemoryLimit: 0, memoryLimit: 0, memoryTicks: [], currentCpuShares: 0, cpuShares: 0, dataDir: null, show: function () { var app = $scope.app; $scope.resources.error = {}; $scope.resources.currentMemoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024); $scope.resources.memoryLimit = $scope.resources.currentMemoryLimit; $scope.resources.currentCpuShares = $scope.resources.cpuShares = app.cpuShares; $scope.resources.dataDir = app.dataDir; Client.memory(function (error, memory) { if (error) return console.error(error); // create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below) // TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates $scope.resources.memoryTicks = []; var npow2 = Math.pow(2, Math.ceil(Math.log(memory.memory)/Math.log(2))); for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) { if (i >= (app.manifest.memoryLimit/1024/1024 || 0)) $scope.resources.memoryTicks.push(i * 1024 * 1024); } if (app.manifest.memoryLimit && $scope.resources.memoryTicks[0] !== app.manifest.memoryLimit) { $scope.resources.memoryTicks.unshift(app.manifest.memoryLimit); } }); }, submitMemoryLimit: function () { $scope.resources.busy = true; $scope.resources.error = {}; var memoryLimit = $scope.resources.memoryLimit === $scope.resources.memoryTicks[0] ? 0 : $scope.resources.memoryLimit; Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit: memoryLimit }, function (error) { if (error) return Client.error(error); $scope.resources.currentMemoryLimit = $scope.resources.memoryLimit; refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.resources.busy = false; }, 1000); }); }); }, submitCpuShares: function () { $scope.resources.busyCpuShares = true; $scope.resources.error = {}; Client.configureApp($scope.app.id, 'cpu_shares', { cpuShares: $scope.resources.cpuShares }, function (error) { if (error) return Client.error(error); $scope.resources.currentCpuShares = $scope.resources.cpuShares; refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.resources.busyCpuShares = false; }, 1000); }); }); }, submitDataDir: function () { $scope.resources.busyDataDir = true; $scope.resources.error = {}; Client.configureApp($scope.app.id, 'data_dir', { dataDir: $scope.resources.dataDir || null }, function (error) { if (error && error.statusCode === 400) { $scope.resources.error.dataDir = error.message; $scope.resources.busyDataDir = false; return; } if (error) return Client.error(error); $scope.resourcesDataDirForm.$setPristine(); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.resources.busyDataDir = false; }, 1000); }); }); } }; $scope.email = { busy: false, error: {}, mailboxName: '', mailboxDomain: '', show: function () { var app = $scope.app; $scope.emailForm.$setPristine(); $scope.email.error = {}; $scope.email.mailboxName = app.mailboxName || ''; $scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === app.mailboxDomain; })[0]; }, submit: function () { $scope.email.error = {}; $scope.email.busy = true; Client.configureApp($scope.app.id, 'mailbox', { mailboxName: $scope.email.mailboxName || null, mailboxDomain: $scope.email.mailboxDomain.domain }, function (error) { if (error && error.statusCode === 400) { $scope.email.busy = false; $scope.email.error.mailboxName = error.message; $scope.emailForm.$setPristine(); return; } if (error) return Client.error(error); $scope.emailForm.$setPristine(); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); // when the mailboxName is 'reset', this will fill it up with the default again $scope.email.mailboxName = $scope.app.mailboxName || ''; $scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === $scope.app.mailboxDomain; })[0]; $timeout(function () { $scope.email.busy = false; }, 1000); }); }); } }; $scope.security = { busy: false, error: {}, success: false, robotsTxt: '', csp: '', show: function () { $scope.security.error = {}; $scope.security.robotsTxt = $scope.app.reverseProxyConfig.robotsTxt || ''; $scope.security.csp = $scope.app.reverseProxyConfig.csp || ''; }, submit: function () { $scope.security.busy = true; $scope.security.error = {}; var reverseProxyConfig = { robotsTxt: $scope.security.robotsTxt || null, // empty string resets csp: $scope.security.csp || null // empty string resets }; Client.configureApp($scope.app.id, 'reverse_proxy', reverseProxyConfig, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.security.success = true; $scope.security.busy = false; }, 1000); }); } }; $scope.updates = { busy: false, busyCheck: false, busyUpdate: false, skipBackup: false, enableAutomaticUpdate: false, show: function () { var app = $scope.app; $scope.updates.enableAutomaticUpdate = app.enableAutomaticUpdate; }, toggleAutomaticUpdates: function () { $scope.updates.busy = true; Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.updates.enableAutomaticUpdate }, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.updates.enableAutomaticUpdate = !$scope.updates.enableAutomaticUpdate; $scope.updates.busy = false; }, 1000); }); }, check: function () { $scope.updates.busyCheck = true; Client.checkForUpdates(function (error) { if (error) Client.error(error); $scope.updates.busyCheck = false; }); }, askUpdate: function () { $scope.updates.busyUpdate = false; $('#updateModal').modal('show'); }, confirmUpdate: function () { $scope.updates.busyUpdate = true; Client.updateApp($scope.app.id, $scope.config.update.apps[$scope.app.id].manifest, { skipBackup: $scope.updates.skipBackup }, function (error) { $scope.updates.busyUpdate = false; if (error) return Client.error(error); $('#updateModal').modal('hide'); refreshApp($scope.app.id); }); } }; $scope.backups = { busy: false, busyCreate: false, error: {}, copyBackupIdDone: false, enableBackup: false, backups: [], copyBackupId: function (backup) { var copyText = document.getElementById('backupIdHelper'); copyText.value = backup.id; copyText.select(); document.execCommand('copy'); $scope.backups.copyBackupIdDone = true; // reset after 2.5sec $timeout(function () { $scope.backups.copyBackupIdDone = false; }, 2500); }, createBackup: function () { $scope.backups.busyCreate = true; Client.backupApp($scope.app.id, function (error) { if (error) Client.error(error); refreshApp($scope.app.id, function () { $scope.backups.busyCreate = false; waitForAppTask(function (error) { if (error) return Client.error(error); $scope.backups.show(); // refresh backup listing }); }); }); }, show: function () { var app = $scope.app; $scope.backups.error = {}; $scope.backups.enableBackup = app.enableBackup; Client.getAppBackups(app.id, function (error, backups) { if (error) return Client.error(error); $scope.backups.backups = backups; }); }, toggleAutomaticBackups: function () { $scope.backups.busy = true; $scope.backups.error = {}; Client.configureApp($scope.app.id, 'automatic_backup', { enable: !$scope.backups.enableBackup }, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.backups.enableBackup = !$scope.backups.enableBackup; $scope.backups.busy = false; }, 1000); }); } }; $scope.s3like = function (provider) { return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos' || provider === 'digitalocean-spaces' || provider === 'scaleway-objectstorage' || provider === 'wasabi'; }; $scope.importBackup = { busy: false, error: {}, provider: '', bucket: '', prefix: '', accessKeyId: '', secretAccessKey: '', gcsKey: { keyFileName: '', content: '' }, region: '', endpoint: '', backupFolder: '', acceptSelfSignedCerts: false, format: 'tgz', backupId: '', key: '', clearForm: function () { $scope.importBackup.bucket = ''; $scope.importBackup.prefix = ''; $scope.importBackup.accessKeyId = ''; $scope.importBackup.secretAccessKey = ''; $scope.importBackup.gcsKey.keyFileName = ''; $scope.importBackup.gcsKey.content = ''; $scope.importBackup.endpoint = ''; $scope.importBackup.region = ''; $scope.importBackup.backupFolder = ''; $scope.importBackup.format = 'tgz'; $scope.importBackup.acceptSelfSignedCerts = false; $scope.importBackup.key = ''; $scope.importBackup.backupId = ''; }, submit: function () { $scope.importBackup.error = {}; $scope.importBackup.busy = true; var backupConfig = { provider: $scope.importBackup.provider, key: $scope.importBackup.key, }; var backupId = $scope.importBackup.backupId; // only set provider specific fields, this will clear them in the db if ($scope.s3like(backupConfig.provider)) { backupConfig.bucket = $scope.importBackup.bucket; backupConfig.prefix = $scope.importBackup.prefix; backupConfig.accessKeyId = $scope.importBackup.accessKeyId; backupConfig.secretAccessKey = $scope.importBackup.secretAccessKey; if ($scope.importBackup.endpoint) backupConfig.endpoint = $scope.importBackup.endpoint; if (backupConfig.provider === 's3') { if ($scope.importBackup.region) backupConfig.region = $scope.importBackup.region; delete backupConfig.endpoint; } else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') { backupConfig.region = 'us-east-1'; backupConfig.acceptSelfSignedCerts = $scope.importBackup.acceptSelfSignedCerts; } else if (backupConfig.provider === 'exoscale-sos') { backupConfig.region = 'us-east-1'; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'wasabi') { backupConfig.region = 'us-east-1'; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'scaleway-objectstorage') { backupConfig.region = $scope.scalewayRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'digitalocean-spaces') { backupConfig.region = 'us-east-1'; } } else if (backupConfig.provider === 'gcs') { backupConfig.bucket = $scope.importBackup.bucket; backupConfig.prefix = $scope.importBackup.prefix; try { var serviceAccountKey = JSON.parse($scope.importBackup.gcsKey.content); backupConfig.projectId = serviceAccountKey.project_id; backupConfig.credentials = { client_email: serviceAccountKey.client_email, private_key: serviceAccountKey.private_key }; if (!backupConfig.projectId || !backupConfig.credentials || !backupConfig.credentials.client_email || !backupConfig.credentials.private_key) { throw 'fields_missing'; } } catch (e) { $scope.importBackup.error.generic = 'Cannot parse Google Service Account Key: ' + e.message; $scope.importBackup.error.gcsKeyInput = true; $scope.importBackup.busy = false; return; } } else if (backupConfig.provider === 'filesystem') { var parts = backupId.split('/'); backupId = parts.pop() || parts.pop(); // removes any trailing slash. this is basename() backupConfig.backupFolder = parts.join('/'); // this is dirname() if ($scope.importBackup.format === 'tgz') { if (backupId.substring(backupId.length - '.tar.gz'.length, backupId.length) === '.tar.gz') { // endsWith('tgz') backupId = backupId.replace(/.tar.gz$/, ''); } } } Client.importBackup($scope.app.id, backupId, $scope.importBackup.format, backupConfig, function (error) { if (error) { $scope.importBackup.busy = false; if (error.statusCode === 424) { $scope.importBackup.error.generic = error.message; if (error.message.indexOf('AWS Access Key Id') !== -1) { $scope.importBackup.error.accessKeyId = true; $scope.importBackupForm.accessKeyId.$setPristine(); $('#inputImportBackupAccessKeyId').focus(); } else if (error.message.indexOf('not match the signature') !== -1 || error.message.indexOf('Signature') !== -1) { $scope.importBackup.error.secretAccessKey = true; $scope.importBackupForm.secretAccessKey.$setPristine(); $('#inputImportBackupSecretAccessKey').focus(); } else if (error.message.toLowerCase() === 'access denied') { $scope.importBackup.error.accessKeyId = true; $scope.importBackupForm.accessKeyId.$setPristine(); $('#inputImportBackupBucket').focus(); } else if (error.message.indexOf('ECONNREFUSED') !== -1) { $scope.importBackup.error.generic = 'Unknown region'; $scope.importBackup.error.region = true; $scope.importBackupForm.region.$setPristine(); $('#inputImportBackupDORegion').focus(); } else if (error.message.toLowerCase() === 'wrong region') { $scope.importBackup.error.generic = 'Wrong S3 Region'; $scope.importBackup.error.region = true; $scope.importBackupForm.region.$setPristine(); $('#inputImportBackupS3Region').focus(); } else { $scope.importBackup.error.bucket = true; $('#inputImportBackupBucket').focus(); $scope.importBackupForm.bucket.$setPristine(); } } else if (error.statusCode === 400) { $scope.importBackup.error.generic = error.message; if ($scope.importBackup.provider === 'filesystem') { $scope.importBackup.error.backupFolder = true; } } else { Client.error(error); } return; } $('#importBackupModal').modal('hide'); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.importBackup.busy = false; }, 1000); }); }); }, show: function () { $scope.importBackup.clearForm(); $('#importBackupModal').modal('show'); } }; $scope.uninstall = { busy: false, error: {}, show: function () { $scope.uninstall.error = {}; }, ask: function () { $('#uninstallModal').modal('show'); }, submit: function () { $scope.uninstall.busy = true; var NOOP = function (next) { return next(); }; var stopAppTask = $scope.app.taskId ? Client.stopTask.bind(null, $scope.app.taskId) : NOOP; stopAppTask(function () { // ignore error Client.uninstallApp($scope.app.id, function (error) { if (error && error.statusCode === 402) { // unpurchase failed Client.error('Relogin to Cloudron App Store'); } else if (error) { Client.error(error); } else { $('#uninstallModal').modal('hide'); Client.refreshAppCache($scope.app.id, function() {}); // reflect the new app state immediately $location.path('/apps'); } $scope.uninstall.busy = false; }); }); } }; $scope.console = { show: function () {}, busyRunState: false, startButton: false, toggleRunState: function () { var func = $scope.app.runState === RSTATES.STOPPED ? Client.startApp : Client.stopApp; $scope.console.busyRunState = true; func($scope.app.id, function (error) { if (error) return Client.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.console.busyRunState = false; }, 1000); }); }); } }; $scope.restore = { busy: false, error: {}, backup: null, show: function (backup) { $scope.restore.error = {}; $scope.restore.backup = backup; $('#restoreModal').modal('show'); }, submit: function () { $scope.restore.busy = true; Client.restoreApp($scope.app.id, $scope.restore.backup.id, function (error) { if (error) { Client.error(error); $scope.restore.busy = false; return; } $('#restoreModal').modal('hide'); refreshApp($scope.app.id); }); } }; $scope.clone = { busy: false, error: {}, backup: null, location: '', domain: null, portBindings: {}, portBindingsInfo: {}, portBindingsEnabled: {}, show: function (backup) { var app = $scope.app; $scope.clone.error = {}; $scope.clone.backup = backup; $scope.clone.domain = $scope.domains.find(function (d) { return app.domain === d.domain; }); // pre-select the app's domain $scope.clone.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information // set default ports for (var env in $scope.clone.portBindingsInfo) { $scope.clone.portBindings[env] = $scope.clone.portBindingsInfo[env].defaultValue || 0; $scope.clone.portBindingsEnabled[env] = true; } $('#cloneModal').modal('show'); }, submit: function () { $scope.clone.busy = true; // only use enabled ports from portBindings var finalPortBindings = {}; for (var env in $scope.clone.portBindings) { if ($scope.clone.portBindingsEnabled[env]) { finalPortBindings[env] = $scope.clone.portBindings[env]; } } var data = { location: $scope.clone.location, domain: $scope.clone.domain.domain, portBindings: finalPortBindings, backupId: $scope.clone.backup.id }; Client.checkDNSRecords(data.domain, data.location, function (error, result) { if (error) { Client.error(error); $scope.clone.busy = false; return; } if (result.error) { if (result.error.reason === ERROR.ACCESS_DENIED) { $scope.clone.error.location = 'DNS credentials for ' + data.domain + ' are invalid. Update it in Domains & Certs view'; } else { $scope.clone.error.location = result.error.message; } $scope.clone.needsOverwrite = true; $scope.clone.busy = false; return; } if (result.needsOverwrite) { $scope.clone.error.location = 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron'; $scope.clone.needsOverwrite = true; $scope.clone.busy = false; return; } Client.cloneApp($scope.app.id, data, function (error/*, clonedApp */) { $scope.clone.busy = false; if (error) { if (error.statusCode === 409) { if (error.portName) { $scope.clone.error.port = error.message; } else if (error.domain) { $scope.clone.error.location = 'This location is already taken.'; $('#cloneLocationInput').focus(); } else { Client.error(error); } } else { Client.error(error); } return; } $('#cloneModal').modal('hide'); $location.path('/apps'); }); }); } }; $scope.repair = { retryBusy: false, error: {}, location: null, domain: null, alternateDomains: [], backups: [], backupId: '', show: function () {}, // this prepares the repair dialog with whatever is required for repair action confirm: function () { $scope.repair.error = {}; $scope.repair.retryBusy = false; $scope.repair.location = null; $scope.repair.domain = null; $scope.repair.alternateDomains = []; $scope.repair.backupId = ''; var app = $scope.app; var errorState = ($scope.app.error && $scope.app.error.installationState) || ISTATES.PENDING_CONFIGURE; if (errorState === ISTATES.PENDING_LOCATION_CHANGE) { $scope.repair.location = app.location; $scope.repair.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0]; $scope.repair.alternateDomains = $scope.app.alternateDomains; $scope.repair.alternateDomains = $scope.app.alternateDomains.map(function (altDomain) { return { subdomain: altDomain.subdomain, enabled: true, domain: $scope.domains.filter(function (d) { return d.domain === altDomain.domain; })[0] }; }); } if (errorState === ISTATES.PENDING_RESTORE) { Client.getAppBackups($scope.app.id, function (error, backups) { if (error) return Client.error(error); $scope.repair.backups = backups; $scope.repair.backupId = ''; $('#repairModal').modal('show'); }); return; } $('#repairModal').modal('show'); }, submit: function () { $scope.repair.error = {}; $scope.repair.retryBusy = true; var errorState = ($scope.app.error && $scope.app.error.installationState) || ISTATES.PENDING_CONFIGURE; var data = {}; var repairFunc; switch (errorState) { case ISTATES.PENDING_INSTALL: case ISTATES.PENDING_CLONE: // if manifest or bad image, use CLI to provide new manifest repairFunc = Client.repairApp.bind(null, $scope.app.id, {}); // this will trigger a re-install break; case ISTATES.PENDING_LOCATION_CHANGE: data.location = $scope.repair.location; data.domain = $scope.repair.domain.domain; data.alternateDomains = $scope.repair.alternateDomains.filter(function (a) { return a.enabled; }) .map(function (d) { return { subdomain: d.subdomain, domain: d.domain.domain }; }); data.overwriteDns = true; // always overwriteDns. user can anyway check and uncheck above repairFunc = Client.configureApp.bind(null, $scope.app.id, 'location', data); break; case ISTATES.PENDING_DATA_DIR_MIGRATION: repairFunc = Client.configureApp.bind(null, $scope.app.id, 'data_dir', { dataDir: null }); break; // this also happens for import faliures. this UI can only show backup listing. use CLI for arbit id/config case ISTATES.PENDING_RESTORE: repairFunc = Client.restoreApp.bind(null, $scope.app.id, $scope.repair.backupId); break; case ISTATES.PENDING_UNINSTALL: repairFunc = Client.uninstallApp.bind(null, $scope.app.id); break; case ISTATES.PENDING_START: case ISTATES.PENDING_STOP: case ISTATES.PENDING_RESTART: case ISTATES.PENDING_RESIZE: case ISTATES.PENDING_DEBUG: case ISTATES.PENDING_RECREATE_CONTAINER: case ISTATES.PENDING_CONFIGURE: case ISTATES.PENDING_BACKUP: // can happen if the backup task was killed/rebooted case ISTATES.PENDING_UPDATE: // when update failed, just bring it back to current state and user can click update again default: repairFunc = Client.repairApp.bind(null, $scope.app.id, {}); break; } repairFunc(function (error) { if (error) return Client.error(error); $scope.repair.retryBusy = false; $('#repairModal').modal('hide'); }); }, stopAppTask: function (taskId) { Client.stopTask(taskId, function (error) { // we can ignore a call trying to cancel an already done task if (error && error.statusCode !== 409) Client.error(error); }); }, restartBusy: false, restartApp: function () { $scope.repair.restartBusy = true; Client.restartApp($scope.app.id, function (error) { if (error) return console.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.repair.restartBusy = false; }, 1000); }); }); }, pauseBusy: false, pauseAppBegin: function () { $scope.repair.pauseBusy = true; Client.debugApp($scope.app.id, true, function (error) { if (error) return console.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.repair.pauseBusy = false; }, 1000); }); }); }, pauseAppDone: function () { $scope.repair.pauseBusy = true; Client.debugApp($scope.app.id, false, function (error) { if (error) return console.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.repair.pauseBusy = false; }, 1000); }); }); } }; $scope.postInstallConfirm = { message: '', confirmed: false, show: function () { $scope.postInstallConfirm.message = $scope.app.manifest.postInstallMessage; $scope.postInstallConfirm.confirmed = false; $('#postInstallConfirmModal').modal('show'); return false; // prevent propagation and default }, submit: function () { if (!$scope.postInstallConfirm.confirmed) return; $scope.app.pendingPostInstallConfirmation = false; delete localStorage['confirmPostInstall_' + $scope.app.id]; $('#postInstallConfirmModal').modal('hide'); } }; function fetchUsers(callback) { Client.getUsers(function (error, users) { if (error) return callback(error); // ensure we have something to work with in the access restriction dropdowns users.forEach(function (user) { user.display = user.username || user.email; }); $scope.users = users; callback(); }); } function fetchGroups(callback) { Client.getGroups(function (error, groups) { if (error) return callback(error); $scope.groups = groups; callback(); }); } function getDomains(callback) { Client.getDomains(function (error, result) { if (error) return callback(error); $scope.domains = result; callback(); }); } function getBackupConfig(callback) { Client.getBackupConfig(function (error, backupConfig) { if (error) return callback(error); $scope.backupEnabled = backupConfig.provider !== 'noop'; callback(); }); } function refreshApp(appId, callback) { callback = callback || function () {}; Client.getApp(appId, function (error, app) { if (error && error.statusCode === 404) return $location.path('/apps'); if (error) return callback(error); // ensure we have amended progress properties set before copy if ($scope.app) { app.taskProgress = $scope.app.taskProgress; app.taskProgressMessage = $scope.app.taskProgressMessage; } $scope.app = app; // show 'Start App' if app is starting or is stopped if (app.installationState === ISTATES.PENDING_START || app.installationState === ISTATES.PENDING_STOP) { $scope.console.startButton = app.installationState === ISTATES.PENDING_START; } else { $scope.console.startButton = app.runState === RSTATES.STOPPED; } if (app.taskId) { Client.getTask(app.taskId, function (error, task) { if (error) return callback(error); $scope.app.taskProgress = task && task.percent ? task.percent : 5; // start with 5 to avoid empty progress bar $scope.app.taskProgressMessage = task ? task.message : ''; callback(); }); } else { $scope.app.taskProgress = 0; $scope.app.taskProgressMessage = ''; callback(); } }); } function waitForAppTask(callback) { callback = callback || function () {}; if (!$scope.app.taskId) return callback(); // app will be refreshed on interval $timeout(waitForAppTask.bind(null, callback), 2000); // not yet done } Client.onReady(function () { refreshApp(appId, function (error) { if (error) return Client.error(error); // skipViewShow because we don't have all the values like domains/users to init the view yet if ($routeParams.view) { // explicit route in url bar $scope.setView($routeParams.view, true /* skipViewShow */); } else { // default $scope.setView($scope.app.error ? 'repair' : 'display', true /* skipViewShow */); } asyncSeries([ fetchUsers, fetchGroups, getDomains, getBackupConfig ], function (error) { if (error) return Client.error(error); $scope[$scope.view].show(); // initialize now that we have all the values var refreshTimer = $interval(function () { refreshApp($scope.app.id); }, 2000); // call with inline function to avoid iteration argument passed see $interval docs $scope.$on('$destroy', function () { $interval.cancel(refreshTimer); }); }); }); }); $('#iconFileInput').get(0).onchange = function (event) { var fr = new FileReader(); fr.onload = function () { $scope.$apply(function () { // var file = event.target.files[0]; $scope.display.icon.data = fr.result; }); }; fr.readAsDataURL(event.target.files[0]); }; // setup all the dialog focus handling ['appUninstallModal', 'appUpdateModal', 'appRestoreModal'].forEach(function (id) { $('#' + id).on('shown.bs.modal', function () { $(this).find('[autofocus]:first').focus(); }); }); $('.modal-backdrop').remove(); }]);