diff options
Diffstat (limited to 'apps/files_external/src/settings.js')
-rw-r--r-- | apps/files_external/src/settings.js | 1579 |
1 files changed, 1579 insertions, 0 deletions
diff --git a/apps/files_external/src/settings.js b/apps/files_external/src/settings.js new file mode 100644 index 00000000000..033696c9d24 --- /dev/null +++ b/apps/files_external/src/settings.js @@ -0,0 +1,1579 @@ +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation' +import { generateUrl } from '@nextcloud/router' +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import axios, { isAxiosError } from '@nextcloud/axios' + +import jQuery from 'jquery' + +addPasswordConfirmationInterceptors(axios) + +/** + * Returns the selection of applicable users in the given configuration row + * + * @param $row configuration row + * @return array array of user names + */ +function getSelection($row) { + let values = $row.find('.applicableUsers').select2('val') + if (!values || values.length === 0) { + values = [] + } + return values +} + +/** + * + * @param $row + */ +function getSelectedApplicable($row) { + const users = [] + const groups = [] + const multiselect = getSelection($row) + $.each(multiselect, function(index, value) { + // FIXME: don't rely on string parts to detect groups... + const pos = (value.indexOf) ? value.indexOf('(group)') : -1 + if (pos !== -1) { + groups.push(value.substr(0, pos)) + } else { + users.push(value) + } + }) + + // FIXME: this should be done in the multiselect change event instead + $row.find('.applicable') + .data('applicable-groups', groups) + .data('applicable-users', users) + + return { users, groups } +} + +/** + * + * @param $element + * @param highlight + */ +function highlightBorder($element, highlight) { + $element.toggleClass('warning-input', highlight) + return highlight +} + +/** + * + * @param $input + */ +function isInputValid($input) { + const optional = $input.hasClass('optional') + switch ($input.attr('type')) { + case 'text': + case 'password': + if ($input.val() === '' && !optional) { + return false + } + break + } + return true +} + +/** + * + * @param $input + */ +function highlightInput($input) { + switch ($input.attr('type')) { + case 'text': + case 'password': + return highlightBorder($input, !isInputValid($input)) + } +} + +/** + * Initialize select2 plugin on the given elements + * + * @param {Array<object>} array of jQuery elements + * @param $elements + * @param {number} userListLimit page size for result list + */ +function initApplicableUsersMultiselect($elements, userListLimit) { + const escapeHTML = function(text) { + return text.toString() + .split('&').join('&') + .split('<').join('<') + .split('>').join('>') + .split('"').join('"') + .split('\'').join(''') + } + if (!$elements.length) { + return + } + return $elements.select2({ + placeholder: t('files_external', 'Type to select account or group.'), + allowClear: true, + multiple: true, + toggleSelect: true, + dropdownCssClass: 'files-external-select2', + // minimumInputLength: 1, + ajax: { + url: OC.generateUrl('apps/files_external/applicable'), + dataType: 'json', + quietMillis: 100, + data(term, page) { // page is the one-based page number tracked by Select2 + return { + pattern: term, // search term + limit: userListLimit, // page size + offset: userListLimit * (page - 1), // page number starts with 0 + } + }, + results(data) { + if (data.status === 'success') { + + const results = [] + let userCount = 0 // users is an object + + // add groups + $.each(data.groups, function(gid, group) { + results.push({ name: gid + '(group)', displayname: group, type: 'group' }) + }) + // add users + $.each(data.users, function(id, user) { + userCount++ + results.push({ name: id, displayname: user, type: 'user' }) + }) + + const more = (userCount >= userListLimit) || (data.groups.length >= userListLimit) + return { results, more } + } else { + // FIXME add error handling + } + }, + }, + initSelection(element, callback) { + const users = {} + users.users = [] + const toSplit = element.val().split(',') + for (let i = 0; i < toSplit.length; i++) { + users.users.push(toSplit[i]) + } + + $.ajax(OC.generateUrl('displaynames'), { + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(users), + dataType: 'json', + }).done(function(data) { + const results = [] + if (data.status === 'success') { + $.each(data.users, function(user, displayname) { + if (displayname !== false) { + results.push({ name: user, displayname, type: 'user' }) + } + }) + callback(results) + } else { + // FIXME add error handling + } + }) + }, + id(element) { + return element.name + }, + formatResult(element) { + const $result = $('<span><div class="avatardiv"></div><span>' + escapeHTML(element.displayname) + '</span></span>') + const $div = $result.find('.avatardiv') + .attr('data-type', element.type) + .attr('data-name', element.name) + .attr('data-displayname', element.displayname) + if (element.type === 'group') { + const url = OC.imagePath('core', 'actions/group') + $div.html('<img width="32" height="32" src="' + url + '">') + } + return $result.get(0).outerHTML + }, + formatSelection(element) { + if (element.type === 'group') { + return '<span title="' + escapeHTML(element.name) + '" class="group">' + escapeHTML(element.displayname + ' ' + t('files_external', '(Group)')) + '</span>' + } else { + return '<span title="' + escapeHTML(element.name) + '" class="user">' + escapeHTML(element.displayname) + '</span>' + } + }, + escapeMarkup(m) { return m }, // we escape the markup in formatResult and formatSelection + }).on('select2-loaded', function() { + $.each($('.avatardiv'), function(i, div) { + const $div = $(div) + if ($div.data('type') === 'user') { + $div.avatar($div.data('name'), 32) + } + }) + }).on('change', function(event) { + highlightBorder($(event.target).closest('.applicableUsersContainer').find('.select2-choices'), !event.val.length) + }) +} + +/** + * @param id + * @class OCA.Files_External.Settings.StorageConfig + * + * @classdesc External storage config + */ +const StorageConfig = function(id) { + this.id = id + this.backendOptions = {} +} +// Keep this in sync with \OCA\Files_External\MountConfig::STATUS_* +StorageConfig.Status = { + IN_PROGRESS: -1, + SUCCESS: 0, + ERROR: 1, + INDETERMINATE: 2, +} +StorageConfig.Visibility = { + NONE: 0, + PERSONAL: 1, + ADMIN: 2, + DEFAULT: 3, +} +/** + * @memberof OCA.Files_External.Settings + */ +StorageConfig.prototype = { + _url: null, + + /** + * Storage id + * + * @type int + */ + id: null, + + /** + * Mount point + * + * @type string + */ + mountPoint: '', + + /** + * Backend + * + * @type string + */ + backend: null, + + /** + * Authentication mechanism + * + * @type string + */ + authMechanism: null, + + /** + * Backend-specific configuration + * + * @type Object.<string,object> + */ + backendOptions: null, + + /** + * Mount-specific options + * + * @type Object.<string,object> + */ + mountOptions: null, + + /** + * Creates or saves the storage. + * + * @param {Function} [options.success] success callback, receives result as argument + * @param {Function} [options.error] error callback + * @param options + */ + save(options) { + let url = OC.generateUrl(this._url) + let method = 'POST' + if (_.isNumber(this.id)) { + method = 'PUT' + url = OC.generateUrl(this._url + '/{id}', { id: this.id }) + } + + this._save(method, url, options) + }, + + /** + * Private implementation of the save function (called after potential password confirmation) + * @param {string} method + * @param {string} url + * @param {{success: Function, error: Function}} options + */ + async _save(method, url, options) { + try { + const response = await axios.request({ + confirmPassword: PwdConfirmationMode.Strict, + method, + url, + data: this.getData(), + }) + const result = response.data + this.id = result.id + options.success(result) + } catch (error) { + options.error(error) + } + }, + + /** + * Returns the data from this object + * + * @return {Array} JSON array of the data + */ + getData() { + const data = { + mountPoint: this.mountPoint, + backend: this.backend, + authMechanism: this.authMechanism, + backendOptions: this.backendOptions, + testOnly: true, + } + if (this.id) { + data.id = this.id + } + if (this.mountOptions) { + data.mountOptions = this.mountOptions + } + return data + }, + + /** + * Recheck the storage + * + * @param {Function} [options.success] success callback, receives result as argument + * @param {Function} [options.error] error callback + * @param options + */ + recheck(options) { + if (!_.isNumber(this.id)) { + if (_.isFunction(options.error)) { + options.error() + } + return + } + $.ajax({ + type: 'GET', + url: OC.generateUrl(this._url + '/{id}', { id: this.id }), + data: { testOnly: true }, + success: options.success, + error: options.error, + }) + }, + + /** + * Deletes the storage + * + * @param {Function} [options.success] success callback + * @param {Function} [options.error] error callback + * @param options + */ + async destroy(options) { + if (!_.isNumber(this.id)) { + // the storage hasn't even been created => success + if (_.isFunction(options.success)) { + options.success() + } + return + } + + try { + await axios.request({ + method: 'DELETE', + url: OC.generateUrl(this._url + '/{id}', { id: this.id }), + confirmPassword: PwdConfirmationMode.Strict, + }) + options.success() + } catch (e) { + options.error(e) + } + }, + + /** + * Validate this model + * + * @return {boolean} false if errors exist, true otherwise + */ + validate() { + if (this.mountPoint === '') { + return false + } + if (!this.backend) { + return false + } + if (this.errors) { + return false + } + return true + }, +} + +/** + * @param id + * @class OCA.Files_External.Settings.GlobalStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc Global external storage config + */ +const GlobalStorageConfig = function(id) { + this.id = id + this.applicableUsers = [] + this.applicableGroups = [] +} +/** + * @memberOf OCA.Files_External.Settings + */ +GlobalStorageConfig.prototype = _.extend({}, StorageConfig.prototype, + /** @lends OCA.Files_External.Settings.GlobalStorageConfig.prototype */ { + _url: 'apps/files_external/globalstorages', + + /** + * Applicable users + * + * @type Array.<string> + */ + applicableUsers: null, + + /** + * Applicable groups + * + * @type Array.<string> + */ + applicableGroups: null, + + /** + * Storage priority + * + * @type int + */ + priority: null, + + /** + * Returns the data from this object + * + * @return {Array} JSON array of the data + */ + getData() { + const data = StorageConfig.prototype.getData.apply(this, arguments) + return _.extend(data, { + applicableUsers: this.applicableUsers, + applicableGroups: this.applicableGroups, + priority: this.priority, + }) + }, + }) + +/** + * @param id + * @class OCA.Files_External.Settings.UserStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc User external storage config + */ +const UserStorageConfig = function(id) { + this.id = id +} +UserStorageConfig.prototype = _.extend({}, StorageConfig.prototype, + /** @lends OCA.Files_External.Settings.UserStorageConfig.prototype */ { + _url: 'apps/files_external/userstorages', + }) + +/** + * @param id + * @class OCA.Files_External.Settings.UserGlobalStorageConfig + * @augments OCA.Files_External.Settings.StorageConfig + * + * @classdesc User external storage config + */ +const UserGlobalStorageConfig = function(id) { + this.id = id +} +UserGlobalStorageConfig.prototype = _.extend({}, StorageConfig.prototype, + /** @lends OCA.Files_External.Settings.UserStorageConfig.prototype */ { + + _url: 'apps/files_external/userglobalstorages', + }) + +/** + * @class OCA.Files_External.Settings.MountOptionsDropdown + * + * @classdesc Dropdown for mount options + * + * @param {object} $container container DOM object + */ +const MountOptionsDropdown = function() { +} +/** + * @memberof OCA.Files_External.Settings + */ +MountOptionsDropdown.prototype = { + /** + * Dropdown element + * + * @member Object + */ + $el: null, + + /** + * Show dropdown + * + * @param {object} $container container + * @param {object} mountOptions mount options + * @param {Array} visibleOptions enabled mount options + */ + show($container, mountOptions, visibleOptions) { + if (MountOptionsDropdown._last) { + MountOptionsDropdown._last.hide() + } + + const $el = $(OCA.Files_External.Templates.mountOptionsDropDown({ + mountOptionsEncodingLabel: t('files_external', 'Compatibility with Mac NFD encoding (slow)'), + mountOptionsEncryptLabel: t('files_external', 'Enable encryption'), + mountOptionsPreviewsLabel: t('files_external', 'Enable previews'), + mountOptionsSharingLabel: t('files_external', 'Enable sharing'), + mountOptionsFilesystemCheckLabel: t('files_external', 'Check for changes'), + mountOptionsFilesystemCheckOnce: t('files_external', 'Never'), + mountOptionsFilesystemCheckDA: t('files_external', 'Once every direct access'), + mountOptionsReadOnlyLabel: t('files_external', 'Read only'), + deleteLabel: t('files_external', 'Disconnect'), + })) + this.$el = $el + + const storage = $container[0].parentNode.className + + this.setOptions(mountOptions, visibleOptions, storage) + + this.$el.appendTo($container) + MountOptionsDropdown._last = this + + this.$el.trigger('show') + }, + + hide() { + if (this.$el) { + this.$el.trigger('hide') + this.$el.remove() + this.$el = null + MountOptionsDropdown._last = null + } + }, + + /** + * Returns the mount options from the dropdown controls + * + * @return {object} options mount options + */ + getOptions() { + const options = {} + + this.$el.find('input, select').each(function() { + const $this = $(this) + const key = $this.attr('name') + let value = null + if ($this.attr('type') === 'checkbox') { + value = $this.prop('checked') + } else { + value = $this.val() + } + if ($this.attr('data-type') === 'int') { + value = parseInt(value, 10) + } + options[key] = value + }) + return options + }, + + /** + * Sets the mount options to the dropdown controls + * + * @param {object} options mount options + * @param {Array} visibleOptions enabled mount options + * @param storage + */ + setOptions(options, visibleOptions, storage) { + if (storage === 'owncloud') { + const ind = visibleOptions.indexOf('encrypt') + if (ind > 0) { + visibleOptions.splice(ind, 1) + } + } + const $el = this.$el + _.each(options, function(value, key) { + const $optionEl = $el.find('input, select').filterAttr('name', key) + if ($optionEl.attr('type') === 'checkbox') { + if (_.isString(value)) { + value = (value === 'true') + } + $optionEl.prop('checked', !!value) + } else { + $optionEl.val(value) + } + }) + $el.find('.optionRow').each(function(i, row) { + const $row = $(row) + const optionId = $row.find('input, select').attr('name') + if (visibleOptions.indexOf(optionId) === -1 && !$row.hasClass('persistent')) { + $row.hide() + } else { + $row.show() + } + }) + }, +} + +/** + * @class OCA.Files_External.Settings.MountConfigListView + * + * @classdesc Mount configuration list view + * + * @param {object} $el DOM object containing the list + * @param {object} [options] + * @param {number} [options.userListLimit] page size in applicable users dropdown + */ +const MountConfigListView = function($el, options) { + this.initialize($el, options) +} + +MountConfigListView.ParameterFlags = { + OPTIONAL: 1, + USER_PROVIDED: 2, + HIDDEN: 4, +} + +MountConfigListView.ParameterTypes = { + TEXT: 0, + BOOLEAN: 1, + PASSWORD: 2, +} + +/** + * @memberOf OCA.Files_External.Settings + */ +MountConfigListView.prototype = _.extend({ + + /** + * jQuery element containing the config list + * + * @type Object + */ + $el: null, + + /** + * Storage config class + * + * @type Class + */ + _storageConfigClass: null, + + /** + * Flag whether the list is about user storage configs (true) + * or global storage configs (false) + * + * @type bool + */ + _isPersonal: false, + + /** + * Page size in applicable users dropdown + * + * @type int + */ + _userListLimit: 30, + + /** + * List of supported backends + * + * @type Object.<string,Object> + */ + _allBackends: null, + + /** + * List of all supported authentication mechanisms + * + * @type Object.<string,Object> + */ + _allAuthMechanisms: null, + + _encryptionEnabled: false, + + /** + * @param {object} $el DOM object containing the list + * @param {object} [options] + * @param {number} [options.userListLimit] page size in applicable users dropdown + */ + initialize($el, options) { + this.$el = $el + this._isPersonal = ($el.data('admin') !== true) + if (this._isPersonal) { + this._storageConfigClass = OCA.Files_External.Settings.UserStorageConfig + } else { + this._storageConfigClass = OCA.Files_External.Settings.GlobalStorageConfig + } + + if (options && !_.isUndefined(options.userListLimit)) { + this._userListLimit = options.userListLimit + } + + this._encryptionEnabled = options.encryptionEnabled + this._canCreateLocal = options.canCreateLocal + + // read the backend config that was carefully crammed + // into the data-configurations attribute of the select + this._allBackends = this.$el.find('.selectBackend').data('configurations') + this._allAuthMechanisms = this.$el.find('#addMountPoint .authentication').data('mechanisms') + + this._initEvents() + }, + + /** + * Custom JS event handlers + * Trigger callback for all existing configurations + * @param callback + */ + whenSelectBackend(callback) { + this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) { + const backend = $(tr).find('.backend').data('identifier') + callback($(tr), backend) + }) + this.on('selectBackend', callback) + }, + whenSelectAuthMechanism(callback) { + const self = this + this.$el.find('tbody tr:not(#addMountPoint):not(.externalStorageLoading)').each(function(i, tr) { + const authMechanism = $(tr).find('.selectAuthMechanism').val() + callback($(tr), authMechanism, self._allAuthMechanisms[authMechanism].scheme) + }) + this.on('selectAuthMechanism', callback) + }, + + /** + * Initialize DOM event handlers + */ + _initEvents() { + const self = this + + const onChangeHandler = _.bind(this._onChange, this) + // this.$el.on('input', 'td input', onChangeHandler); + this.$el.on('keyup', 'td input', onChangeHandler) + this.$el.on('paste', 'td input', onChangeHandler) + this.$el.on('change', 'td input:checkbox', onChangeHandler) + this.$el.on('change', '.applicable', onChangeHandler) + + this.$el.on('click', '.status>span', function() { + self.recheckStorageConfig($(this).closest('tr')) + }) + + this.$el.on('click', 'td.mountOptionsToggle .icon-delete', function() { + self.deleteStorageConfig($(this).closest('tr')) + }) + + this.$el.on('click', 'td.save>.icon-checkmark', function() { + self.saveStorageConfig($(this).closest('tr')) + }) + + this.$el.on('click', 'td.mountOptionsToggle>.icon-more', function() { + $(this).attr('aria-expanded', 'true') + self._showMountOptionsDropdown($(this).closest('tr')) + }) + + this.$el.on('change', '.selectBackend', _.bind(this._onSelectBackend, this)) + this.$el.on('change', '.selectAuthMechanism', _.bind(this._onSelectAuthMechanism, this)) + + this.$el.on('change', '.applicableToAllUsers', _.bind(this._onChangeApplicableToAllUsers, this)) + }, + + _onChange(event) { + const $target = $(event.target) + if ($target.closest('.dropdown').length) { + // ignore dropdown events + return + } + highlightInput($target) + const $tr = $target.closest('tr') + this.updateStatus($tr, null) + }, + + _onSelectBackend(event) { + const $target = $(event.target) + let $tr = $target.closest('tr') + + const storageConfig = new this._storageConfigClass() + storageConfig.mountPoint = $tr.find('.mountPoint input').val() + storageConfig.backend = $target.val() + $tr.find('.mountPoint input').val('') + + $tr.find('.selectBackend').prop('selectedIndex', 0) + + const onCompletion = jQuery.Deferred() + $tr = this.newStorage(storageConfig, onCompletion) + $tr.find('.applicableToAllUsers').prop('checked', false).trigger('change') + onCompletion.resolve() + + $tr.find('td.configuration').children().not('[type=hidden]').first().focus() + this.saveStorageConfig($tr) + }, + + _onSelectAuthMechanism(event) { + const $target = $(event.target) + const $tr = $target.closest('tr') + const authMechanism = $target.val() + + const onCompletion = jQuery.Deferred() + this.configureAuthMechanism($tr, authMechanism, onCompletion) + onCompletion.resolve() + + this.saveStorageConfig($tr) + }, + + _onChangeApplicableToAllUsers(event) { + const $target = $(event.target) + const $tr = $target.closest('tr') + const checked = $target.is(':checked') + + $tr.find('.applicableUsersContainer').toggleClass('hidden', checked) + if (!checked) { + $tr.find('.applicableUsers').select2('val', '', true) + } + + this.saveStorageConfig($tr) + }, + + /** + * Configure the storage config with a new authentication mechanism + * + * @param {jQuery} $tr config row + * @param {string} authMechanism + * @param {jQuery.Deferred} onCompletion + */ + configureAuthMechanism($tr, authMechanism, onCompletion) { + const authMechanismConfiguration = this._allAuthMechanisms[authMechanism] + const $td = $tr.find('td.configuration') + $td.find('.auth-param').remove() + + $.each(authMechanismConfiguration.configuration, _.partial( + this.writeParameterInput, $td, _, _, ['auth-param'], + ).bind(this)) + + this.trigger('selectAuthMechanism', + $tr, authMechanism, authMechanismConfiguration.scheme, onCompletion, + ) + }, + + /** + * Create a config row for a new storage + * + * @param {StorageConfig} storageConfig storage config to pull values from + * @param {jQuery.Deferred} onCompletion + * @param {boolean} deferAppend + * @return {jQuery} created row + */ + newStorage(storageConfig, onCompletion, deferAppend) { + let mountPoint = storageConfig.mountPoint + let backend = this._allBackends[storageConfig.backend] + + if (!backend) { + backend = { + name: 'Unknown: ' + storageConfig.backend, + invalid: true, + } + } + + // FIXME: Replace with a proper Handlebar template + const $template = this.$el.find('tr#addMountPoint') + const $tr = $template.clone() + if (!deferAppend) { + $tr.insertBefore($template) + } + + $tr.data('storageConfig', storageConfig) + $tr.show() + $tr.find('td.mountOptionsToggle, td.save, td.remove').removeClass('hidden') + $tr.find('td').last().removeAttr('style') + $tr.removeAttr('id') + $tr.find('select#selectBackend') + if (!deferAppend) { + initApplicableUsersMultiselect($tr.find('.applicableUsers'), this._userListLimit) + } + + if (storageConfig.id) { + $tr.data('id', storageConfig.id) + } + + $tr.find('.backend').text(backend.name) + if (mountPoint === '') { + mountPoint = this._suggestMountPoint(backend.name) + } + $tr.find('.mountPoint input').val(mountPoint) + $tr.addClass(backend.identifier) + $tr.find('.backend').data('identifier', backend.identifier) + + if (backend.invalid || (backend.identifier === 'local' && !this._canCreateLocal)) { + $tr.find('[name=mountPoint]').prop('disabled', true) + $tr.find('.applicable,.mountOptionsToggle').empty() + $tr.find('.save').empty() + if (backend.invalid) { + this.updateStatus($tr, false, t('files_external', 'Unknown backend: {backendName}', { backendName: backend.name })) + } + return $tr + } + + const selectAuthMechanism = $('<select class="selectAuthMechanism"></select>') + const neededVisibility = (this._isPersonal) ? StorageConfig.Visibility.PERSONAL : StorageConfig.Visibility.ADMIN + $.each(this._allAuthMechanisms, function(authIdentifier, authMechanism) { + if (backend.authSchemes[authMechanism.scheme] && (authMechanism.visibility & neededVisibility)) { + selectAuthMechanism.append( + $('<option value="' + authMechanism.identifier + '" data-scheme="' + authMechanism.scheme + '">' + authMechanism.name + '</option>'), + ) + } + }) + if (storageConfig.authMechanism) { + selectAuthMechanism.val(storageConfig.authMechanism) + } else { + storageConfig.authMechanism = selectAuthMechanism.val() + } + $tr.find('td.authentication').append(selectAuthMechanism) + + const $td = $tr.find('td.configuration') + $.each(backend.configuration, _.partial(this.writeParameterInput, $td).bind(this)) + + this.trigger('selectBackend', $tr, backend.identifier, onCompletion) + this.configureAuthMechanism($tr, storageConfig.authMechanism, onCompletion) + + if (storageConfig.backendOptions) { + $td.find('input, select').each(function() { + const input = $(this) + const val = storageConfig.backendOptions[input.data('parameter')] + if (val !== undefined) { + if (input.is('input:checkbox')) { + input.prop('checked', val) + } + input.val(storageConfig.backendOptions[input.data('parameter')]) + highlightInput(input) + } + }) + } + + let applicable = [] + if (storageConfig.applicableUsers) { + applicable = applicable.concat(storageConfig.applicableUsers) + } + if (storageConfig.applicableGroups) { + applicable = applicable.concat( + _.map(storageConfig.applicableGroups, function(group) { + return group + '(group)' + }), + ) + } + if (applicable.length) { + $tr.find('.applicableUsers').val(applicable).trigger('change') + $tr.find('.applicableUsersContainer').removeClass('hidden') + } else { + // applicable to all + $tr.find('.applicableUsersContainer').addClass('hidden') + } + $tr.find('.applicableToAllUsers').prop('checked', !applicable.length) + + const priorityEl = $('<input type="hidden" class="priority" value="' + backend.priority + '" />') + $tr.append(priorityEl) + + if (storageConfig.mountOptions) { + $tr.find('input.mountOptions').val(JSON.stringify(storageConfig.mountOptions)) + } else { + // FIXME default backend mount options + $tr.find('input.mountOptions').val(JSON.stringify({ + encrypt: true, + previews: true, + enable_sharing: false, + filesystem_check_changes: 1, + encoding_compatibility: false, + readonly: false, + })) + } + + return $tr + }, + + /** + * Load storages into config rows + */ + loadStorages() { + const self = this + + const onLoaded1 = $.Deferred() + const onLoaded2 = $.Deferred() + + this.$el.find('.externalStorageLoading').removeClass('hidden') + $.when(onLoaded1, onLoaded2).always(() => { + self.$el.find('.externalStorageLoading').addClass('hidden') + }) + + if (this._isPersonal) { + // load userglobal storages + $.ajax({ + type: 'GET', + url: OC.generateUrl('apps/files_external/userglobalstorages'), + data: { testOnly: true }, + contentType: 'application/json', + success(result) { + result = Object.values(result) + const onCompletion = jQuery.Deferred() + let $rows = $() + result.forEach(function(storageParams) { + let storageConfig + const isUserGlobal = storageParams.type === 'system' && self._isPersonal + storageParams.mountPoint = storageParams.mountPoint.substr(1) // trim leading slash + if (isUserGlobal) { + storageConfig = new UserGlobalStorageConfig() + } else { + storageConfig = new self._storageConfigClass() + } + _.extend(storageConfig, storageParams) + const $tr = self.newStorage(storageConfig, onCompletion, true) + + // userglobal storages must be at the top of the list + $tr.detach() + self.$el.prepend($tr) + + const $authentication = $tr.find('.authentication') + $authentication.text($authentication.find('select option:selected').text()) + + // disable any other inputs + $tr.find('.mountOptionsToggle, .remove').empty() + $tr.find('input:not(.user_provided), select:not(.user_provided)').attr('disabled', 'disabled') + + if (isUserGlobal) { + $tr.find('.configuration').find(':not(.user_provided)').remove() + } else { + // userglobal storages do not expose configuration data + $tr.find('.configuration').text(t('files_external', 'Admin defined')) + } + + // don't recheck config automatically when there are a large number of storages + if (result.length < 20) { + self.recheckStorageConfig($tr) + } else { + self.updateStatus($tr, StorageConfig.Status.INDETERMINATE, t('files_external', 'Automatic status checking is disabled due to the large number of configured storages, click to check status')) + } + $rows = $rows.add($tr) + }) + initApplicableUsersMultiselect(self.$el.find('.applicableUsers'), this._userListLimit) + self.$el.find('tr#addMountPoint').before($rows) + const mainForm = $('#files_external') + if (result.length === 0 && mainForm.attr('data-can-create') === 'false') { + mainForm.hide() + $('a[href="#external-storage"]').parent().hide() + $('.emptycontent').show() + } + onCompletion.resolve() + onLoaded1.resolve() + }, + }) + } else { + onLoaded1.resolve() + } + + const url = this._storageConfigClass.prototype._url + + $.ajax({ + type: 'GET', + url: OC.generateUrl(url), + contentType: 'application/json', + success(result) { + result = Object.values(result) + const onCompletion = jQuery.Deferred() + let $rows = $() + result.forEach(function(storageParams) { + storageParams.mountPoint = (storageParams.mountPoint === '/') ? '/' : storageParams.mountPoint.substr(1) // trim leading slash + const storageConfig = new self._storageConfigClass() + _.extend(storageConfig, storageParams) + const $tr = self.newStorage(storageConfig, onCompletion, true) + + // don't recheck config automatically when there are a large number of storages + if (result.length < 20) { + self.recheckStorageConfig($tr) + } else { + self.updateStatus($tr, StorageConfig.Status.INDETERMINATE, t('files_external', 'Automatic status checking is disabled due to the large number of configured storages, click to check status')) + } + $rows = $rows.add($tr) + }) + initApplicableUsersMultiselect($rows.find('.applicableUsers'), this._userListLimit) + self.$el.find('tr#addMountPoint').before($rows) + onCompletion.resolve() + onLoaded2.resolve() + }, + }) + }, + + /** + * @param {jQuery} $td + * @param {string} parameter + * @param {string} placeholder + * @param {Array} classes + * @return {jQuery} newly created input + */ + writeParameterInput($td, parameter, placeholder, classes) { + const hasFlag = function(flag) { + return (placeholder.flags & flag) === flag + } + classes = $.isArray(classes) ? classes : [] + classes.push('added') + if (hasFlag(MountConfigListView.ParameterFlags.OPTIONAL)) { + classes.push('optional') + } + + if (hasFlag(MountConfigListView.ParameterFlags.USER_PROVIDED)) { + if (this._isPersonal) { + classes.push('user_provided') + } else { + return + } + } + + let newElement + + const trimmedPlaceholder = placeholder.value + if (hasFlag(MountConfigListView.ParameterFlags.HIDDEN)) { + newElement = $('<input type="hidden" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" />') + } else if (placeholder.type === MountConfigListView.ParameterTypes.PASSWORD) { + newElement = $('<input type="password" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" placeholder="' + trimmedPlaceholder + '" />') + } else if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) { + const checkboxId = _.uniqueId('checkbox_') + newElement = $('<div><label><input type="checkbox" id="' + checkboxId + '" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" />' + trimmedPlaceholder + '</label></div>') + } else { + newElement = $('<input type="text" class="' + classes.join(' ') + '" data-parameter="' + parameter + '" placeholder="' + trimmedPlaceholder + '" />') + } + + if (placeholder.defaultValue) { + if (placeholder.type === MountConfigListView.ParameterTypes.BOOLEAN) { + newElement.find('input').prop('checked', placeholder.defaultValue) + } else { + newElement.val(placeholder.defaultValue) + } + } + + if (placeholder.tooltip) { + newElement.attr('title', placeholder.tooltip) + } + + highlightInput(newElement) + $td.append(newElement) + return newElement + }, + + /** + * Gets the storage model from the given row + * + * @param $tr row element + * @return {OCA.Files_External.StorageConfig} storage model instance + */ + getStorageConfig($tr) { + let storageId = $tr.data('id') + if (!storageId) { + // new entry + storageId = null + } + + let storage = $tr.data('storageConfig') + if (!storage) { + storage = new this._storageConfigClass(storageId) + } + storage.errors = null + storage.mountPoint = $tr.find('.mountPoint input').val() + storage.backend = $tr.find('.backend').data('identifier') + storage.authMechanism = $tr.find('.selectAuthMechanism').val() + + const classOptions = {} + const configuration = $tr.find('.configuration input') + const missingOptions = [] + $.each(configuration, function(index, input) { + const $input = $(input) + const parameter = $input.data('parameter') + if ($input.attr('type') === 'button') { + return + } + if (!isInputValid($input) && !$input.hasClass('optional')) { + missingOptions.push(parameter) + return + } + if ($(input).is(':checkbox')) { + if ($(input).is(':checked')) { + classOptions[parameter] = true + } else { + classOptions[parameter] = false + } + } else { + classOptions[parameter] = $(input).val() + } + }) + + storage.backendOptions = classOptions + if (missingOptions.length) { + storage.errors = { + backendOptions: missingOptions, + } + } + + // gather selected users and groups + if (!this._isPersonal) { + const multiselect = getSelectedApplicable($tr) + const users = multiselect.users || [] + const groups = multiselect.groups || [] + const isApplicableToAllUsers = $tr.find('.applicableToAllUsers').is(':checked') + + if (isApplicableToAllUsers) { + storage.applicableUsers = [] + storage.applicableGroups = [] + } else { + storage.applicableUsers = users + storage.applicableGroups = groups + + if (!storage.applicableUsers.length && !storage.applicableGroups.length) { + if (!storage.errors) { + storage.errors = {} + } + storage.errors.requiredApplicable = true + } + } + + storage.priority = parseInt($tr.find('input.priority').val() || '100', 10) + } + + const mountOptions = $tr.find('input.mountOptions').val() + if (mountOptions) { + storage.mountOptions = JSON.parse(mountOptions) + } + + return storage + }, + + /** + * Deletes the storage from the given tr + * + * @param $tr storage row + * @param Function callback callback to call after save + */ + deleteStorageConfig($tr) { + const self = this + const configId = $tr.data('id') + if (!_.isNumber(configId)) { + // deleting unsaved storage + $tr.remove() + return + } + const storage = new this._storageConfigClass(configId) + + OC.dialogs.confirm(t('files_external', 'Are you sure you want to disconnect this external storage? It will make the storage unavailable in Nextcloud and will lead to a deletion of these files and folders on any sync client that is currently connected but will not delete any files and folders on the external storage itself.', { + storage: this.mountPoint, + }), t('files_external', 'Delete storage?'), function(confirm) { + if (confirm) { + self.updateStatus($tr, StorageConfig.Status.IN_PROGRESS) + + storage.destroy({ + success() { + $tr.remove() + }, + error(result) { + const statusMessage = (result && result.responseJSON) ? result.responseJSON.message : undefined + self.updateStatus($tr, StorageConfig.Status.ERROR, statusMessage) + }, + }) + } + }) + }, + + /** + * Saves the storage from the given tr + * + * @param $tr storage row + * @param Function callback callback to call after save + * @param callback + * @param concurrentTimer only update if the timer matches this + */ + saveStorageConfig($tr, callback, concurrentTimer) { + const self = this + const storage = this.getStorageConfig($tr) + if (!storage || !storage.validate()) { + return false + } + + this.updateStatus($tr, StorageConfig.Status.IN_PROGRESS) + storage.save({ + success(result) { + if (concurrentTimer === undefined + || $tr.data('save-timer') === concurrentTimer + ) { + self.updateStatus($tr, result.status, result.statusMessage) + $tr.data('id', result.id) + + if (_.isFunction(callback)) { + callback(storage) + } + } + }, + error(result) { + if (concurrentTimer === undefined + || $tr.data('save-timer') === concurrentTimer + ) { + const statusMessage = (result && result.responseJSON) ? result.responseJSON.message : undefined + self.updateStatus($tr, StorageConfig.Status.ERROR, statusMessage) + } + }, + }) + }, + + /** + * Recheck storage availability + * + * @param {jQuery} $tr storage row + * @return {boolean} success + */ + recheckStorageConfig($tr) { + const self = this + const storage = this.getStorageConfig($tr) + if (!storage.validate()) { + return false + } + + this.updateStatus($tr, StorageConfig.Status.IN_PROGRESS) + storage.recheck({ + success(result) { + self.updateStatus($tr, result.status, result.statusMessage) + }, + error(result) { + const statusMessage = (result && result.responseJSON) ? result.responseJSON.message : undefined + self.updateStatus($tr, StorageConfig.Status.ERROR, statusMessage) + }, + }) + }, + + /** + * Update status display + * + * @param {jQuery} $tr + * @param {number} status + * @param {string} message + */ + updateStatus($tr, status, message) { + const $statusSpan = $tr.find('.status span') + switch (status) { + case null: + // remove status + $statusSpan.hide() + break + case StorageConfig.Status.IN_PROGRESS: + $statusSpan.attr('class', 'icon-loading-small') + break + case StorageConfig.Status.SUCCESS: + $statusSpan.attr('class', 'success icon-checkmark-white') + break + case StorageConfig.Status.INDETERMINATE: + $statusSpan.attr('class', 'indeterminate icon-info-white') + break + default: + $statusSpan.attr('class', 'error icon-error-white') + } + if (status !== null) { + $statusSpan.show() + } + if (typeof message !== 'string') { + message = t('files_external', 'Click to recheck the configuration') + } + $statusSpan.attr('title', message) + }, + + /** + * Suggest mount point name that doesn't conflict with the existing names in the list + * + * @param {string} defaultMountPoint default name + */ + _suggestMountPoint(defaultMountPoint) { + const $el = this.$el + const pos = defaultMountPoint.indexOf('/') + if (pos !== -1) { + defaultMountPoint = defaultMountPoint.substring(0, pos) + } + defaultMountPoint = defaultMountPoint.replace(/\s+/g, '') + let i = 1 + let append = '' + let match = true + while (match && i < 20) { + match = false + $el.find('tbody td.mountPoint input').each(function(index, mountPoint) { + if ($(mountPoint).val() === defaultMountPoint + append) { + match = true + return false + } + }) + if (match) { + append = i + i++ + } else { + break + } + } + return defaultMountPoint + append + }, + + /** + * Toggles the mount options dropdown + * + * @param {object} $tr configuration row + */ + _showMountOptionsDropdown($tr) { + const self = this + const storage = this.getStorageConfig($tr) + const $toggle = $tr.find('.mountOptionsToggle') + const dropDown = new MountOptionsDropdown() + const visibleOptions = [ + 'previews', + 'filesystem_check_changes', + 'enable_sharing', + 'encoding_compatibility', + 'readonly', + 'delete', + ] + if (this._encryptionEnabled) { + visibleOptions.push('encrypt') + } + dropDown.show($toggle, storage.mountOptions || [], visibleOptions) + $('body').on('mouseup.mountOptionsDropdown', function(event) { + const $target = $(event.target) + if ($target.closest('.popovermenu').length) { + return + } + dropDown.hide() + }) + + dropDown.$el.on('hide', function() { + const mountOptions = dropDown.getOptions() + $('body').off('mouseup.mountOptionsDropdown') + $tr.find('input.mountOptions').val(JSON.stringify(mountOptions)) + $tr.find('td.mountOptionsToggle>.icon-more').attr('aria-expanded', 'false') + self.saveStorageConfig($tr) + }) + }, +}, OC.Backbone.Events) + +window.addEventListener('DOMContentLoaded', function() { + const enabled = $('#files_external').attr('data-encryption-enabled') + const canCreateLocal = $('#files_external').attr('data-can-create-local') + const encryptionEnabled = (enabled === 'true') + const mountConfigListView = new MountConfigListView($('#externalStorage'), { + encryptionEnabled, + canCreateLocal: (canCreateLocal === 'true'), + }) + mountConfigListView.loadStorages() + + // TODO: move this into its own View class + const $allowUserMounting = $('#allowUserMounting') + $allowUserMounting.bind('change', function() { + OC.msg.startSaving('#userMountingMsg') + if (this.checked) { + OCP.AppConfig.setValue('files_external', 'allow_user_mounting', 'yes') + $('input[name="allowUserMountingBackends\\[\\]"]').prop('checked', true) + $('#userMountingBackends').removeClass('hidden') + $('input[name="allowUserMountingBackends\\[\\]"]').eq(0).trigger('change') + } else { + OCP.AppConfig.setValue('files_external', 'allow_user_mounting', 'no') + $('#userMountingBackends').addClass('hidden') + } + OC.msg.finishedSaving('#userMountingMsg', { status: 'success', data: { message: t('files_external', 'Saved') } }) + }) + + $('input[name="allowUserMountingBackends\\[\\]"]').bind('change', function() { + OC.msg.startSaving('#userMountingMsg') + + let userMountingBackends = $('input[name="allowUserMountingBackends\\[\\]"]:checked').map(function() { + return $(this).val() + }).get() + const deprecatedBackends = $('input[name="allowUserMountingBackends\\[\\]"][data-deprecate-to]').map(function() { + if ($.inArray($(this).data('deprecate-to'), userMountingBackends) !== -1) { + return $(this).val() + } + return null + }).get() + userMountingBackends = userMountingBackends.concat(deprecatedBackends) + + OCP.AppConfig.setValue('files_external', 'user_mounting_backends', userMountingBackends.join()) + OC.msg.finishedSaving('#userMountingMsg', { status: 'success', data: { message: t('files_external', 'Saved') } }) + + // disable allowUserMounting + if (userMountingBackends.length === 0) { + $allowUserMounting.prop('checked', false) + $allowUserMounting.trigger('change') + + } + }) + + $('#global_credentials').on('submit', async function(event) { + event.preventDefault() + const $form = $(this) + const $submit = $form.find('[type=submit]') + $submit.val(t('files_external', 'Saving …')) + + const uid = $form.find('[name=uid]').val() + const user = $form.find('[name=username]').val() + const password = $form.find('[name=password]').val() + + try { + await axios.request({ + method: 'POST', + data: { + uid, + user, + password, + }, + url: generateUrl('apps/files_external/globalcredentials'), + confirmPassword: PwdConfirmationMode.Strict, + }) + + $submit.val(t('files_external', 'Saved')) + setTimeout(function() { + $submit.val(t('files_external', 'Save')) + }, 2500) + } catch (error) { + $submit.val(t('files_external', 'Save')) + if (isAxiosError(error)) { + const message = error.response?.data?.message || t('files_external', 'Failed to save global credentials') + showError(t('files_external', 'Failed to save global credentials: {message}', { message })) + } + } + + return false + }) + + // global instance + OCA.Files_External.Settings.mountConfig = mountConfigListView + + /** + * Legacy + * + * @namespace + * @deprecated use OCA.Files_External.Settings.mountConfig instead + */ + OC.MountConfig = { + saveStorage: _.bind(mountConfigListView.saveStorageConfig, mountConfigListView), + } +}) + +// export + +OCA.Files_External = OCA.Files_External || {} +/** + * @namespace + */ +OCA.Files_External.Settings = OCA.Files_External.Settings || {} + +OCA.Files_External.Settings.GlobalStorageConfig = GlobalStorageConfig +OCA.Files_External.Settings.UserStorageConfig = UserStorageConfig +OCA.Files_External.Settings.MountConfigListView = MountConfigListView |