'use strict'; /* global angular:false */ /* global $:false */ /* global ERROR */ /* global RSTATES */ /* global moment */ angular.module('Application').controller('AppStoreController', ['$scope', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $location, $timeout, $routeParams, Client) { Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); }); $scope.HOST_PORT_MIN = 1024; $scope.HOST_PORT_MAX = 65535; $scope.ready = false; $scope.apps = []; $scope.config = Client.getConfig(); $scope.user = Client.getUserInfo(); $scope.users = []; $scope.groups = []; $scope.domains = []; $scope.category = ''; $scope.cachedCategory = ''; // used to cache the selected category while searching $scope.searchString = ''; $scope.validSubscription = false; $scope.unstableApps = false; $scope.subscription = {}; $scope.memory = null; // { memory, swap } $scope.showView = function (view) { // wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already $('.modal').on('hidden.bs.modal', function () { $scope.$apply(function () { $scope.appInstall.reset(); $('.modal').off('hidden.bs.modal'); $location.path(view); }); }); $('.modal').modal('hide'); }; $scope.categories = [ { id: 'analytics', icon: 'fa fa-chart-line', label: 'Analytics'}, { id: 'blog', icon: 'fa fa-font', label: 'Blog'}, { id: 'chat', icon: 'fa fa-comments', label: 'Chat'}, { id: 'git', icon: 'fa fa-code-branch', label: 'Code Hosting'}, { id: 'crm', icon: 'fab fa-connectdevelop', label: 'CRM'}, { id: 'document', icon: 'fa fa-file-word', label: 'Documents'}, { id: 'email', icon: 'fa fa-envelope', label: 'Email'}, { id: 'sync', icon: 'fa fa-sync-alt', label: 'File Sync'}, { id: 'finance', icon: 'fa fa-hand-holding-usd', label: 'Finance'}, { id: 'forum', icon: 'fa fa-users', label: 'Forum'}, { id: 'gallery', icon: 'fa fa-images', label: 'Gallery'}, { id: 'game', icon: 'fa fa-gamepad', label: 'Games'}, { id: 'notes', icon: 'fa fa-sticky-note', label: 'Notes'}, { id: 'project', icon: 'fas fa-project-diagram', label: 'Project Management'}, { id: 'vpn', icon: 'fa fa-user-secret', label: 'VPN'}, { id: 'hosting', icon: 'fa fa-server', label: 'Web Hosting'}, { id: 'wiki', icon: 'fab fa-wikipedia-w', label: 'Wiki'}, ]; $scope.categoryButtonLabel = function (category) { if (category === 'new') return 'Category'; if (category === 'recent') return 'Category'; if (category === 'featured') return 'Category'; var tmp = $scope.categories.find(function (c) { return c.id === category; }); if (tmp) return tmp.label; return 'Category'; }; $scope.appInstall = { busy: false, state: 'appInfo', error: {}, app: {}, needsOverwrite: false, location: '', domain: null, portBindings: {}, mediaLinks: [], certificateFile: null, certificateFileName: '', keyFile: null, keyFileName: '', accessRestrictionOption: 'any', accessRestriction: { users: [], groups: [] }, customAuth: false, optionalSso: false, subscriptionErrorMesssage: '', isAccessRestrictionValid: function () { var tmp = $scope.appInstall.accessRestriction; return !!(tmp.users.length || tmp.groups.length); }, reset: function () { $scope.appInstall.app = {}; $scope.appInstall.error = {}; $scope.appInstall.needsOverwrite = false; $scope.appInstall.location = ''; $scope.appInstall.domain = null; $scope.appInstall.portBindings = {}; $scope.appInstall.state = 'appInfo'; $scope.appInstall.mediaLinks = []; $scope.appInstall.certificateFile = null; $scope.appInstall.certificateFileName = ''; $scope.appInstall.keyFile = null; $scope.appInstall.keyFileName = ''; $scope.appInstall.accessRestrictionOption = 'any'; $scope.appInstall.accessRestriction = { users: [], groups: [] }; $scope.appInstall.optionalSso = false; $scope.appInstall.customAuth = false; $scope.appInstall.subscriptionErrorMesssage = ''; $('#collapseInstallForm').collapse('hide'); $('#collapseResourceConstraint').collapse('hide'); $('#collapseSubscriptionRequired').collapse('hide'); $('#collapseMediaLinksCarousel').collapse('show'); if ($scope.appInstallForm) { $scope.appInstallForm.$setPristine(); $scope.appInstallForm.$setUntouched(); } }, showForm: function (force) { var app = $scope.appInstall.app; var DEFAULT_MEMORY_LIMIT = 1024 * 1024 * 256; var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM+Swap var used = Client.getInstalledApps().reduce(function (prev, cur) { if (cur.runState === RSTATES.STOPPED) return prev; return prev + (cur.memoryLimit || cur.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT); }, 0); var totalMemory = ($scope.memory.memory + $scope.memory.swap) * 1.5; var available = (totalMemory || 0) - used; var enoughResourcesAvailable = (available - needed) >= 0; if (enoughResourcesAvailable || force) { $scope.appInstall.state = 'installForm'; $('#collapseMediaLinksCarousel').collapse('hide'); $('#collapseResourceConstraint').collapse('hide'); $('#collapseInstallForm').collapse('show'); $('#appInstallLocationInput').focus(); } else { $scope.appInstall.state = 'resourceConstraint'; $('#collapseMediaLinksCarousel').collapse('hide'); $('#collapseResourceConstraint').collapse('show'); } }, show: function (app) { // this is an appstore app object! $scope.appInstall.reset(); // make a copy to work with in case the app object gets updated while polling angular.copy(app, $scope.appInstall.app); $scope.appInstall.mediaLinks = $scope.appInstall.app.manifest.mediaLinks || []; $scope.appInstall.domain = $scope.domains.find(function (d) { return $scope.config.adminDomain === d.domain; }); // pre-select the adminDomain $scope.appInstall.portBindingsInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information $scope.appInstall.portBindings = {}; // This is the actual model holding the env:port pair $scope.appInstall.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag var manifest = app.manifest; $scope.appInstall.optionalSso = !!manifest.optionalSso; $scope.appInstall.customAuth = !(manifest.addons['ldap'] || manifest.addons['oauth']); $scope.appInstall.accessRestrictionOption = 'any'; $scope.appInstall.accessRestriction = { users: [], groups: [] }; // set default ports var allPorts = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); for (var env in allPorts) { $scope.appInstall.portBindings[env] = allPorts[env].defaultValue || 0; $scope.appInstall.portBindingsEnabled[env] = true; } $('#appInstallModal').modal('show'); }, submit: function () { $scope.appInstall.busy = true; $scope.appInstall.error.other = null; $scope.appInstall.error.location = null; $scope.appInstall.error.port = null; // only use enabled ports from portBindings var finalPortBindings = {}; for (var env in $scope.appInstall.portBindings) { if ($scope.appInstall.portBindingsEnabled[env]) { finalPortBindings[env] = $scope.appInstall.portBindings[env]; } } var finalAccessRestriction = null; if ($scope.appInstall.accessRestrictionOption === 'groups') { finalAccessRestriction = { users: [], groups: [] }; finalAccessRestriction.users = $scope.appInstall.accessRestriction.users.map(function (u) { return u.id; }); finalAccessRestriction.groups = $scope.appInstall.accessRestriction.groups.map(function (g) { return g.id; }); } var data = { overwriteDns: $scope.appInstall.needsOverwrite, location: $scope.appInstall.location || '', domain: $scope.appInstall.domain.domain, portBindings: finalPortBindings, accessRestriction: finalAccessRestriction, cert: $scope.appInstall.certificateFile, key: $scope.appInstall.keyFile, sso: !$scope.appInstall.optionalSso ? undefined : ($scope.appInstall.accessRestrictionOption !== 'nosso') }; Client.checkDNSRecords(data.domain, data.location, function (error, result) { if (error) return Client.error(error); if (!data.overwriteDns) { if (result.error || result.needsOverwrite) { if (result.error) { if (result.error.reason === ERROR.ACCESS_DENIED) { $scope.appInstall.error.location = 'DNS credentials for ' + data.domain + ' are invalid. Update it in Domains & Certs view'; } else { $scope.appInstall.error.location = result.error.message; } } else { $scope.appInstall.error.location = 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron'; $scope.appInstall.needsOverwrite = true; } $scope.appInstall.busy = false; $scope.appInstallForm.location.$setPristine(); $('#appInstallLocationInput').focus(); return; } } Client.installApp($scope.appInstall.app.id, $scope.appInstall.app.manifest, $scope.appInstall.app.title, data, function (error, newAppId) { if (error) { if (error.statusCode === 402) { $scope.appInstall.state = 'subscriptionRequired'; $scope.appInstall.subscriptionErrorMesssage = error.message; $('#collapseMediaLinksCarousel').collapse('hide'); $('#collapseResourceConstraint').collapse('hide'); $('#collapseInstallForm').collapse('hide'); $('#collapseSubscriptionRequired').collapse('show'); } else if (error.statusCode === 409) { if (error.portName) { $scope.appInstall.error.port = error.message; } else if (error.domain) { $scope.appInstall.error.location = error.message; $scope.appInstallForm.location.$setPristine(); $('#appInstallLocationInput').focus(); } else { $scope.appInstall.error.other = error.message; } } else if (error.statusCode === 400) { if (error.field === 'cert') { $scope.appInstall.error.cert = error.message; $scope.appInstall.certificateFileName = ''; $scope.appInstall.certificateFile = null; $scope.appInstall.keyFileName = ''; $scope.appInstall.keyFile = null; } else { $scope.appInstall.error.other = error.message; } } else { $scope.appInstall.error.other = error.message; } $scope.appInstall.busy = false; return; } $scope.appInstall.busy = false; // stash new app id for later $scope.appInstall.app.id = newAppId; // we track the postinstall confirmation for the current user's browser // TODO later we might want to have a notification db to track the state across admins and browsers if ($scope.appInstall.app.manifest.postInstallMessage) { localStorage['confirmPostInstall_' + $scope.appInstall.app.id] = true; } // wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already $('#appInstallModal').on('hidden.bs.modal', function () { $scope.$apply(function () { $location.path('/apps').search({ }); }); }); $('#appInstallModal').modal('hide'); }); }); } }; $scope.appNotFound = { appId: '', version: '' }; $scope.appstoreLogin = { busy: false, error: {}, email: '', password: '', totpToken: '', register: true, termsAccepted: false, purpose: '', submit: function () { $scope.appstoreLogin.error = {}; $scope.appstoreLogin.busy = true; Client.registerCloudron($scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.register, $scope.appstoreLogin.purpose, function (error) { if (error) { $scope.appstoreLogin.busy = false; if (error.statusCode === 409) { $scope.appstoreLogin.error.email = 'An account with this email already exists'; $scope.appstoreLogin.password = ''; $scope.appstoreLoginForm.email.$setPristine(); $scope.appstoreLoginForm.password.$setPristine(); $('#inputAppstoreLoginEmail').focus(); } else if (error.statusCode === 412) { if (error.message.indexOf('TOTP token missing') !== -1) { $scope.appstoreLogin.error.totpToken = 'A 2FA token is required'; setTimeout(function () { $('#inputAppstoreLoginTotpToken').focus(); }, 0); } else if (error.message.indexOf('TOTP token invalid') !== -1) { $scope.appstoreLogin.error.totpToken = 'Wrong 2FA token'; $scope.appstoreLogin.totpToken = ''; setTimeout(function () { $('#inputAppstoreLoginTotpToken').focus(); }, 0); } else { $scope.appstoreLogin.error.password = 'Wrong email or password'; $scope.appstoreLogin.password = ''; $('#inputAppstoreLoginPassword').focus(); $scope.appstoreLoginForm.password.$setPristine(); } } else if (error.statusCode === 424) { if (error.message === 'wrong user') { $scope.appstoreLogin.error.generic = 'Wrong cloudron.io account'; $scope.appstoreLogin.email = ''; $scope.appstoreLogin.password = ''; $scope.appstoreLoginForm.email.$setPristine(); $scope.appstoreLoginForm.password.$setPristine(); $('#inputAppstoreLoginEmail').focus(); } else { console.error(error); $scope.appstoreLogin.error.generic = error.message; } } else { console.error(error); $scope.appstoreLogin.error.generic = error.message || 'Please retry later'; } return; } getSubscription(function (error) { if (error) return console.error(error); onSubscribed(function (error) { if (error) console.error(error); }); }); }); } }; function onSubscribed(callback) { Client.getAppstoreApps(function (error) { if (error) return callback(error); // start with all apps listing. this also sets $scope.apps accordingly $scope.showCategory(''); // do this in background fetchUsers(); fetchGroups(); // domains is required since we populate the dropdown with domains[0] Client.getDomains(function (error, result) { if (error) return callback(error); $scope.domains = result; // show install app dialog immediately if an app id was passed in the query // hashChangeListener calls $apply, so make sure we don't double digest here setTimeout(hashChangeListener, 1); setTimeout(function () { $('#appstoreSearch').focus(); }, 1); callback(); }); }); } // TODO does not support testing apps in search $scope.search = function () { if (!$scope.searchString) return $scope.showCategory($scope.cachedCategory); $scope.category = ''; Client.getAppstoreAppsFast(function (error, apps) { if (error) return $timeout($scope.search, 1000); var token = $scope.searchString.toUpperCase(); $scope.apps = apps.filter(function (app) { if (app.manifest.id.toUpperCase().indexOf(token) !== -1) return true; if (app.manifest.title.toUpperCase().indexOf(token) !== -1) return true; if (app.manifest.tagline.toUpperCase().indexOf(token) !== -1) return true; if (app.manifest.tags.join().toUpperCase().indexOf(token) !== -1) return true; if (app.manifest.description.toUpperCase().indexOf(token) !== -1) return true; return false; }); }); }; function filterForNewApps(apps) { var minApps = apps.length < 10 ? apps.length : 10; // prevent endless loop var tmp = []; var i = 0; do { var offset = moment().subtract(i++, 'days'); tmp = apps.filter(function (app) { return moment(app.ts).isAfter(offset); }); } while(tmp.length < minApps); return tmp; } function filterForRecentlyUpdatedApps(apps) { var minApps = apps.length < 10 ? apps.length : 10; // prevent endless loop var tmp = []; var i = 0; do { var offset = moment().subtract(i++, 'days'); tmp = apps.filter(function (app) { return moment(app.creationDate).isAfter(offset); }); } while(tmp.length < minApps); return tmp; } $scope.showCategory = function (category) { $scope.category = category; $scope.cachedCategory = $scope.category; Client.getAppstoreAppsFast(function (error, apps) { if (error) return $timeout($scope.showCategory.bind(null, category), 1000); if (!$scope.category) { $scope.apps = apps.slice(0).sort(function (a1, a2) { return a1.manifest.title.localeCompare(a2.manifest.title); }); } else if ($scope.category === 'featured') { $scope.apps = apps.filter(function (app) { return app.featured; }).sort(function (a1, a2) { return a2.ranking - a1.ranking; }); // reverse sort } else if ($scope.category === 'new') { $scope.apps = filterForNewApps(apps); } else if ($scope.category === 'recent') { $scope.apps = filterForRecentlyUpdatedApps(apps); } else { $scope.apps = apps.filter(function (app) { return app.manifest.tags.some(function (tag) { return $scope.category === tag; }); // reverse sort; }).sort(function (a1, a2) { return a2.ranking - a1.ranking; }); } // ensure we scroll to top document.getElementById('ng-view').scrollTop = 0; }); }; $scope.openSubscriptionSetup = function () { Client.getSubscription(function (error, subscription) { if (error) return console.error('Unable to get subscription.', error); Client.openSubscriptionSetup(subscription); }); }; document.getElementById('appInstallCertificateFileInput').onchange = function (event) { $scope.$apply(function () { $scope.appInstall.certificateFile = null; $scope.appInstall.certificateFileName = event.target.files[0].name; var reader = new FileReader(); reader.onload = function (result) { if (!result.target || !result.target.result) return console.error('Unable to read local file'); $scope.appInstall.certificateFile = result.target.result; }; reader.readAsText(event.target.files[0]); }); }; document.getElementById('appInstallKeyFileInput').onchange = function (event) { $scope.$apply(function () { $scope.appInstall.keyFile = null; $scope.appInstall.keyFileName = event.target.files[0].name; var reader = new FileReader(); reader.onload = function (result) { if (!result.target || !result.target.result) return console.error('Unable to read local file'); $scope.appInstall.keyFile = result.target.result; }; reader.readAsText(event.target.files[0]); }); }; $scope.showAppNotFound = function (appId, version) { $scope.appNotFound.appId = appId; $scope.appNotFound.version = version; $('#appNotFoundModal').modal('show'); }; $scope.gotoApp = function (app) { $location.path('/appstore/' + app.manifest.id, false).search({ version : app.manifest.version }); }; function hashChangeListener() { // event listener is called from DOM not angular, need to use $apply $scope.$apply(function () { var appId = $location.path().slice('/appstore/'.length); var version = $location.search().version; if (appId) { Client.getAppstoreAppByIdAndVersion(appId, version || 'latest', function (error, result) { if (error) { $scope.showAppNotFound(appId, version); console.error(error); return; } $scope.appInstall.show(result); }); } else { $scope.appInstall.reset(); } }); } function fetchUsers() { Client.getUsers(function (error, users) { if (error) { console.error(error); return $timeout(fetchUsers, 5000); } $scope.users = users; }); } function fetchGroups() { Client.getGroups(function (error, groups) { if (error) { console.error(error); return $timeout(fetchGroups, 5000); } $scope.groups = groups; }); } function getSubscription(callback) { Client.getSubscription(function (error, subscription) { if (error) { if (error.statusCode === 412) { // not registered yet $scope.validSubscription = false; } else if (error.statusCode === 402) { // invalid token, license error $scope.validSubscription = false; } else { // 424/external error? return callback(error); } } else { $scope.validSubscription = true; $scope.subscription = subscription; } // clear busy state when a login/signup was performed $scope.appstoreLogin.busy = false; // also update the root controller status if ($scope.$parent) $scope.$parent.updateSubscriptionStatus(); callback(); }); } function getMemory(callback) { Client.memory(function (error, memory) { if (error) console.error(error); $scope.memory = memory; callback(); }); } function init() { $scope.ready = false; getSubscription(function (error) { if (error) { console.error(error); return $timeout(init, 1000); } if (!$scope.validSubscription) { // show the login form $scope.ready = true; return; } onSubscribed(function (error) { if (error) console.error(error); $scope.ready = true; }); }); } Client.onReady(function () { getMemory(function () { init(); }); }); // note: do not use hide.bs.model since it is called immediately from switchToAppsView which is already in angular scope $('#appInstallModal').on('hidden.bs.modal', function () { // clear the appid and version in the search bar when dialog is cancelled $scope.$apply(function () { $location.path('/appstore', false).search({ }); // 'false' means do not reload }); }); window.addEventListener('hashchange', hashChangeListener); $scope.$on('$destroy', function handler() { window.removeEventListener('hashchange', hashChangeListener); }); // setup all the dialog focus handling ['appInstallModal'].forEach(function (id) { $('#' + id).on('shown.bs.modal', function () { $(this).find('[autofocus]:first').focus(); }); }); // autofocus if appstore login is shown $scope.$watch('validSubscription', function (newValue/*, oldValue */) { if (!newValue) setTimeout(function () { $('[name=appstoreLoginForm]').find('[autofocus]:first').focus(); }, 1000); }); $('.modal-backdrop').remove(); }]);