'use strict'; /* global angular:false */ /* global $:false */ angular.module('Application').controller('AppStoreController', ['$scope', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $location, $timeout, $routeParams, Client) { Client.onReady(function () { if (!Client.getUserInfo().admin) $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.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.appInstall = { busy: false, state: 'appInfo', subscriptionHelperPage: '', error: {}, app: {}, overwriteDns: 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.overwriteDns = false; $scope.appInstall.location = ''; $scope.appInstall.domain = null; $scope.appInstall.portBindings = {}; $scope.appInstall.state = 'appInfo'; $scope.appInstall.subscriptionHelperPage = ''; $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) { if (Client.enoughResourcesAvailable($scope.appInstall.app) || 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.overwriteDns = false; $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']); // for spaces users, the User management is hidden. thus the admin flag check if (!$scope.user.admin) { // just install it with access restriction as just the user var me = $scope.users.find(function (u) { return u.id === $scope.user.id; }); $scope.appInstall.accessRestrictionOption = 'groups'; $scope.appInstall.accessRestriction = { users: [ me ], groups: [] }; } else { $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.overwriteDns, 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.getDNSRecords(data.domain, data.location, function (error, result) { if (error) return Client.error(error); if (!data.overwriteDns) { if (result.error || result.needsOverwrite) { $scope.appInstall.error.location = result.error ? result.error.message : 'DNS Record already exists outside of Cloudron'; $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'); if (error.message.indexOf('Upgrade to the premium') === 0) { $scope.appInstall.subscriptionHelperPage = 'app_install_premium_subscription_required.html'; } else if (error.message.indexOf('The subscription for this Cloudron has expired.') === 0) { $scope.appInstall.subscriptionHelperPage = 'app_install_subscription_expired.html'; } else { $scope.appInstall.subscriptionHelperPage = 'app_install_subscription_required.html'; } } 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, submit: function () { $scope.appstoreLogin.error = {}; $scope.appstoreLogin.busy = true; Client.registerCloudron($scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.register, 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 featured apps listing. this also sets $scope.apps accordingly $scope.showCategory(null, 'featured'); // 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(); }, 1000); callback(); }); }); } // TODO does not support testing apps in search $scope.search = function () { if (!$scope.searchString) return $scope.showCategory(null, $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; }); }); }; $scope.showCategory = function (event, category) { if (!event) $scope.category = category; else $scope.category = event.target.getAttribute('category'); $scope.cachedCategory = $scope.category; Client.getAppstoreAppsFast(function (error, apps) { if (error) return $timeout($scope.showCategory.bind(null, event), 1000); if (!$scope.category) { $scope.apps = apps; } else if ($scope.category === 'featured') { $scope.apps = apps.filter(function (app) { return app.featured; }); } else { $scope.apps = apps.filter(function (app) { return app.manifest.tags.some(function (tag) { return $scope.category === tag; }); }); } if (document.getElementById('appstoreGrid')) document.getElementById('appstoreGrid').scrollIntoView(); }); }; 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 $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 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(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(); }]);