'use strict'; /* global angular */ /* global $ */ /* global async */ /* global RSTATES */ /* global ISTATES */ /* global ERROR */ /* global Chart */ /* global Clipboard */ /* global SECRET_PLACEHOLDER */ /* global APP_TYPES, STORAGE_PROVIDERS, BACKUP_FORMATS */ /* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR, REGIONS_HETZNER */ /* global onAppClick */ angular.module('Application').controller('AppController', ['$scope', '$location', '$translate', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $interval, $route, $routeParams, Client) { $scope.s3Regions = REGIONS_S3; $scope.wasabiRegions = REGIONS_WASABI; $scope.doSpacesRegions = REGIONS_DIGITALOCEAN; $scope.exoscaleSosRegions = REGIONS_EXOSCALE; $scope.scalewayRegions = REGIONS_SCALEWAY; $scope.linodeRegions = REGIONS_LINODE; $scope.ovhRegions = REGIONS_OVH; $scope.ionosRegions = REGIONS_IONOS; $scope.upcloudRegions = REGIONS_UPCLOUD; $scope.vultrRegions = REGIONS_VULTR; $scope.contaboRegions = REGIONS_VULTR; $scope.hetznerRegions = REGIONS_HETZNER; $scope.storageProviders = STORAGE_PROVIDERS; $scope.formats = BACKUP_FORMATS; // 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(); // note: these variables will remain empty for operators $scope.domains = []; $scope.volumes = []; $scope.groups = []; $scope.users = []; $scope.backupConfig = null; $scope.diskUsage = -1; $scope.diskUsageDate = 0; $scope.APP_TYPES = APP_TYPES; $scope.HOST_PORT_MIN = 1; $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.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); }); }; $scope.appPostInstallConfirm = { app: {}, message: '', show: function (app) { $scope.appPostInstallConfirm.app = app; $scope.appPostInstallConfirm.message = app.manifest.postInstallMessage; $('#appPostInstallConfirmModal').modal('show'); return false; // prevent propagation and default }, submit: function () { $scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false; delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id]; $('#appPostInstallConfirmModal').modal('hide'); } }; $scope.postInstallMessage = { openApp: false, show: function (openApp) { $scope.postInstallMessage.openApp = !!openApp; if (!$scope.app.manifest.postInstallMessage) return; $('#postInstallModal').modal('show'); }, submit: function () { $scope.app.pendingPostInstallConfirmation = false; delete localStorage['confirmPostInstall_' + $scope.app.id]; $('#postInstallModal').modal('hide'); } }; $scope.getAppBackupDownloadLink = function (backup) { return Client.getAppBackupDownloadLink($scope.app.id, backup.id); }; $scope.onAppClick = function (app, $event) { onAppClick(app, $event, true /* always operator */, $scope); }; $scope.sftpInfo = { show: function () { $('#sftpInfoModal').modal('show'); } }; $scope.info = { showDoneChecklist: false, hasOldChecklist: false, notes: { busy: true, busySave: false, editing: false, content: '', placeholder: 'Add admin notes here...', edit: function () { $scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes; $scope.info.notes.editing = true; setTimeout(function () { document.getElementById('adminNotesTextarea').focus(); }, 1); }, dismiss: function () { $scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes; $scope.info.notes.editing = false; }, submit: function () { $scope.info.notes.busySave = true; // skip saving if unchanged from postInstall if ($scope.info.notes.content === $scope.app.manifest.postInstallMessage) { $scope.info.notes.busySave = false; $scope.info.notes.editing = false; return; } Client.configureApp($scope.app.id, 'notes', { notes: $scope.info.notes.content }, function (error) { if (error) return console.error('Failed to save notes.', error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes; $scope.info.notes.busySave = false; $scope.info.notes.editing = false; }); }); } }, show: function () { $scope.info.hasOldChecklist = !!Object.keys($scope.app.checklist).find((k) => { return $scope.app.checklist[k].acknowledged; }); $scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes; $scope.info.notes.editing = false; $scope.info.notes.busy = false; }, checklistAck(item, key) { item.acknowledged = true; // item.acknowledged = !item.acknowledged; Client.ackAppChecklistItem($scope.app.id, key, item.acknowledged, function (error) { if (error) return console.error('Failed to ack checklist item.', error); $scope.info.hasOldChecklist = true; refreshApp($scope.app.id); }); } }; $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, // object and not the string subdomain: '', secondaryDomains: {}, redirectDomains: [], aliasDomains: [], ports: {}, portsEnabled: {}, portInfo: {}, addRedirectDomain: function (event) { event.preventDefault(); $scope.location.redirectDomains.push({ domain: $scope.domains.filter(function (d) { return d.domain === $scope.app.domain; })[0], // pre-select app's domain by default subdomain: '' }); setTimeout(function () { document.getElementById('redirectDomainsInput-' + ($scope.location.redirectDomains.length-1)).focus(); }, 200); }, delRedirectDomain: function (event, index) { event.preventDefault(); $scope.location.redirectDomains.splice(index, 1); }, addAliasDomain: function (event) { event.preventDefault(); $scope.location.aliasDomains.push({ domain: $scope.domains.filter(function (d) { return d.domain === $scope.app.domain; })[0], // pre-select app's domain by default subdomain: '' }); setTimeout(function () { document.getElementById('aliasDomainsInput-' + ($scope.location.aliasDomains.length-1)).focus(); }, 200); }, delAliasDomain: function (event, index) { event.preventDefault(); $scope.location.aliasDomains.splice(index, 1); }, show: function () { var app = $scope.app; $scope.location.error = {}; $scope.location.domainCollisions = []; $scope.location.subdomain = app.subdomain; $scope.location.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0]; // for compat, secondary domain can be empty after an upgrade. so it may not exist in app.secondaryDomains $scope.location.secondaryDomains = {}; var httpPorts = app.manifest.httpPorts || {}; for (var env2 in httpPorts) { $scope.location.secondaryDomains[env2] = { subdomain: httpPorts[env2].defaultValue || '', domain: $scope.location.domain }; } // now fill secondaryDomains with real values, if it exists app.secondaryDomains.forEach(function (sd) { $scope.location.secondaryDomains[sd.environmentVariable] = { subdomain: sd.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === sd.domain; })[0] }; }); $scope.location.portInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information $scope.location.redirectDomains = app.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };}); $scope.location.aliasDomains = app.aliasDomains.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.portInfo) { if (app.portBindings && app.portBindings[env]) { $scope.location.ports[env] = app.portBindings[env].hostPort; $scope.location.portsEnabled[env] = true; } else { $scope.location.ports[env] = $scope.location.portInfo[env].defaultValue || 0; $scope.location.portsEnabled[env] = false; } } }, submit: function (overwriteDns) { $('#domainCollisionsModal').modal('hide'); $scope.location.busy = true; $scope.location.error = {}; $scope.location.domainCollisions = []; var secondaryDomains = {}; for (var env2 in $scope.location.secondaryDomains) { secondaryDomains[env2] = { subdomain: $scope.location.secondaryDomains[env2].subdomain, domain: $scope.location.secondaryDomains[env2].domain.domain }; } // only use enabled ports var ports = {}; for (var env in $scope.location.ports) { if ($scope.location.portsEnabled[env]) { ports[env] = $scope.location.ports[env]; } } var data = { overwriteDns: !!overwriteDns, subdomain: $scope.location.subdomain, domain: $scope.location.domain.domain, ports: ports, secondaryDomains: secondaryDomains, redirectDomains: $scope.location.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}), aliasDomains: $scope.location.aliasDomains.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.subdomain !== data.subdomain) domains.push({ subdomain: data.subdomain, domain: data.domain, type: 'primary' }); Object.keys(data.secondaryDomains).forEach(function (env) { var subdomain = data.secondaryDomains[env].subdomain, domain = data.secondaryDomains[env].domain; if ($scope.app.secondaryDomains.some(function (d) { return d.domain === domain && d.subdomain === subdomain; })) return; domains.push({ subdomain: subdomain, domain: domain, type: 'secondary' }); }); data.redirectDomains.forEach(function (a) { if ($scope.app.redirectDomains.some(function (d) { return d.domain === a.domain && d.subdomain === a.subdomain; })) return; domains.push({ subdomain: a.subdomain, domain: a.domain, type: 'redirect' }); }); data.aliasDomains.forEach(function (a) { if ($scope.app.aliasDomains.some(function (d) { return d.domain === a.domain && d.subdomain === a.subdomain; })) return; domains.push({ subdomain: a.subdomain, domain: a.domain, type: 'alias' }); }); var canConfigure = true; async.eachSeries(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 (domain.type === 'primary') { $scope.location.error.location = domain.domain + ' ' + result.error.message; } else if (domain.type === 'alias') { $scope.location.error.aliasDomains = domain.domain + ' ' + result.error.message; } else { $scope.location.error.redirectDomains = domain.domain + ' ' + result.error.message; } $scope.location.busy = false; canConfigure = false; } else if (result.needsOverwrite) { $scope.location.domainCollisions.push(domain); canConfigure = false; } callback(); }); }, function (error) { if (error) { $scope.location.busy = false; return Client.error(error); } if (!canConfigure) { $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)) { var errorMessage = error.message.toLowerCase(); if (errorMessage.indexOf('location') !== -1) { if (errorMessage.indexOf('primary') !== -1) { $scope.location.error.location = error.message; $scope.locationForm.$setPristine(); } else if (errorMessage.indexOf('secondary') !== -1) { $scope.location.error.secondaryDomain = error.message; } else if (errorMessage.indexOf('redirect') !== -1) { $scope.location.error.redirectDomains = error.message; } else if (errorMessage.indexOf('alias') !== -1) { $scope.location.error.aliasDomains = error.message; } } else if (errorMessage.indexOf('port') !== -1) { $scope.location.error.port = error.message; } else { $scope.location.error.location = error.message; // fallback } $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', accessRestrictionOptionCur: 'any', accessRestriction: { users: [], groups: [] }, operators: { 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['oidc'] || app.manifest.addons['proxyAuth']) && app.sso; $scope.access.accessRestrictionOption = app.accessRestriction ? 'groups' : 'any'; $scope.access.accessRestrictionOptionCur = app.accessRestriction ? 'groups' : 'any'; $scope.access.accessRestriction = { users: [], groups: [] }; $scope.access.operators = { users: [], groups: [] }; var userSet, groupSet; if (app.accessRestriction) { 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); }); groupSet = {}; if (app.accessRestriction.groups) 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); }); } if (app.operators) { userSet = {}; app.operators.users.forEach(function (uid) { userSet[uid] = true; }); $scope.users.forEach(function (u) { if (userSet[u.id] === true) $scope.access.operators.users.push(u); }); groupSet = {}; if (app.operators.groups) app.operators.groups.forEach(function (gid) { groupSet[gid] = true; }); $scope.groups.forEach(function (g) { if (groupSet[g.id] === true) $scope.access.operators.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; }); } var operators = null; if ($scope.access.operators.users.length || $scope.access.operators.groups.length) { operators = { users: [], groups: [] }; operators.users = $scope.access.operators.users.map(function (u) { return u.id; }); operators.groups = $scope.access.operators.groups.map(function (g) { return g.id; }); } async.series([ function (callback) { if ($scope.access.accessRestrictionOption === $scope.access.accessRestrictionOptionCur && !$scope.accessForm.accessUsersSelect.$dirty && !$scope.accessForm.accessGroupsSelect.$dirty) return callback(); Client.configureApp($scope.app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback); }, function (callback) { if (!$scope.accessForm.operatorsUsersSelect.$dirty && !$scope.accessForm.operatorsGroupsSelect.$dirty) return callback(); Client.configureApp($scope.app.id, 'operators', { operators: operators }, callback); } ], function (error) { if (error) return Client.error(error); $scope.accessForm.$setPristine(); $scope.access.accessRestrictionOptionCur = $scope.access.accessRestrictionOption; $timeout(function () { $scope.access.success = true; $scope.access.busy = false; }, 3000); }); } }; $scope.resources = { error: {}, busy: false, currentMemoryLimit: 0, memoryLimit: 0, // RAM memoryTicks: [], currentCpuQuota: 0, cpuQuota: 0, devices: '', show: function () { var app = $scope.app; $scope.resources.busy = true; $scope.resources.error = {}; $scope.resources.currentMemoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024); Client.memory(function (error, result) { if (error) console.error(error); // create ticks starting from manifest memory limit. the memory limit here is just RAM $scope.resources.memoryTicks = []; // we max system memory and current app memory for the case where the user configured the app on another server with more resources var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024; var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024); // code below ensure we atleast have 2 ticks to keep the slider usable $scope.resources.memoryTicks.push(startTick); // start tick for (var i = startTick * 2; i < nearest256m; i *= 2) { $scope.resources.memoryTicks.push(i); } $scope.resources.memoryTicks.push(nearest256m); // end tick }); // for firefox widget update $timeout(function() { $scope.resources.currentCpuQuota = $scope.resources.cpuQuota = app.cpuQuota; $scope.resources.memoryLimit = $scope.resources.currentMemoryLimit; $scope.resources.busy = false; }, 500); $scope.resources.devices = Object.keys(app.devices).join(', '); }, submitMemoryLimit: function () { $scope.resources.busy = true; $scope.resources.error = {}; const tmp = parseInt($scope.resources.memoryLimit); const memoryLimit = tmp === $scope.resources.memoryTicks[0] ? 0 : tmp; Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit }, function (error) { if (error && error.statusCode === 400) { $scope.resources.busy = false; $scope.resources.error.memoryLimit = true; return; } 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); }); }); }, submitCpuQuota: function () { $scope.resources.busy = true; $scope.resources.error = {}; Client.configureApp($scope.app.id, 'cpu_quota', { cpuQuota: parseInt($scope.resources.cpuQuota) }, function (error) { if (error) return Client.error(error); $scope.resources.currentCpuQuota = $scope.resources.cpuQuota; refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.resources.busy = false; }, 1000); }); }); }, submitDevices: function () { $scope.resources.busy = true; $scope.resources.error = {}; const devices = {}; $scope.resources.devices.split(',').forEach(d => { if (!d.trim()) return; devices[d.trim()] = {}; }); Client.configureApp($scope.app.id, 'devices', { devices }, function (error) { if (error && error.statusCode === 400) { $scope.resources.error.devices = error.message; return $scope.resources.busy = false; } else if (error) { return Client.error(error); } refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.resources.busy = false; }, 1000); }); }); }, }; $scope.services = { error: {}, busy: false, enableTurn: '1', // curse of radio buttons enableRedis: '1', show: function () { var app = $scope.app; $scope.services.error = {}; $scope.services.enableTurn = app.enableTurn ? '1' : '0'; $scope.services.enableRedis = app.enableRedis ? '1' : '0'; }, submitTurn: function () { $scope.services.busy = true; $scope.services.error = {}; Client.configureApp($scope.app.id, 'turn', { enable: $scope.services.enableTurn === '1' }, function (error) { if (error && error.statusCode === 400) { $scope.services.busy = false; $scope.services.error.turn = true; return; } if (error) return Client.error(error); $timeout(function () { $scope.services.busy = false; }, 1000); }); }, submitRedis: function () { $scope.services.busy = true; $scope.services.error = {}; Client.configureApp($scope.app.id, 'redis', { enable: $scope.services.enableRedis === '1' }, function (error) { if (error && error.statusCode === 400) { $scope.services.busy = false; $scope.services.error.redis = true; return; } if (error) return Client.error(error); $timeout(function () { $scope.services.busy = false; }, 1000); }); }, }; $scope.storage = { error: {}, busy: false, busyDataDir: false, storageVolumeId: null, storageVolumePrefix: '', location: null, locationOptions: [], busyBinds: false, mounts: [], // { volume, readOnly } show: function () { var app = $scope.app; $scope.storage.error = {}; $scope.storage.storageVolumeId = app.storageVolumeId; $scope.storage.storageVolumePrefix = app.storageVolumePrefix || ''; $scope.storage.mounts = []; $scope.storage.locationOptions = [ { id: 'default', type: 'default', displayName: 'Default - /home/yellowtent/appsdata/' + app.id }, ]; $scope.volumes.forEach(function (volume) { $scope.storage.locationOptions.push({ id: volume.id, type: 'volume', value: volume.id, displayName: 'Volume - ' + volume.name, mountType: volume.mountType }); }); $scope.storage.location = $scope.storage.locationOptions.find(function (l) { return l.id === (app.storageVolumeId || 'default'); }); app.mounts.forEach(function (mount) { // { volumeId, readOnly } var volume = $scope.volumes.find(function (v) { return v.id === mount.volumeId; }); $scope.storage.mounts.push({ volume: volume, readOnly: mount.readOnly ? 'true' : 'false' }); }); }, submitDataDir: function () { $scope.storage.busyDataDir = true; $scope.storage.error = {}; var data = { storageVolumeId: null, storageVolumePrefix: null }; if ($scope.storage.location.id !== 'default') { data.storageVolumeId = $scope.storage.location.id; data.storageVolumePrefix = $scope.storage.storageVolumePrefix; } Client.configureApp($scope.app.id, 'storage', data, function (error) { if (error && error.statusCode === 400) { $scope.storage.error.storageVolumePrefix = error.message; $scope.storage.busyDataDir = false; return; } else if (error) { Client.error(error); $scope.storage.busyDataDir = false; return; } $scope.storageDataDirForm.$setPristine(); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.storage.busyDataDir = false; }, 1000); }); }); }, addMount: function (event) { event.preventDefault(); $scope.storage.mounts.push({ volume: $scope.volumes[0], readOnly: true }); }, delMount: function (event, index) { event.preventDefault(); $scope.storage.mounts.splice(index, 1); }, submitMounts: function () { $scope.storage.busyMounts = true; $scope.storage.error = {}; var data = []; $scope.storage.mounts.forEach(function (mount) { data.push({ volumeId: mount.volume.id, readOnly: mount.readOnly === 'true' }); }); Client.configureApp($scope.app.id, 'mounts', { mounts: data }, function (error) { if (error && error.statusCode === 400) { $scope.storage.error.mounts = error.message; $scope.storage.busyMounts = false; return; } if (error) return Client.error(error); refreshApp($scope.app.id, function (error) { if (error) return Client.error(error); $timeout(function () { $scope.storage.busyMounts = false; }, 1000); }); }); } }; $scope.graphs = { error: {}, busy: true, period: 6, memoryChart: null, diskChart: null, blockReadTotal: 0, blockWriteTotal: 0, networkReadTotal: 0, networkWriteTotal: 0, setPeriod: function (hours) { $scope.graphs.period = hours; $scope.graphs.show(); }, show: function () { $scope.graphs.busy = true; // in minutes var timePeriod = $scope.graphs.period * 60; // keep in sync with graphs.js var timeBucketSizeMinutes = timePeriod > (24 * 60) ? (6*60) : 5; var steps = Math.floor(timePeriod/timeBucketSizeMinutes); var labels = new Array(steps).fill(0); labels = labels.map(function (v, index) { var dateTime = new Date(Date.now() - ((timePeriod - (index * timeBucketSizeMinutes)) * 60 * 1000)); if ($scope.graphs.period > 24) { return dateTime.toLocaleDateString(); } else { return dateTime.toLocaleTimeString(); } }); var borderColors = [ '#2196F3', '#FF6384' ]; var backgroundColors = [ '#82C4F844', '#FF63844F' ]; function fillGraph(canvasId, contents, chartPropertyName, divisor, max, format, formatDivisor, stepSize) { if (!contents || !contents[0]) return; // no data available yet var datasets = []; contents.forEach(function (content, index) { // fill holes with previous value var cur = 0; content.data.forEach(function (d) { if (d[0] === null) d[0] = cur; else cur = d[0]; }); var datapoints = Array(steps).map(function () { return '0'; }); // walk backwards and fill up the datapoints content.data.reverse().forEach(function (d, index) { datapoints[datapoints.length-1-index] = (d[0] / divisor).toFixed(2); // return parseInt((d[0] / divisor).toFixed(2)); }); datasets.push({ label: content.label, backgroundColor: backgroundColors[index], borderColor: borderColors[index], borderWidth: 1, radius: 0, data: datapoints, cubicInterpolationMode: 'monotone', tension: 0.4 }); }); var graphData = { labels: labels, datasets: datasets }; var options = { responsive: true, maintainAspectRatio: true, aspectRatio: 2.5, animation: false, plugins: { legend: { display: false } }, interaction: { intersect: false, mode: 'index', }, scales: { x: { ticks: { autoSkipPadding: 50, maxRotation: 0 } }, y: { ticks: { maxTicksLimit: 6 }, min: 0, beginAtZero: true } } }; if (format) options.scales.y.ticks.callback = function (value) { if (!formatDivisor) return value + ' ' + format; return (value/formatDivisor).toLocaleString('en-US', { maximumFractionDigits: 6 }) + ' ' + format; }; if (max) options.scales.y.max = max; if (stepSize) options.scales.y.ticks.stepSize = stepSize; var ctx = $(canvasId).get(0).getContext('2d'); if ($scope.graphs[chartPropertyName]) $scope.graphs[chartPropertyName].destroy(); $scope.graphs[chartPropertyName] = new Chart(ctx, { type: 'line', data: graphData, options: options }); } Client.getAppGraphs(appId, timePeriod, function (error, result) { if (error) return console.error(error); var currentMemoryLimit = $scope.app.memoryLimit || $scope.app.manifest.memoryLimit || 0; var maxGraphMemory = currentMemoryLimit < (512 * 1024 * 1024) ? (512 * 1024 * 1024) : currentMemoryLimit; var cpuCount = result.cpuCount; var ioDivisor = 1000 * 1000; $scope.graphs.blockReadTotal = (result.blockReadTotal / ioDivisor / 1000).toFixed(2) + ' MB'; $scope.graphs.blockWriteTotal = (result.blockWriteTotal / ioDivisor / 1000).toFixed(2) + ' MB'; $scope.graphs.networkReadTotal = (result.networkReadTotal / ioDivisor / 1000).toFixed(2) + ' MB'; $scope.graphs.networkWriteTotal = (result.networkWriteTotal / ioDivisor / 1000).toFixed(2) + ' MB'; fillGraph('#graphsMemoryChart', [{ data: result.memory, label: 'Memory' }], 'memoryChart', 1024 * 1024, maxGraphMemory / 1024 / 1024, 'GiB', 1024, (maxGraphMemory / 1024 / 1024) <= 1024 ? 256 : 512); fillGraph('#graphsCpuChart', [{ data: result.cpu, label: 'CPU' }], 'cpuChart', 1, cpuCount * 100, '%'); fillGraph('#graphsDiskChart', [{ data: result.blockRead, label: 'read' }, { data: result.blockWrite, label: 'write' }], 'diskChart', ioDivisor, null, 'kB/s'); fillGraph('#graphsNetworkChart', [{ data: result.networkRead, label: 'inbound' }, { data: result.networkWrite, label: 'outbound' }], 'networkChart', ioDivisor, null, 'kB/s'); $scope.graphs.busy = false; }); } }; function findInbox(inboxes, app) { return inboxes.find(function (i) { return i.name === app.inboxName && i.domain === (app.inboxDomain || app.domain); }); } $scope.email = { enableMailbox: true, mailboxName: '', mailboxDomain: null, mailboxDisplayName: '', currentMailboxName: '', currentMailboxDomainName: '', mailboxError: {}, mailboxBusy: false, inboxError: {}, inboxBusy: false, enableInbox: true, inboxes: [], currentInbox: null, inbox: null, show: function () { var app = $scope.app; $scope.emailForm.$setPristine(); $scope.email.mailboxError = {}; $scope.email.enableMailbox = app.enableMailbox ? '1' : '0'; $scope.email.mailboxName = app.mailboxName || ''; $scope.email.mailboxDisplayName = app.mailboxDisplayName || ''; $scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === (app.mailboxDomain || app.domain); })[0]; $scope.email.currentMailboxName = app.mailboxName || ''; $scope.email.currentMailboxDomainName = $scope.email.mailboxDomain ? $scope.email.mailboxDomain.domain : ''; $scope.email.inboxError = {}; $scope.email.enableInbox = app.enableInbox ? true : false; Client.getAllMailboxes(function (error, mailboxes) { if (error) console.error('Failed to list mailboxes.', error); $scope.email.inboxes = mailboxes.map(function (m) { return { display: m.name + '@' + m.domain, name: m.name, domain: m.domain }; }); $scope.email.currentInbox = findInbox($scope.email.inboxes, app); $scope.email.inbox = findInbox($scope.email.inboxes, app); }); }, submitMailbox: function () { $scope.email.error = {}; $scope.email.mailboxBusy = true; var data = { enable: $scope.email.enableMailbox === '1' }; if (data.enable) { data.mailboxName = $scope.email.mailboxName || null; data.mailboxDomain = $scope.email.mailboxDomain.domain; data.mailboxDisplayName = $scope.email.mailboxDisplayName; } Client.configureApp($scope.app.id, 'mailbox', data, function (error) { if (error && error.statusCode === 400) { $scope.email.mailboxBusy = 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.enableMailbox = $scope.app.enableMailbox ? '1' : '0'; $scope.email.mailboxName = $scope.app.mailboxName || ''; $scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === ($scope.app.mailboxDomain || $scope.app.domain); })[0]; $scope.email.currentMailboxName = $scope.app.mailboxName || ''; $scope.email.currentMailboxDomainName = $scope.email.mailboxDomain ? $scope.email.mailboxDomain.domain : ''; $timeout(function () { $scope.email.mailboxBusy = false; }, 1000); }); }); }, submitInbox: function () { $scope.email.error = {}; $scope.email.inboxBusy = true; var data = { enable: $scope.email.enableInbox }; if (data.enable) { data.inboxName = $scope.email.inbox.name; data.inboxDomain = $scope.email.inbox.domain; } Client.configureApp($scope.app.id, 'inbox', data, function (error) { if (error && error.statusCode === 400) { $scope.email.inboxBusy = false; $scope.email.error.inboxName = error.message; return; } if (error) return Client.error(error); 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.enableInbox = $scope.app.enableInbox ? true : false; $scope.email.currentInbox = findInbox($scope.email.inboxes, $scope.app); $scope.email.inbox = findInbox($scope.email.inboxes, $scope.app); $timeout(function () { $scope.email.inboxBusy = false; }, 1000); }); }); } }; $scope.eventlog = { busy: false, eventLogs: [], activeEventLog: null, currentPage: 1, perPage: 15, show: function () { $scope.eventlog.refresh(); }, refresh: function () { $scope.eventlog.busy = true; Client.getAppEventLog($scope.app.id, $scope.eventlog.currentPage, $scope.eventlog.perPage, function (error, result) { if (error) return console.error('Failed to get events:', error); $scope.eventlog.eventLogs = []; result.forEach(function (e) { $scope.eventlog.eventLogs.push({ raw: e, details: Client.eventLogDetails(e, $scope.app.id), source: Client.eventLogSource(e) }); }); $scope.eventlog.busy = false; }); }, showDetails: function (eventLog) { if ($scope.eventlog.activeEventLog === eventLog) $scope.eventlog.activeEventLog = null; else $scope.eventlog.activeEventLog = eventLog; }, showNextPage: function () { $scope.eventlog.currentPage++; $scope.eventlog.refresh(); }, showPrevPage: function () { if ($scope.eventlog.currentPage > 1) $scope.eventlog.currentPage--; else $scope.eventlog.currentPage = 1; $scope.eventlog.refresh(); } }; $scope.cron = { busy: false, error: {}, commonPatterns: [ { value: '* * * * *', label: $translate.instant('app.cron.commonPattern.everyMinute') }, { value: '0 * * * *', label: $translate.instant('app.cron.commonPattern.everyHour') }, { value: '*/30 * * * *', label: $translate.instant('app.cron.commonPattern.twicePerHour') }, { value: '0 0 * * *', label: $translate.instant('app.cron.commonPattern.everyDay') }, { value: '0 */12 * * *', label: $translate.instant('app.cron.commonPattern.twicePerDay') }, { value: '0 0 * * 0', label: $translate.instant('app.cron.commonPattern.everySunday') }, { value: '@daily', label: $translate.instant('app.cron.commonPattern.daily') }, { value: '@hourly', label: $translate.instant('app.cron.commonPattern.hourly') }, { value: '@service', label: $translate.instant('app.cron.commonPattern.service') } ], crontab: '', crontabDefault: '' + '# +------------------------ minute (0 - 59)\n' + '# | +------------------- hour (0 - 23)\n' + '# | | +-------------- day of month (1 - 31)\n' + '# | | | +--------- month (1 - 12)\n' + '# | | | | +---- day of week (0 - 6) (Sunday=0 or 7)\n' + '# | | | | |\n' + '# * * * * * command to be executed\n\n', show: function () { $scope.cronForm.$setPristine(); $scope.cron.error = {}; $scope.cron.crontab = $scope.app.crontab; if ($scope.cron.crontab === null) $scope.cron.crontab = $scope.cron.crontabDefault; // only when null, not when '' }, submit: function () { $scope.cron.error = {}; $scope.cron.busy = true; Client.configureApp($scope.app.id, 'crontab', { crontab: $scope.cron.crontab }, function (error) { if (error && error.statusCode === 400) { $scope.cron.busy = false; $scope.cron.error.crontab = error.message; $scope.cronForm.$setPristine(); return; } if (error) return Client.error(error); $scope.cronForm.$setPristine(); $timeout(function () { $scope.cron.busy = false; }, 1000); }); }, addCommonPattern: function (pattern) { $scope.cron.crontab += pattern + ' /path/to/command\n'; } }; $scope.security = { busy: false, error: {}, success: false, robotsTxt: '', csp: '', hstsPreload: false, show: function () { $scope.security.error = {}; $scope.security.robotsTxt = $scope.app.reverseProxyConfig.robotsTxt || ''; $scope.security.csp = $scope.app.reverseProxyConfig.csp || ''; $scope.security.hstsPreload = $scope.app.reverseProxyConfig.hstsPreload || false; }, 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 hstsPreload: $scope.security.hstsPreload }; 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.proxy = { busy: false, error: null, success: false, upstreamUri: '', show: function () { $scope.proxyForm.$setPristine(); $scope.proxy.error = null; $scope.proxy.upstreamUri = $scope.app.upstreamUri || ''; }, submit: function () { $scope.proxy.busy = true; $scope.proxy.error = null; var upstreamUri = $scope.proxy.upstreamUri.replace(/\/$/, ''); Client.configureApp($scope.app.id, 'upstream_uri', { upstreamUri: upstreamUri }, function (error) { $scope.proxy.busy = false; if (error && error.statusCode === 400) { $scope.proxy.error = error.message; $scope.proxyForm.$setPristine(); return; } if (error) return Client.error(error); $scope.proxyForm.$setPristine(); $timeout(function () { $scope.proxy.success = true; }, 1000); }); } }; $scope.updates = { busy: false, busyCheck: false, busyUpdate: false, busyAutomaticUpdates: false, skipBackup: false, enableAutomaticUpdate: true, show: function () { $scope.updates.skipBackup = false; $scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate; }, toggleAutomaticUpdates: function () { $scope.updates.busyAutomaticUpdates = true; Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.updates.enableAutomaticUpdate }, function (error) { if (error) return Client.error(error); refreshApp($scope.app.id, function (error) { if (error) console.error(error); $timeout(function () { console.log($scope.updates.enableAutomaticUpdate, $scope.app.enableAutomaticUpdate); $scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate; $scope.updates.busyAutomaticUpdates = false; }, 2000); }); }); }, check: function () { $scope.updates.busyCheck = true; Client.checkForAppUpdates($scope.app.id, 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[$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.backupDetails = { backup: null, show: function (backup) { $scope.backupDetails.backup = backup; $('#backupDetailsModal').modal('show'); } }; $scope.backups = { busy: false, busyCreate: false, busyAutomaticBackups: false, error: {}, enableBackup: false, backups: [], 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; Client.getAppEventLog(app.id, 1, 1, function (error, result) { if (error) return console.error('Failed to get events:', error); if (result.length !== 0 && result[0].action == 'app.backup.finish') { $scope.backups.error.message = result[0].data.errorMessage; } }); }); }, refresh: function () { Client.getAppBackups($scope.app.id, function (error, backups) { if (error) return Client.error(error); $scope.backups.backups = backups; }); }, toggleAutomaticBackups: function () { $scope.backups.busyAutomaticBackups = 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.busyAutomaticBackups = false; }, 1000); }); } }; $scope.s3like = function (provider) { return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos' || provider === 'digitalocean-spaces' || provider === 'hetzner-objectstorage' || provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2' || provider === 'contabo-objectstorage'; }; $scope.mountlike = function (provider) { return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs'; }; $scope.importBackup = { busy: false, error: {}, // variables here have to match the import config logic! provider: '', bucket: '', prefix: '', mountPoint: '', // for mountpoint accessKeyId: '', secretAccessKey: '', gcsKey: { keyFileName: '', content: '' }, region: '', endpoint: '', acceptSelfSignedCerts: false, format: 'tgz', remotePath: '', password: '', encryptedFilenames: true, mountOptions: { host: '', remoteDir: '', username: '', password: '', diskPath: '', user: '', seal: true, port: 22, privateKey: '' }, encrypted: false, // helps with ng-required when backupConfig is read from file clearForm: function () { // $scope.importBackup.provider = ''; // do not clear since we call this function on provider change $scope.importBackup.bucket = ''; $scope.importBackup.mountPoint = ''; $scope.importBackup.accessKeyId = ''; $scope.importBackup.secretAccessKey = ''; $scope.importBackup.gcsKey.keyFileName = ''; $scope.importBackup.gcsKey.content = ''; $scope.importBackup.endpoint = ''; $scope.importBackup.region = ''; $scope.importBackup.format = 'tgz'; $scope.importBackup.acceptSelfSignedCerts = false; $scope.importBackup.password = ''; $scope.importBackup.encryptedFilenames = true; $scope.importBackup.remotePath = ''; $scope.importBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' }; }, submit: function () { $scope.importBackup.error = {}; $scope.importBackup.busy = true; var backupConfig = { provider: $scope.importBackup.provider, }; if ($scope.importBackup.password) { backupConfig.password = $scope.importBackup.password; backupConfig.encryptedFilenames = $scope.importBackup.encryptedFilenames; } var remotePath = $scope.importBackup.remotePath; // 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 = backupConfig.region || 'us-east-1'; backupConfig.acceptSelfSignedCerts = $scope.importBackup.acceptSelfSignedCerts; backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI } else if (backupConfig.provider === 'exoscale-sos') { backupConfig.region = 'us-east-1'; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'wasabi') { backupConfig.region = $scope.wasabiRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; 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 === 'linode-objectstorage') { backupConfig.region = $scope.linodeRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'ovh-objectstorage') { backupConfig.region = $scope.ovhRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'ionos-objectstorage') { backupConfig.region = $scope.ionosRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'vultr-objectstorage') { backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'contabo-objectstorage') { backupConfig.region = $scope.contaboRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region; backupConfig.signatureVersion = 'v4'; backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets) } else if (backupConfig.provider === 'upcloud-objectstorage') { var m = /^.*\.(.*)\.upcloudobjects.com$/.exec(backupConfig.endpoint); backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid backupConfig.signatureVersion = 'v4'; } else if (backupConfig.provider === 'digitalocean-spaces') { backupConfig.region = 'us-east-1'; } else if (backupConfig.provider === 'hetzner-objectstorage') { backupConfig.region = 'us-east-1'; backupConfig.signatureVersion = 'v4'; } } 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 === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') { backupConfig.mountOptions = $scope.importBackup.mountOptions; backupConfig.prefix = $scope.importBackup.prefix; } else if (backupConfig.provider === 'mountpoint') { backupConfig.prefix = $scope.importBackup.prefix; backupConfig.mountPoint = $scope.importBackup.mountPoint; } else if (backupConfig.provider === 'filesystem') { var parts = remotePath.split('/'); remotePath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename() backupConfig.backupFolder = parts.join('/'); // this is dirname() } if ($scope.importBackup.format === 'tgz') { if (remotePath.substring(remotePath.length - '.tar.gz'.length, remotePath.length) === '.tar.gz') { // endsWith remotePath = remotePath.replace(/.tar.gz$/, ''); } else if (remotePath.substring(remotePath.length - '.tar.gz.enc'.length, remotePath.length) === '.tar.gz.enc') { // endsWith remotePath = remotePath.replace(/.tar.gz.enc$/, ''); } } Client.importBackup($scope.app.id, remotePath, $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'); // clear potential post-install flag $scope.app.pendingPostInstallConfirmation = false; delete localStorage['confirmPostInstall_' + $scope.app.id]; 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.editBackup = { busy: false, error: null, backup: null, label: '', persist: false, show: function (backup) { $scope.editBackup.backup = backup; $scope.editBackup.label = backup.label; $scope.editBackup.persist = backup.preserveSecs === -1; $scope.editBackup.error = null; $scope.editBackup.busy = false; $('#editBackupModal').modal('show'); }, submit: function () { $scope.editBackup.error = null; $scope.editBackup.busy = true; Client.editAppBackup($scope.app.id, $scope.editBackup.backup.id, $scope.editBackup.label, $scope.editBackup.persist ? -1 : 0, function (error) { $scope.editBackup.busy = false; if (error) return $scope.editBackup.error = error.message; $scope.backups.refresh(); $('#editBackupModal').modal('hide'); }); } }; $scope.backupDetails = { backup: null, show: function (backup) { $scope.backupDetails.backup = backup; $('#backupDetailsModal').modal('show'); } }; $scope.uninstall = { busy: false, error: {}, busyRunState: false, startButton: false, latestBackup: null, toggleRunState: function (confirmStop) { if (confirmStop && $scope.app.runState !== RSTATES.STOPPED) { $('#stopModal').modal('show'); return; } $('#stopModal').modal('hide'); var func = $scope.app.runState === RSTATES.STOPPED ? Client.startApp : Client.stopApp; $scope.uninstall.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.uninstall.busyRunState = false; }, 1000); }); }); }, show: function () { $scope.uninstall.error = {}; $scope.uninstall.latestBackup = null; Client.getAppBackups($scope.app.id, function (error, backups) { if (!error && backups.length) $scope.uninstall.latestBackup = backups[0]; }); }, ask: function (what) { if (what === 'uninstall') { $('#uninstallModal').modal('show'); } else { $('#archiveModal').modal('show'); } }, submit: function (what) { $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 const func = what === 'uninstall' ? Client.uninstallApp.bind(null, $scope.app.id) : Client.archiveApp.bind(Client, $scope.app.id, $scope.uninstall.latestBackup.id); func(function (error) { if (error && error.statusCode === 402) { // unpurchase failed Client.error('Relogin to Cloudron App Store'); } else if (error) { Client.error(error); } else { if (what === 'uninstall') { $('#uninstallModal').modal('hide'); } else { $('#archiveModal').modal('hide'); } $location.path('/apps'); } $scope.uninstall.busy = false; }); }); } }; $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) { $scope.restore.busy = false; if (error) { Client.error(error); return; } $('#restoreModal').modal('hide'); refreshApp($scope.app.id); }); } }; $scope.clone = { busy: false, error: {}, backup: null, subdomain: '', domain: null, secondaryDomains: {}, needsOverwrite: false, overwriteDns: false, ports: {}, portsEnabled: {}, portInfo: {}, 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.needsOverwrite = false; $scope.clone.overwriteDns = false; $scope.clone.secondaryDomains = {}; var httpPorts = backup.manifest.httpPorts || {}; for (var env2 in httpPorts) { $scope.clone.secondaryDomains[env2] = { subdomain: httpPorts[env2].defaultValue || '', domain: $scope.clone.domain }; } $scope.clone.portInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information // set default ports for (var env in $scope.clone.portInfo) { $scope.clone.ports[env] = $scope.clone.portInfo[env].defaultValue || 0; $scope.clone.portsEnabled[env] = true; } $('#appCloneModal').modal('show'); }, submit: function () { $scope.clone.busy = true; var secondaryDomains = {}; for (var env2 in $scope.clone.secondaryDomains) { secondaryDomains[env2] = { subdomain: $scope.clone.secondaryDomains[env2].subdomain, domain: $scope.clone.secondaryDomains[env2].domain.domain }; } // only use enabled ports var finalPorts = {}; for (var env in $scope.clone.ports) { if ($scope.clone.portsEnabled[env]) { finalPorts[env] = $scope.clone.ports[env]; } } var data = { subdomain: $scope.clone.subdomain, domain: $scope.clone.domain.domain, secondaryDomains: secondaryDomains, ports: finalPorts, backupId: $scope.clone.backup.id, overwriteDns: $scope.clone.overwriteDns }; var allDomains = [{ domain: data.domain, subdomain: data.subdomain }].concat(Object.keys(secondaryDomains).map(function (k) { return { domain: secondaryDomains[k].domain, subdomain: secondaryDomains[k].subdomain }; })); async.eachSeries(allDomains, function (domain, callback) { if ($scope.clone.overwriteDns) return callback(); Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) { if (error) return callback(error); var fqdn = domain.subdomain + '.' + domain.domain; if (result.error) { if (result.error.reason === ERROR.ACCESS_DENIED) return callback({ type: 'provider', fqdn: fqdn, message: 'DNS credentials for ' + domain.domain + ' are invalid. Update it in Domains & Certs view' }); return callback({ type: 'provider', fqdn: fqdn, message: result.error.message }); } if (result.needsOverwrite) { $scope.clone.needsOverwrite = true; $scope.clone.overwriteDns = true; return callback({ type: 'externally_exists', fqdn: fqdn, message: 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron' }); } callback(); }); }, function (error) { if (error) { if (error.type) { $scope.clone.error.location = error; $scope.clone.busy = false; } else { Client.error(error); } $scope.clone.error.location = error; $scope.clone.busy = false; return; } Client.cloneApp($scope.app.id, data, function (error/*, clonedApp */) { $scope.clone.busy = false; if (error) { var errorMessage = error.message.toLowerCase(); if (errorMessage.indexOf('port') !== -1) { $scope.clone.error.port = error.message; } else if (error.message.indexOf('location') !== -1 || error.message.indexOf('subdomain') !== -1) { // TODO extract fqdn from error message, currently we just set it always to the main location $scope.clone.error.location = { type: 'internally_exists', fqdn: data.subdomain + '.' + data.domain, message: error.message }; $('#cloneLocationInput').focus(); } else { Client.error(error); } return; } $('#appCloneModal').modal('hide'); $location.path('/apps'); }); }); } }; $scope.repair = { retryBusy: false, error: {}, subdomain: null, domain: null, redirectDomains: [], aliasDomains: [], 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.subdomain = null; $scope.repair.domain = null; $scope.repair.redirectDomains = []; $scope.repair.aliasDomains = []; $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.subdomain = app.subdomain; $scope.repair.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0]; $scope.repair.aliasDomains = $scope.app.aliasDomains; $scope.repair.aliasDomains = $scope.app.aliasDomains.map(function (aliasDomain) { return { subdomain: aliasDomain.subdomain, enabled: true, domain: $scope.domains.filter(function (d) { return d.domain === aliasDomain.domain; })[0] }; }); $scope.repair.redirectDomains = $scope.app.redirectDomains; $scope.repair.redirectDomains = $scope.app.redirectDomains.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 || errorState === ISTATES.PENDING_IMPORT) { 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.subdomain = $scope.repair.subdomain; data.domain = $scope.repair.domain.domain; data.aliasDomains = $scope.repair.aliasDomains.filter(function (a) { return a.enabled; }) .map(function (d) { return { subdomain: d.subdomain, domain: d.domain.domain }; }); data.redirectDomains = $scope.repair.redirectDomains.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, 'storage', { storageVolumeId: null, storageVolumePrefix: 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: case ISTATES.PENDING_IMPORT: if ($scope.repair.backups.length === 0) { // this can happen when you give some invalid backup via CLI and restore via UI repairFunc = Client.repairApp.bind(null, $scope.app.id, {}); // this will trigger a re-install } else { 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) { $scope.repair.retryBusy = false; if (error) return Client.error(error); $scope.repair.retryBusy = false; $('#repairModal').modal('hide'); }); }, 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); }); }); } }; function fetchUsers(callback) { Client.getAllUsers(function (error, users) { if (error) return callback(error); $scope.users = users; callback(); }); } function fetchGroups(callback) { Client.getGroups(function (error, groups) { if (error) return callback(error); $scope.groups = groups; callback(); }); } function fetchDiskUsage(callback) { $scope.diskUsage = -1; $scope.diskUsageDate = 0; Client.diskUsage(function (error, result) { if (error) return callback(error); if (!result.usage) return callback(); // no usage date yet $scope.diskUsageDate = result.usage.ts; for (var diskName in result.usage.disks) { var disk = result.usage.disks[diskName]; var content = disk.contents.find(function (c) { return c.id === appId; }); if (content) { $scope.diskUsage = content.usage; break; } } callback(); }); } function getDomains(callback) { Client.getDomains(function (error, result) { if (error) return callback(error); $scope.domains = result; callback(); }); } function getVolumes(callback) { Client.getVolumes(function (error, result) { if (error) return callback(error); $scope.volumes = result; callback(); }); } function getBackupConfig(callback) { Client.getBackupConfig(function (error, backupConfig) { if (error) return callback(error); $scope.backupConfig = backupConfig; callback(); }); } function refreshApp(appId, callback) { callback = callback || function () {}; Client.getAppWithTask(appId, function (error, app) { if (error && error.statusCode === 404) return $location.path('/apps'); if (error) return callback(error); $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.uninstall.startButton = app.installationState === ISTATES.PENDING_START; } else { $scope.uninstall.startButton = app.runState === RSTATES.STOPPED; } 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 } // https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server#18197341 function download(filename, text) { var element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } $scope.downloadConfig = function (backup) { // secrets and tokens already come with placeholder characters we remove them var tmp = { remotePath: backup.remotePath, encrypted: !!$scope.backupConfig.password // we add this just to help the import UI }; Object.keys($scope.backupConfig).forEach(function (k) { var v = $scope.backupConfig[k]; if (v && typeof v === 'object') { // to hide mountOptions.password and the likes tmp[k] = {}; Object.keys(v).forEach(function (j) { if (v[j] !== SECRET_PLACEHOLDER) tmp[k][j] = v[j]; }); } else { if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = v; } }); const filename = `${$scope.app.fqdn}-backup-config-${(new Date(backup.creationTime)).toISOString().split('T')[0]}.json`; download(filename, JSON.stringify(tmp, null, 4)); }; document.getElementById('backupConfigFileInput').onchange = function (event) { var reader = new FileReader(); reader.onload = function (result) { if (!result.target || !result.target.result) return console.error('Unable to read backup config'); var backupConfig; try { backupConfig = JSON.parse(result.target.result); if (backupConfig.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path backupConfig.remotePath = backupConfig.backupFolder + '/' + backupConfig.remotePath; delete backupConfig.backupFolder; } } catch (e) { console.error('Unable to parse backup config', e); return; } $scope.$apply(function () { // we assume property names match here, this does not yet work for gcs keys Object.keys(backupConfig).forEach(function (k) { if (k in $scope.importBackup) { $scope.importBackup[k] = backupConfig[k]; } }); }); }; reader.readAsText(event.target.files[0]); }; Client.onReady(function () { refreshApp(appId, function (error) { if (error) return Client.error(error); if ($scope.app.accessLevel !== 'admin' && $scope.app.accessLevel !== 'operator') return $location.path('/'); // 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' : 'info', true /* skipViewShow */); } function done() { $scope[$scope.view].show(); // initialize now that we have all the values var refreshTimer = $interval(function () { refreshApp($scope.app.id); }, 5000); // call with inline function to avoid iteration argument passed see $interval docs $scope.$on('$destroy', function () { $interval.cancel(refreshTimer); }); } if ($scope.app.accessLevel !== 'admin') return done(); async.series([ fetchUsers, fetchGroups, fetchDiskUsage, getDomains, getVolumes, getBackupConfig ], function (error) { if (error) return Client.error(error); // check for updates, if the app has a pending update. this handles two cases: // 1. user got a valid subscription. this will make the updates get the manifest field // 2. user has not refreshed the ui in a while or updated via cli tool. this will ensure we are not holding to a dangling update if ($scope.config.update[$scope.app.id]) Client.checkForUpdates(); done(); }); }); }); $('#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', 'appCloneModal', 'editBackupModal'].forEach(function (id) { $('#' + id).on('shown.bs.modal', function () { $(this).find('[autofocus]:first').focus(); }); }); var clipboard = new Clipboard('.clipboard'); clipboard.on('success', function () { $scope.$apply(function () { $scope.copyBackupIdDone = true; }); $timeout(function () { $scope.copyBackupIdDone = false; }, 5000); }); $('.modal-backdrop').remove(); }]);