feat(files_external): migrate to vue

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
pull/39577/head
John Molakvoæ 10 months ago
parent 385f987a28
commit 38480fda3c
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF

@ -23,7 +23,7 @@
<span />
</template>
<script>
<script lang="ts">
/**
* This component is used to render custom
* elements provided by an API. Vue doesn't allow
@ -46,20 +46,29 @@ export default {
required: true,
},
},
computed: {
element() {
return this.render(this.source, this.currentView)
},
},
watch: {
element() {
this.$el.replaceWith(this.element)
this.$el = this.element
source() {
this.updateRootElement()
},
currentView() {
this.updateRootElement()
},
},
mounted() {
this.$el.replaceWith(this.element)
this.$el = this.element
this.updateRootElement()
},
methods: {
async updateRootElement() {
const span = document.createElement('span') as HTMLSpanElement
this.$el.replaceWith(span)
this.$el = span
const element = await this.render(this.source, this.currentView)
if (element) {
this.$el.replaceWith(element)
this.$el = element
}
},
},
}
</script>

@ -91,8 +91,12 @@
<!-- Actions -->
<td v-show="!isRenamingSmallScreen" :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
<!-- Inline actions -->
<!-- TODO: implement CustomElementRender -->
<!-- Render actions -->
<CustomElementRender v-for="action in enabledRenderActions"
:key="action.id"
:current-view="currentView"
:render="action.renderInline"
:source="source" />
<!-- Menu actions -->
<NcActions v-if="active"
@ -301,15 +305,16 @@ export default Vue.extend({
return formatFileSize(size, true)
},
sizeOpacity() {
const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) {
return 1
}
// Whatever theme is active, the contrast will pass WCAG AA
// with color main text over main background and an opacity of 0.7
const minOpacity = 0.7
const maxOpacitySize = 10 * 1024 * 1024
const size = parseInt(this.source.size, 10) || 0
if (!size || size < 0) {
return minOpacity
}
return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
},
@ -396,9 +401,17 @@ export default Vue.extend({
return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
},
// Enabled action that are displayed inline with a custom render function
enabledRenderActions() {
if (!this.active) {
return []
}
return this.enabledActions.filter(action => typeof action.renderInline === 'function')
},
// Default actions
enabledDefaultActions() {
return this.enabledActions.filter(action => !!action.default)
return this.enabledActions.filter(action => !!action?.default)
},
// Actions shown in the menu
@ -407,7 +420,7 @@ export default Vue.extend({
// Showing inline first for the NcActions inline prop
...this.enabledInlineActions,
// Then the rest
...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN),
...this.enabledActions.filter(action => action.default !== DefaultType.HIDDEN && typeof action.renderInline !== 'function'),
].filter((value, index, self) => {
// Then we filter duplicates to prevent inline actions to be shown twice
return index === self.findIndex(action => action.id === value.id)

@ -74,7 +74,7 @@ interface FileActionData {
* If defined, the returned html element will be
* appended before the actions menu.
*/
renderInline?: (file: Node, view: Navigation) => HTMLElement,
renderInline?: (file: Node, view: Navigation) => Promise<HTMLElement | null>,
}
export class FileAction {

@ -183,19 +183,24 @@ export default Vue.extend({
return this.isAscSorting ? results : results.reverse()
}
const identifiers = [
// Sort favorites first if enabled
...this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : [],
// Sort folders first if sorting by name
...this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : [],
// Use sorting mode if NOT basename (to be able to use displayName too)
...this.sortingMode !== 'basename' ? [v => v[this.sortingMode]] : [],
// Use displayName if available, fallback to name
v => v.attributes?.displayName || v.basename,
// Finally, use basename if all previous sorting methods failed
v => v.basename,
]
const orders = new Array(identifiers.length).fill(this.isAscSorting ? 'asc' : 'desc')
return orderBy(
[...(this.currentFolder?._children || []).map(this.getNode).filter(file => file)],
[
// Sort favorites first if enabled
...this.userConfig.sort_favorites_first ? [v => v.attributes?.favorite !== 1] : [],
// Sort folders first if sorting by name
...this.sortingMode === 'basename' ? [v => v.type !== 'folder'] : [],
// Use sorting mode
v => v[this.sortingMode],
// Finally, fallback to name
v => v.basename,
],
this.isAscSorting ? ['asc', 'asc', 'asc'] : ['desc', 'desc', 'desc'],
identifiers,
orders,
)
},

@ -62,5 +62,10 @@ return [
'url' => '/api/v1/mounts',
'verb' => 'GET',
],
[
'name' => 'Api#askNativeAuth',
'url' => '/api/v1/auth',
'verb' => 'GET',
],
],
];

@ -96,6 +96,7 @@ return array(
'OCA\\Files_External\\Lib\\Storage\\Swift' => $baseDir . '/../lib/Lib/Storage/Swift.php',
'OCA\\Files_External\\Lib\\VisibilityTrait' => $baseDir . '/../lib/Lib/VisibilityTrait.php',
'OCA\\Files_External\\Listener\\GroupDeletedListener' => $baseDir . '/../lib/Listener/GroupDeletedListener.php',
'OCA\\Files_External\\Listener\\LoadAdditionalListener' => $baseDir . '/../lib/Listener/LoadAdditionalListener.php',
'OCA\\Files_External\\Listener\\StorePasswordListener' => $baseDir . '/../lib/Listener/StorePasswordListener.php',
'OCA\\Files_External\\Listener\\UserDeletedListener' => $baseDir . '/../lib/Listener/UserDeletedListener.php',
'OCA\\Files_External\\Migration\\DummyUserSession' => $baseDir . '/../lib/Migration/DummyUserSession.php',

@ -111,6 +111,7 @@ class ComposerStaticInitFiles_External
'OCA\\Files_External\\Lib\\Storage\\Swift' => __DIR__ . '/..' . '/../lib/Lib/Storage/Swift.php',
'OCA\\Files_External\\Lib\\VisibilityTrait' => __DIR__ . '/..' . '/../lib/Lib/VisibilityTrait.php',
'OCA\\Files_External\\Listener\\GroupDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/GroupDeletedListener.php',
'OCA\\Files_External\\Listener\\LoadAdditionalListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalListener.php',
'OCA\\Files_External\\Listener\\StorePasswordListener' => __DIR__ . '/..' . '/../lib/Listener/StorePasswordListener.php',
'OCA\\Files_External\\Listener\\UserDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/UserDeletedListener.php',
'OCA\\Files_External\\Migration\\DummyUserSession' => __DIR__ . '/..' . '/../lib/Migration/DummyUserSession.php',

@ -3,7 +3,7 @@
'name' => '__root__',
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
'reference' => '706c141fffce928d344fe2f039da549fad065393',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
@ -13,7 +13,7 @@
'__root__' => array(
'pretty_version' => 'dev-master',
'version' => 'dev-master',
'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
'reference' => '706c141fffce928d344fe2f039da549fad065393',
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),

@ -1,4 +0,0 @@
.files-filestable tbody tr.externalErroredRow {
/* TODO: As soon as firefox supports it: color-mix(in srgb, var(--color-error) 15%, var(--color-main-background)) */
background-color: rgba(255, 0, 0, 0.13);
}

@ -1,112 +0,0 @@
/*
* Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
if (!OCA.Files_External) {
/**
* @namespace
*/
OCA.Files_External = {};
}
/**
* @namespace
*/
OCA.Files_External.App = {
fileList: null,
initList: function($el) {
if (this.fileList) {
return this.fileList;
}
this.fileList = new OCA.Files_External.FileList(
$el,
{
fileActions: this._createFileActions()
}
);
this._extendFileList(this.fileList);
this.fileList.appName = t('files_external', 'External storage');
return this.fileList;
},
removeList: function() {
if (this.fileList) {
this.fileList.$fileList.empty();
}
},
_createFileActions: function() {
// inherit file actions from the files app
var fileActions = new OCA.Files.FileActions();
fileActions.registerDefaultActions();
// when the user clicks on a folder, redirect to the corresponding
// folder in the files app instead of opening it directly
fileActions.register('dir', 'Open', OC.PERMISSION_READ, '', function (filename, context) {
OCA.Files.App.setActiveView('files', {silent: true});
OCA.Files.App.fileList.changeDirectory(OC.joinPaths(context.$file.attr('data-path'), filename), true, true);
});
fileActions.setDefault('dir', 'Open');
return fileActions;
},
_extendFileList: function(fileList) {
// remove size column from summary
fileList.fileSummary.$el.find('.filesize').remove();
}
};
window.addEventListener('DOMContentLoaded', function() {
$('#app-content-extstoragemounts').on('show', function(e) {
OCA.Files_External.App.initList($(e.target));
});
$('#app-content-extstoragemounts').on('hide', function() {
OCA.Files_External.App.removeList();
});
/* Status Manager */
if ($('#filesApp').val()) {
$('#app-content-files')
.add('#app-content-extstoragemounts')
.on('changeDirectory', function(e){
if (e.dir === '/') {
var mount_point = e.previousDir.split('/', 2)[1];
// Every time that we return to / root folder from a mountpoint, mount_point status is rechecked
OCA.Files_External.StatusManager.getMountPointList(function() {
OCA.Files_External.StatusManager.recheckConnectivityForMount([mount_point], true);
});
}
})
.on('fileActionsReady', function(e){
if ($.isArray(e.$files)) {
if (OCA.Files_External.StatusManager.mountStatus === null ||
OCA.Files_External.StatusManager.mountPointList === null ||
_.size(OCA.Files_External.StatusManager.mountStatus) !== _.size(OCA.Files_External.StatusManager.mountPointList)) {
// Will be the very first check when the files view will be loaded
OCA.Files_External.StatusManager.launchFullConnectivityCheckOneByOne();
} else {
// When we change between general files view and external files view
OCA.Files_External.StatusManager.getMountPointList(function(){
var fileNames = [];
$.each(e.$files, function(key, value){
fileNames.push(value.attr('data-file'));
});
// Recheck if launched but work from cache
OCA.Files_External.StatusManager.recheckConnectivityForMount(fileNames, false);
});
}
}
});
}
/* End Status Manager */
});

@ -1,149 +0,0 @@
/*
* Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
(function() {
/**
* @class OCA.Files_External.FileList
* @augments OCA.Files.FileList
*
* @classdesc External storage file list.
*
* Displays a list of mount points visible
* for the current user.
*
* @param $el container element with existing markup for the .files-controls
* and a table
* @param [options] map of options, see other parameters
**/
var FileList = function($el, options) {
this.initialize($el, options);
};
FileList.prototype = _.extend({}, OCA.Files.FileList.prototype,
/** @lends OCA.Files_External.FileList.prototype */ {
appName: 'External storage',
_allowSelection: false,
/**
* @private
*/
initialize: function($el, options) {
OCA.Files.FileList.prototype.initialize.apply(this, arguments);
if (this.initialized) {
return;
}
},
/**
* @param {OCA.Files_External.MountPointInfo} fileData
*/
_createRow: function(fileData) {
// TODO: hook earlier and render the whole row here
var $tr = OCA.Files.FileList.prototype._createRow.apply(this, arguments);
var $scopeColumn = $('<td class="column-scope column-last"><span></span></td>');
var $backendColumn = $('<td class="column-backend"></td>');
var scopeText = t('files_external', 'Personal');
if (fileData.scope === 'system') {
scopeText = t('files_external', 'System');
}
$tr.find('.filesize,.date').remove();
$scopeColumn.find('span').text(scopeText);
$backendColumn.text(fileData.backend);
$tr.find('td.filename').after($scopeColumn).after($backendColumn);
return $tr;
},
updateEmptyContent: function() {
var dir = this.getCurrentDirectory();
if (dir === '/') {
// root has special permissions
this.$el.find('.emptyfilelist.emptycontent').toggleClass('hidden', !this.isEmpty);
this.$el.find('.files-filestable thead th').toggleClass('hidden', this.isEmpty);
}
else {
OCA.Files.FileList.prototype.updateEmptyContent.apply(this, arguments);
}
},
getDirectoryPermissions: function() {
return OC.PERMISSION_READ | OC.PERMISSION_DELETE;
},
updateStorageStatistics: function() {
// no op because it doesn't have
// storage info like free space / used space
},
reload: function() {
this.showMask();
if (this._reloadCall?.abort) {
this._reloadCall.abort();
}
// there is only root
this._setCurrentDir('/', false);
this._reloadCall = $.ajax({
url: OC.linkToOCS('apps/files_external/api/v1') + 'mounts',
data: {
format: 'json'
},
type: 'GET',
beforeSend: function(xhr) {
xhr.setRequestHeader('OCS-APIREQUEST', 'true');
}
});
var callBack = this.reloadCallback.bind(this);
return this._reloadCall.then(callBack, callBack);
},
reloadCallback: function(result) {
delete this._reloadCall;
this.hideMask();
if (result.ocs && result.ocs.data) {
this.setFiles(this._makeFiles(result.ocs.data));
return true;
}
return false;
},
/**
* Converts the OCS API response data to a file info
* list
* @param OCS API mounts array
* @return array of file info maps
*/
_makeFiles: function(data) {
var files = _.map(data, function(fileData) {
fileData.icon = OC.imagePath('core', 'filetypes/folder-external');
fileData.mountType = 'external';
return fileData;
});
files.sort(this._sortComparator);
return files;
}
});
/**
* Mount point info attributes.
*
* @typedef {Object} OCA.Files_External.MountPointInfo
*
* @property {String} name mount point name
* @property {String} scope mount point scope "personal" or "system"
* @property {String} backend external storage backend name
*/
OCA.Files_External.FileList = FileList;
})();

@ -1,82 +0,0 @@
window.addEventListener('DOMContentLoaded', function() {
function displayGranted($tr) {
$tr.find('.configuration input.auth-param').attr('disabled', 'disabled').addClass('disabled-success');
}
OCA.Files_External.Settings.mountConfig.whenSelectAuthMechanism(function($tr, authMechanism, scheme, onCompletion) {
if (authMechanism === 'oauth1::oauth1') {
var config = $tr.find('.configuration');
config.append($(document.createElement('input'))
.addClass('button auth-param')
.attr('type', 'button')
.attr('value', t('files_external', 'Grant access'))
.attr('name', 'oauth1_grant')
);
onCompletion.then(function() {
var configured = $tr.find('[data-parameter="configured"]');
if ($(configured).val() == 'true') {
displayGranted($tr);
} else {
var app_key = $tr.find('.configuration [data-parameter="app_key"]').val();
var app_secret = $tr.find('.configuration [data-parameter="app_secret"]').val();
if (app_key != '' && app_secret != '') {
var pos = window.location.search.indexOf('oauth_token') + 12;
var token = $tr.find('.configuration [data-parameter="token"]');
if (pos != -1 && window.location.search.substr(pos, $(token).val().length) == $(token).val()) {
var token_secret = $tr.find('.configuration [data-parameter="token_secret"]');
var statusSpan = $tr.find('.status span');
statusSpan.removeClass();
statusSpan.addClass('waiting');
$.post(OC.filePath('files_external', 'ajax', 'oauth1.php'), { step: 2, app_key: app_key, app_secret: app_secret, request_token: $(token).val(), request_token_secret: $(token_secret).val() }, function(result) {
if (result && result.status == 'success') {
$(token).val(result.access_token);
$(token_secret).val(result.access_token_secret);
$(configured).val('true');
OCA.Files_External.Settings.mountConfig.saveStorageConfig($tr, function(status) {
if (status) {
displayGranted($tr);
}
});
} else {
OC.dialogs.alert(result.data.message, t('files_external', 'Error configuring OAuth1'));
}
});
}
}
}
});
}
});
$('#externalStorage').on('click', '[name="oauth1_grant"]', function(event) {
event.preventDefault();
var tr = $(this).parent().parent();
var app_key = $(this).parent().find('[data-parameter="app_key"]').val();
var app_secret = $(this).parent().find('[data-parameter="app_secret"]').val();
if (app_key != '' && app_secret != '') {
var configured = $(this).parent().find('[data-parameter="configured"]');
var token = $(this).parent().find('[data-parameter="token"]');
var token_secret = $(this).parent().find('[data-parameter="token_secret"]');
$.post(OC.filePath('files_external', 'ajax', 'oauth1.php'), { step: 1, app_key: app_key, app_secret: app_secret, callback: location.protocol + '//' + location.host + location.pathname }, function(result) {
if (result && result.status == 'success') {
$(configured).val('false');
$(token).val(result.data.request_token);
$(token_secret).val(result.data.request_token_secret);
OCA.Files_External.Settings.mountConfig.saveStorageConfig(tr, function() {
window.location = result.data.url;
});
} else {
OC.dialogs.alert(result.data.message, t('files_external', 'Error configuring OAuth1'));
}
});
} else {
OC.dialogs.alert(
t('files_external', 'Please provide a valid app key and secret.'),
t('files_external', 'Error configuring OAuth1')
);
}
});
});

@ -1,96 +0,0 @@
window.addEventListener('DOMContentLoaded', function() {
function displayGranted($tr) {
$tr.find('.configuration input.auth-param').attr('disabled', 'disabled').addClass('disabled-success');
}
OCA.Files_External.Settings.mountConfig.whenSelectAuthMechanism(function($tr, authMechanism, scheme, onCompletion) {
if (authMechanism === 'oauth2::oauth2') {
var config = $tr.find('.configuration');
config.append($(document.createElement('input'))
.addClass('button auth-param')
.attr('type', 'button')
.attr('value', t('files_external', 'Grant access'))
.attr('name', 'oauth2_grant')
);
onCompletion.then(function() {
var configured = $tr.find('[data-parameter="configured"]');
if ($(configured).val() == 'true') {
displayGranted($tr);
} else {
var client_id = $tr.find('.configuration [data-parameter="client_id"]').val();
var client_secret = $tr.find('.configuration [data-parameter="client_secret"]')
.val();
if (client_id != '' && client_secret != '') {
var params = {};
window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(m, key, value) {
params[key] = value;
});
if (params['code'] !== undefined) {
var token = $tr.find('.configuration [data-parameter="token"]');
var statusSpan = $tr.find('.status span');
statusSpan.removeClass();
statusSpan.addClass('waiting');
$.post(OC.filePath('files_external', 'ajax', 'oauth2.php'),
{
step: 2,
client_id: client_id,
client_secret: client_secret,
redirect: location.protocol + '//' + location.host + location.pathname,
code: params['code'],
}, function(result) {
if (result && result.status == 'success') {
$(token).val(result.data.token);
$(configured).val('true');
OCA.Files_External.Settings.mountConfig.saveStorageConfig($tr, function(status) {
if (status) {
displayGranted($tr);
}
});
} else {
OC.dialogs.alert(result.data.message,
t('files_external', 'Error configuring OAuth2')
);
}
}
);
}
}
}
});
}
});
$('#externalStorage').on('click', '[name="oauth2_grant"]', function(event) {
event.preventDefault();
var tr = $(this).parent().parent();
var configured = $(this).parent().find('[data-parameter="configured"]');
var client_id = $(this).parent().find('[data-parameter="client_id"]').val();
var client_secret = $(this).parent().find('[data-parameter="client_secret"]').val();
if (client_id != '' && client_secret != '') {
var token = $(this).parent().find('[data-parameter="token"]');
$.post(OC.filePath('files_external', 'ajax', 'oauth2.php'),
{
step: 1,
client_id: client_id,
client_secret: client_secret,
redirect: location.protocol + '//' + location.host + location.pathname,
}, function(result) {
if (result && result.status == 'success') {
$(configured).val('false');
$(token).val('false');
OCA.Files_External.Settings.mountConfig.saveStorageConfig(tr, function(status) {
window.location = result.data.url;
});
} else {
OC.dialogs.alert(result.data.message,
t('files_external', 'Error configuring OAuth2')
);
}
}
);
}
});
});

@ -1,64 +0,0 @@
window.addEventListener('DOMContentLoaded', function() {
OCA.Files_External.Settings.mountConfig.whenSelectAuthMechanism(function($tr, authMechanism, scheme, onCompletion) {
if (scheme === 'publickey' && authMechanism === 'publickey::rsa') {
var config = $tr.find('.configuration');
if ($(config).find('[name="public_key_generate"]').length === 0) {
setupTableRow($tr, config);
onCompletion.then(function() {
// If there's no private key, build one
if (0 === $(config).find('[data-parameter="private_key"]').val().length) {
generateKeys($tr);
}
});
}
}
});
$('#externalStorage').on('click', '[name="public_key_generate"]', function(event) {
event.preventDefault();
var tr = $(this).parent().parent();
generateKeys(tr);
});
function setupTableRow(tr, config) {
var selectList = document.createElement('select');
selectList.id = 'keyLength';
var options = [1024, 2048, 4096];
for (var i = 0; i < options.length; i++) {
var option = document.createElement('option');
option.value = options[i];
option.text = options[i];
selectList.appendChild(option);
}
$(config).append(selectList);
$(config).append($(document.createElement('input'))
.addClass('button auth-param')
.attr('type', 'button')
.attr('value', t('files_external', 'Generate keys'))
.attr('name', 'public_key_generate')
);
}
function generateKeys(tr) {
var config = $(tr).find('.configuration');
var keyLength = config.find('#keyLength').val();
$.post(OC.filePath('files_external', 'ajax', 'public_key.php'), {
keyLength: keyLength
}, function(result) {
if (result && result.status === 'success') {
$(config).find('[data-parameter="public_key"]').val(result.data.public_key).keyup();
$(config).find('[data-parameter="private_key"]').val(result.data.private_key);
OCA.Files_External.Settings.mountConfig.saveStorageConfig(tr, function() {
// Nothing to do
});
} else {
OC.dialogs.alert(result.data.message, t('files_external', 'Error generating key pair') );
}
});
}
});

@ -1,137 +0,0 @@
/**
* ownCloud
*
* @author Juan Pablo Villafañez Ramos <jvillafanez@owncloud.com>
* @author Jesus Macias Portela <jesus@owncloud.com>
* @copyright (C) 2014 ownCloud, Inc.
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
(function(){
/**
* Launch several functions at thee same time. The number of functions
* running at the same time is controlled by the queueWindow param
*
* The function list come in the following format:
*
* var flist = [
* {
* funcName: function () {
* var d = $.Deferred();
* setTimeout(function(){d.resolve();}, 1000);
* return d;
* }
* },
* {
* funcName: $.get,
* funcArgs: [
* OC.filePath('files_external', 'ajax', 'connectivityCheck.php'),
* {},
* function () {
* console.log('titoooo');
* }
* ]
* },
* {
* funcName: $.get,
* funcArgs: [
* OC.filePath('files_external', 'ajax', 'connectivityCheck.php')
* ],
* done: function () {
* console.log('yuupi');
* },
* always: function () {
* console.log('always done');
* }
* }
*];
*
* functions MUST implement the deferred interface
*
* @param functionList list of functions that the queue will run
* (check example above for the expected format)
* @param queueWindow specify the number of functions that will
* be executed at the same time
*/
var RollingQueue = function (functionList, queueWindow, callback) {
this.queueWindow = queueWindow || 1;
this.functionList = functionList;
this.callback = callback;
this.counter = 0;
this.runQueue = function() {
this.callbackCalled = false;
this.deferredsList = [];
if (!$.isArray(this.functionList)) {
throw "functionList must be an array";
}
for (var i = 0; i < this.queueWindow; i++) {
this.launchNext();
}
};
this.hasNext = function() {
return (this.counter in this.functionList);
};
this.launchNext = function() {
var currentCounter = this.counter++;
if (currentCounter in this.functionList) {
var funcData = this.functionList[currentCounter];
if ($.isFunction(funcData.funcName)) {
var defObj = funcData.funcName.apply(funcData.funcName, funcData.funcArgs);
this.deferredsList.push(defObj);
if ($.isFunction(funcData.done)) {
defObj.done(funcData.done);
}
if ($.isFunction(funcData.fail)) {
defObj.fail(funcData.fail);
}
if ($.isFunction(funcData.always)) {
defObj.always(funcData.always);
}
if (this.hasNext()) {
var self = this;
defObj.always(function(){
_.defer($.proxy(function(){
self.launchNext();
}, self));
});
} else {
if (!this.callbackCalled) {
this.callbackCalled = true;
if ($.isFunction(this.callback)) {
$.when.apply($, this.deferredsList)
.always($.proxy(function(){
this.callback();
}, this)
);
}
}
}
return defObj;
}
}
return false;
};
};
if (!OCA.Files_External) {
OCA.Files_External = {};
}
if (!OCA.Files_External.StatusManager) {
OCA.Files_External.StatusManager = {};
}
OCA.Files_External.StatusManager.RollingQueue = RollingQueue;
})();

@ -1,613 +0,0 @@
/**
* ownCloud
*
* @author Juan Pablo Villafañez Ramos <jvillafanez@owncloud.com>
* @author Jesus Macias Portela <jesus@owncloud.com>
* @copyright (C) 2014 ownCloud, Inc.
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
/** @global Handlebars */
if (!OCA.Files_External) {
OCA.Files_External = {};
}
if (!OCA.Files_External.StatusManager) {
OCA.Files_External.StatusManager = {};
}
OCA.Files_External.StatusManager = {
mountStatus: null,
mountPointList: null,
/**
* Function
* @param {callback} afterCallback
*/
getMountStatus: function (afterCallback) {
var self = this;
if (typeof afterCallback !== 'function' || self.isGetMountStatusRunning) {
return;
}
if (self.mountStatus) {
afterCallback(self.mountStatus);
}
},
/**
* Function Check mount point status from cache
* @param {string} mount_point
*/
getMountPointListElement: function (mount_point) {
var element;
$.each(this.mountPointList, function (key, value) {
if (value.mount_point === mount_point) {
element = value;
return false;
}
});
return element;
},
/**
* Function Check mount point status from cache
* @param {string} mount_point
* @param {string} mount_point
*/
getMountStatusForMount: function (mountData, afterCallback) {
var self = this;
if (typeof afterCallback !== 'function' || self.isGetMountStatusRunning) {
return $.Deferred().resolve();
}
var defObj;
if (self.mountStatus[mountData.mount_point]) {
defObj = $.Deferred();
afterCallback(mountData, self.mountStatus[mountData.mount_point]);
defObj.resolve(); // not really useful, but it'll keep the same behaviour
} else {
defObj = $.ajax({
type: 'GET',
url: OC.getRootPath() + '/index.php/apps/files_external/' + ((mountData.type === 'personal') ? 'userstorages' : 'userglobalstorages') + '/' + mountData.id,
data: {'testOnly' : false},
success: function (response) {
if (response && response.status === 0) {
self.mountStatus[mountData.mount_point] = response;
} else {
var statusCode = response.status ? response.status : 1;
var statusMessage = response.statusMessage ? response.statusMessage : t('files_external', 'Empty response from the server')
// failure response with error message
self.mountStatus[mountData.mount_point] = {
type: mountData.type,
status: statusCode,
id: mountData.id,
error: statusMessage,
userProvided: response.userProvided,
authMechanism: response.authMechanism,
canEdit: response.can_edit,
};
}
afterCallback(mountData, self.mountStatus[mountData.mount_point]);
},
error: function (jqxhr, state, error) {
var message;
if (mountData.location === 3) {
// In this case the error is because mount point use Login credentials and don't exist in the session
message = t('files_external', 'Couldn\'t access. Please log out and in again to activate this mount point');
} else {
message = t('files_external', 'Couldn\'t get the information from the remote server: {code} {type}', {
code: jqxhr.status,
type: error
});
}
self.mountStatus[mountData.mount_point] = {
type: mountData.type,
status: 1,
location: mountData.location,
error: message
};
afterCallback(mountData, self.mountStatus[mountData.mount_point]);
}
});
}
return defObj;
},
/**
* Function to get external mount point list from the files_external API
* @param {Function} afterCallback function to be executed
*/
getMountPointList: function (afterCallback) {
var self = this;
if (typeof afterCallback !== 'function' || self.isGetMountPointListRunning) {
return;
}
if (self.mountPointList) {
afterCallback(self.mountPointList);
} else {
self.isGetMountPointListRunning = true;
$.ajax({
type: 'GET',
url: OC.linkToOCS('apps/files_external/api/v1') + 'mounts?format=json',
success: function (response) {
self.mountPointList = [];
_.each(response.ocs.data, function (mount) {
var element = {};
element.mount_point = mount.name;
element.type = mount.scope;
element.location = "";
element.id = mount.id;
element.backendText = mount.backend;
element.backend = mount.class;
self.mountPointList.push(element);
});
afterCallback(self.mountPointList);
},
error: function (jqxhr, state, error) {
self.mountPointList = [];
OC.Notification.show(t('files_external', 'Couldn\'t get the list of external mount points: {type}',
{type: error}), {type: 'error'}
);
},
complete: function () {
self.isGetMountPointListRunning = false;
}
});
}
},
/**
* Function to manage action when a mountpoint status = 1 (Errored). Show a dialog to be redirected to settings page.
* @param {string} name MountPoint Name
*/
manageMountPointError: function (name) {
this.getMountStatus($.proxy(function (allMountStatus) {
if (allMountStatus.hasOwnProperty(name) && allMountStatus[name].status > 0 && allMountStatus[name].status < 7) {
var mountData = allMountStatus[name];
if (mountData.type === "system") {
if (mountData.userProvided || mountData.authMechanism === 'password::global::user') {
// personal mount whit credentials problems
this.showCredentialsDialog(name, mountData);
} else if (mountData.canEdit) {
OC.dialogs.confirm(t('files_external', 'There was an error with message: ') + mountData.error + '. Do you want to review mount point config in admin settings page?', t('files_external', 'External mount error'), function (e) {
if (e === true) {
OC.redirect(OC.generateUrl('/settings/admin/externalstorages'));
}
});
} else {
OC.dialogs.info(t('files_external', 'There was an error with message: ') + mountData.error + '. Please contact your system administrator.', t('files_external', 'External mount error'), () => {});
}
} else {
OC.dialogs.confirm(t('files_external', 'There was an error with message: ') + mountData.error + '. Do you want to review mount point config in personal settings page?', t('files_external', 'External mount error'), function (e) {
if (e === true) {
OC.redirect(OC.generateUrl('/settings/personal#' + t('files_external', 'external-storage')));
}
});
}
}
}, this));
},
/**
* Function to process a mount point in relation with their status, Called from Async Queue.
* @param {object} mountData
* @param {object} mountStatus
*/
processMountStatusIndividual: function (mountData, mountStatus) {
var mountPoint = mountData.mount_point;
if (mountStatus.status > 0) {
var trElement = FileList.findFileEl(OCA.Files_External.StatusManager.Utils.jqSelEscape(mountPoint));
var route = OCA.Files_External.StatusManager.Utils.getIconRoute(trElement) + '-error';
if (OCA.Files_External.StatusManager.Utils.isCorrectViewAndRootFolder()) {
OCA.Files_External.StatusManager.Utils.showIconError(mountPoint, $.proxy(OCA.Files_External.StatusManager.manageMountPointError, OCA.Files_External.StatusManager), route);
}
return false;
} else {
if (OCA.Files_External.StatusManager.Utils.isCorrectViewAndRootFolder()) {
OCA.Files_External.StatusManager.Utils.restoreFolder(mountPoint);
OCA.Files_External.StatusManager.Utils.toggleLink(mountPoint, true, true);
}
return true;
}
},
/**
* Function to process a mount point in relation with their status
* @param {object} mountData
* @param {object} mountStatus
*/
processMountList: function (mountList) {
var elementList = null;
$.each(mountList, function (name, value) {
var trElement = $('.files-fileList tr[data-file=\"' + OCA.Files_External.StatusManager.Utils.jqSelEscape(value.mount_point) + '\"]'); //FileList.findFileEl(OCA.Files_External.StatusManager.Utils.jqSelEscape(value.mount_point));
trElement.attr('data-external-backend', value.backend);
if (elementList) {
elementList = elementList.add(trElement);
} else {
elementList = trElement;
}
});
if (elementList instanceof $) {
if (OCA.Files_External.StatusManager.Utils.isCorrectViewAndRootFolder()) {
// Put their custom icon
OCA.Files_External.StatusManager.Utils.changeFolderIcon(elementList);
// Save default view
OCA.Files_External.StatusManager.Utils.storeDefaultFolderIconAndBgcolor(elementList);
OCA.Files_External.StatusManager.Utils.toggleLink(elementList.find('a.name'), false, false);
}
}
},
/**
* Function to process the whole mount point list in relation with their status (Async queue)
*/
launchFullConnectivityCheckOneByOne: function () {
var self = this;
this.getMountPointList(function (list) {
// check if we have a list first
if (list === undefined && !self.emptyWarningShown) {
self.emptyWarningShown = true;
OC.Notification.show(t('files_external', 'Couldn\'t fetch list of Windows network drive mount points: Empty response from server'),
{type: 'error'}
);
return;
}
if (list && list.length > 0) {
self.processMountList(list);
if (!self.mountStatus) {
self.mountStatus = {};
}
var ajaxQueue = [];
$.each(list, function (key, value) {
var queueElement = {
funcName: $.proxy(self.getMountStatusForMount, self),
funcArgs: [value,
$.proxy(self.processMountStatusIndividual, self)]
};
ajaxQueue.push(queueElement);
});
var rolQueue = new OCA.Files_External.StatusManager.RollingQueue(ajaxQueue, 4, function () {
if (!self.notificationHasShown) {
$.each(self.mountStatus, function (key, value) {
if (value.status === 1) {
self.notificationHasShown = true;
}
});
}
});
rolQueue.runQueue();
}
});
},
/**
* Function to process a mount point list in relation with their status (Async queue)
* @param {object} mountListData
* @param {boolean} recheck delete cached info and force api call to check mount point status
*/
launchPartialConnectivityCheck: function (mountListData, recheck) {
if (mountListData.length === 0) {
return;
}
var self = this;
var ajaxQueue = [];
$.each(mountListData, function (key, value) {
if (recheck && value.mount_point in self.mountStatus) {
delete self.mountStatus[value.mount_point];
}
var queueElement = {
funcName: $.proxy(self.getMountStatusForMount, self),
funcArgs: [value,
$.proxy(self.processMountStatusIndividual, self)]
};
ajaxQueue.push(queueElement);
});
new OCA.Files_External.StatusManager.RollingQueue(ajaxQueue, 4).runQueue();
},
/**
* Function to relaunch some mount point status check
* @param {string} mountListNames
* @param {boolean} recheck delete cached info and force api call to check mount point status
*/
recheckConnectivityForMount: function (mountListNames, recheck) {
if (mountListNames.length === 0) {
return;
}
var self = this;
var mountListData = [];
if (!self.mountStatus) {
self.mountStatus = {};
}
$.each(mountListNames, function (key, value) {
var mountData = self.getMountPointListElement(value);
if (mountData) {
mountListData.push(mountData);
}
});
// for all mounts in the list, delete the cached status values
if (recheck) {
$.each(mountListData, function (key, value) {
if (value.mount_point in self.mountStatus) {
delete self.mountStatus[value.mount_point];
}
});
}
self.processMountList(mountListData);
self.launchPartialConnectivityCheck(mountListData, recheck);
},
credentialsDialogTemplate:
'<div id="files_external_div_form"><div>' +
'<div>{{credentials_text}}</div>' +
'<form>' +
'<input type="text" name="username" placeholder="{{placeholder_username}}"/>' +
'<input type="password" name="password" placeholder="{{placeholder_password}}"/>' +
'</form>' +
'</div></div>',
/**
* Function to display custom dialog to enter credentials
* @param {any} mountPoint -
* @param {any} mountData -
*/
showCredentialsDialog: function (mountPoint, mountData) {
var dialog = $(OCA.Files_External.Templates.credentialsDialog({
credentials_text: t('files_external', 'Please enter the credentials for the {mount} mount', {
'mount': mountPoint
}),
placeholder_username: t('files_external', 'Username'),
placeholder_password: t('files_external', 'Password')
}));
$('body').append(dialog);
var apply = function () {
var username = dialog.find('[name=username]').val();
var password = dialog.find('[name=password]').val();
var endpoint = OC.generateUrl('apps/files_external/userglobalstorages/{id}', {
id: mountData.id
});
$('.oc-dialog-close').hide();
$.ajax({
type: 'PUT',
url: endpoint,
data: {
backendOptions: {
user: username,
password: password
}
},
success: function (data) {
OC.Notification.show(t('files_external', 'Credentials saved'), {type: 'success'});
dialog.ocdialog('close');
/* Trigger status check again */
OCA.Files_External.StatusManager.recheckConnectivityForMount([OC.basename(data.mountPoint)], true);
},
error: function () {
$('.oc-dialog-close').show();
OC.Notification.show(t('files_external', 'Credentials saving failed'), {type: 'error'});
}
});
return false;
};
var ocdialogParams = {
modal: true,
title: t('files_external', 'Credentials required'),
buttons: [{
text: t('files_external', 'Save'),
click: apply,
closeOnEscape: true
}],
closeOnExcape: true
};
dialog.ocdialog(ocdialogParams)
.bind('ocdialogclose', function () {
dialog.ocdialog('destroy').remove();
});
dialog.find('form').on('submit', apply);
dialog.find('form input:first').focus();
dialog.find('form input').keyup(function (e) {
if ((e.which && e.which === 13) || (e.keyCode && e.keyCode === 13)) {
$(e.target).closest('form').submit();
return false;
} else {
return true;
}
});
}
};
OCA.Files_External.StatusManager.Utils = {
showIconError: function (folder, clickAction, errorImageUrl) {
var imageUrl = "url(" + errorImageUrl + ")";
var trFolder = $('.files-fileList tr[data-file=\"' + OCA.Files_External.StatusManager.Utils.jqSelEscape(folder) + '\"]'); //FileList.findFileEl(OCA.Files_External.StatusManager.Utils.jqSelEscape(folder));
this.changeFolderIcon(folder, imageUrl);
this.toggleLink(folder, false, clickAction);
trFolder.addClass('externalErroredRow');
},
/**
* @param folder string with the folder or jQuery element pointing to the tr element
*/
storeDefaultFolderIconAndBgcolor: function (folder) {
var trFolder;
if (folder instanceof $) {
trFolder = folder;
} else {
trFolder = $('.files-fileList tr[data-file=\"' + OCA.Files_External.StatusManager.Utils.jqSelEscape(folder) + '\"]'); //FileList.findFileEl(OCA.Files_External.StatusManager.Utils.jqSelEscape(folder)); //$('.files-fileList tr[data-file=\"' + OCA.Files_External.StatusManager.Utils.jqSelEscape(folder) + '\"]');
}
trFolder.each(function () {
var thisElement = $(this);
if (thisElement.data('oldbgcolor') === undefined) {
thisElement.data('oldbgcolor', thisElement.css('background-color'));
}
});
var icon = trFolder.find('td.filename div.thumbnail');
icon.each(function () {
var thisElement = $(this);
if (thisElement.data('oldImage') === undefined) {
thisElement.data('oldImage', thisElement.css('background-image'));
}
});
},
/**
* @param folder string with the folder or jQuery element pointing to the tr element
*/
restoreFolder: function (folder) {
var trFolder;
if (folder instanceof $) {
trFolder = folder;
} else {
// can't use here FileList.findFileEl(OCA.Files_External.StatusManager.Utils.jqSelEscape(folder)); return incorrect instance of filelist
trFolder = $('.files-fileList tr[data-file=\"' + OCA.Files_External.StatusManager.Utils.jqSelEscape(folder) + '\"]');
}
var tdChilds = trFolder.find("td.filename div.thumbnail");
tdChilds.each(function () {
var thisElement = $(this);
thisElement.css('background-image', thisElement.data('oldImage'));
});
},
/**
* @param folder string with the folder or jQuery element pointing to the first td element
* of the tr matching the folder name
*/
changeFolderIcon: function (filename) {
var file;
var route;
if (filename instanceof $) {
//trElementList
$.each(filename, function (index) {
route = OCA.Files_External.StatusManager.Utils.getIconRoute($(this));
$(this).attr("data-icon", route);
$(this).find('td.filename div.thumbnail').css('background-image', "url(" + route + ")").css('display', 'none').css('display', 'inline');
});
} else {
file = $(".files-fileList tr[data-file=\"" + this.jqSelEscape(filename) + "\"] > td.filename div.thumbnail");
var parentTr = file.parents('tr:first');
route = OCA.Files_External.StatusManager.Utils.getIconRoute(parentTr);
parentTr.attr("data-icon", route);
file.css('background-image', "url(" + route + ")").css('display', 'none').css('display', 'inline');
}
},
/**
* @param backend string with the name of the external storage backend
* of the tr matching the folder name
*/
getIconRoute: function (tr) {
if (OCA.Theming) {
var icon = OC.generateUrl('/apps/theming/img/core/filetypes/folder-external.svg?v=' + OCA.Theming.cacheBuster);
} else {
var icon = OC.imagePath('core', 'filetypes/folder-external');
}
var backend = null;
if (tr instanceof $) {
backend = tr.attr('data-external-backend');
}
switch (backend) {
case 'windows_network_drive':
icon = OC.imagePath('windows_network_drive', 'folder-windows');
break;
}
return icon;
},
toggleLink: function (filename, active, action) {
var link;
if (filename instanceof $) {
link = filename;
} else {
link = $(".files-fileList tr[data-file=\"" + this.jqSelEscape(filename) + "\"] > td.filename a.name");
}
if (active) {
link.off('click.connectivity');
OCA.Files.App.fileList.fileActions.display(link.parent(), true, OCA.Files.App.fileList);
} else {
link.find('.fileactions, .nametext .action').remove(); // from files/js/fileactions (display)
link.off('click.connectivity');
link.on('click.connectivity', function (e) {
if (action && $.isFunction(action)) {
action(filename);
}
e.preventDefault();
return false;
});
}
},
isCorrectViewAndRootFolder: function () {
// correct views = files & extstoragemounts
if (OCA.Files.App.getActiveView() === 'files' || OCA.Files.App.getActiveView() === 'extstoragemounts') {
return OCA.Files.App.currentFileList.getCurrentDirectory() === '/';
}
return false;
},
/* escape a selector expression for jQuery */
jqSelEscape: function (expression) {
if (expression) {
return expression.replace(/[!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~]/g, '\\$&');
}
return null;
},
/* Copied from http://stackoverflow.com/questions/2631001/javascript-test-for-existence-of-nested-object-key */
checkNested: function (cobj /*, level1, level2, ... levelN*/) {
var args = Array.prototype.slice.call(arguments),
obj = args.shift();
for (var i = 0; i < args.length; i++) {
if (!obj || !obj.hasOwnProperty(args[i])) {
return false;
}
obj = obj[args[i]];
}
return true;
}
};

@ -29,6 +29,7 @@
*/
namespace OCA\Files_External\AppInfo;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_External\Config\ConfigAdapter;
use OCA\Files_External\Config\UserPlaceholderHandler;
use OCA\Files_External\Lib\Auth\AmazonS3\AccessKey;
@ -62,6 +63,7 @@ use OCA\Files_External\Lib\Backend\Swift;
use OCA\Files_External\Lib\Config\IAuthMechanismProvider;
use OCA\Files_External\Lib\Config\IBackendProvider;
use OCA\Files_External\Listener\GroupDeletedListener;
use OCA\Files_External\Listener\LoadAdditionalListener;
use OCA\Files_External\Listener\UserDeletedListener;
use OCA\Files_External\Service\BackendService;
use OCP\AppFramework\App;
@ -78,6 +80,7 @@ require_once __DIR__ . '/../../3rdparty/autoload.php';
* @package OCA\Files_External\AppInfo
*/
class Application extends App implements IBackendProvider, IAuthMechanismProvider, IBootstrap {
public const APP_ID = 'files_external';
/**
* Application constructor.
@ -85,28 +88,19 @@ class Application extends App implements IBackendProvider, IAuthMechanismProvide
* @throws \OCP\AppFramework\QueryException
*/
public function __construct(array $urlParams = []) {
parent::__construct('files_external', $urlParams);
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(GroupDeletedEvent::class, GroupDeletedListener::class);
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class);
}
public function boot(IBootContext $context): void {
$context->injectFn(function (IMountProviderCollection $mountProviderCollection, ConfigAdapter $configAdapter) {
$mountProviderCollection->registerProvider($configAdapter);
});
\OCA\Files\App::getNavigationManager()->add(function () {
$l = \OC::$server->getL10N('files_external');
return [
'id' => 'extstoragemounts',
'appname' => 'files_external',
'script' => 'list.php',
'order' => 30,
'name' => $l->t('External storage'),
];
});
$context->injectFn(function (BackendService $backendService, UserPlaceholderHandler $userConfigHandler) {
$backendService->registerBackendProvider($this);
$backendService->registerAuthMechanismProvider($this);

@ -37,30 +37,22 @@ use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
/**
* @psalm-import-type FilesExternalMount from ResponseDefinitions
*/
class ApiController extends OCSController {
/** @var IUserSession */
private $userSession;
/** @var UserGlobalStoragesService */
private $userGlobalStoragesService;
/** @var UserStoragesService */
private $userStoragesService;
private UserGlobalStoragesService $userGlobalStoragesService;
private UserStoragesService $userStoragesService;
public function __construct(
string $appName,
IRequest $request,
IUserSession $userSession,
UserGlobalStoragesService $userGlobalStorageService,
UserStoragesService $userStorageService
) {
parent::__construct($appName, $request);
$this->userSession = $userSession;
$this->userGlobalStoragesService = $userGlobalStorageService;
$this->userStoragesService = $userStorageService;
}
@ -89,14 +81,15 @@ class ApiController extends OCSController {
}
$entry = [
'id' => $mountConfig->getId(),
'type' => 'dir',
'name' => basename($mountPoint),
'path' => $path,
'type' => 'dir',
'backend' => $mountConfig->getBackend()->getText(),
'scope' => $isSystemMount ? 'system' : 'personal',
'permissions' => $permissions,
'id' => $mountConfig->getId(),
'scope' => $isSystemMount ? 'system' : 'personal',
'backend' => $mountConfig->getBackend()->getText(),
'class' => $mountConfig->getBackend()->getIdentifier(),
'config' => $mountConfig->jsonSerialize(true),
];
return $entry;
}
@ -127,4 +120,31 @@ class ApiController extends OCSController {
return new DataResponse($entries);
}
/**
* @NoAdminRequired
*
* Ask for credentials using a browser's native basic auth prompt
* Then returns it if provided
*/
public function askNativeAuth(): DataResponse {
if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) {
$response = new DataResponse([], Http::STATUS_UNAUTHORIZED);
$response->addHeader('WWW-Authenticate', 'Basic realm="Storage authentification needed"');
return $response;
}
$user = $_SERVER['PHP_AUTH_USER'];
$password = $_SERVER['PHP_AUTH_PW'];
// Reset auth
unset($_SERVER['PHP_AUTH_USER']);
unset($_SERVER['PHP_AUTH_PW']);
// Using 401 again to ensure we clear any cached Authorization
return new DataResponse([
'user' => $user,
'password' => $password,
], Http::STATUS_UNAUTHORIZED);
}
}

@ -134,7 +134,7 @@ class GlobalStoragesController extends StoragesController {
$this->updateStorageStatus($newStorage);
return new DataResponse(
$this->formatStorageForUI($newStorage),
$newStorage->jsonSerialize(true),
Http::STATUS_CREATED
);
}
@ -201,7 +201,7 @@ class GlobalStoragesController extends StoragesController {
$this->updateStorageStatus($storage, $testOnly);
return new DataResponse(
$this->formatStorageForUI($storage),
$storage->jsonSerialize(true),
Http::STATUS_OK
);
}

@ -276,7 +276,7 @@ abstract class StoragesController extends Controller {
* @return DataResponse
*/
public function index() {
$storages = $this->formatStoragesForUI($this->service->getStorages());
$storages = array_map(static fn ($storage) => $storage->jsonSerialize(true), $this->service->getStorages());
return new DataResponse(
$storages,
@ -284,29 +284,6 @@ abstract class StoragesController extends Controller {
);
}
protected function formatStoragesForUI(array $storages): array {
return array_map(function ($storage) {
return $this->formatStorageForUI($storage);
}, $storages);
}
protected function formatStorageForUI(StorageConfig $storage): StorageConfig {
/** @var DefinitionParameter[] $parameters */
$parameters = array_merge($storage->getBackend()->getParameters(), $storage->getAuthMechanism()->getParameters());
$options = $storage->getBackendOptions();
foreach ($options as $key => $value) {
foreach ($parameters as $parameter) {
if ($parameter->getName() === $key && $parameter->getType() === DefinitionParameter::VALUE_PASSWORD) {
$storage->setBackendOption($key, DefinitionParameter::UNMODIFIED_PLACEHOLDER);
break;
}
}
}
return $storage;
}
/**
* Get an external storage entry.
*
@ -329,7 +306,7 @@ abstract class StoragesController extends Controller {
);
}
$data = $this->formatStorageForUI($storage)->jsonSerialize();
$data = $storage->jsonSerialize(true);
$isAdmin = $this->groupManager->isAdmin($this->userSession->getUser()->getUID());
$data['can_edit'] = $storage->getType() === StorageConfig::MOUNT_TYPE_PERSONAl || $isAdmin;

@ -88,12 +88,13 @@ class UserGlobalStoragesController extends StoragesController {
* @NoAdminRequired
*/
public function index() {
$storages = $this->formatStoragesForUI($this->service->getUniqueStorages());
// remove configuration data, this must be kept private
foreach ($storages as $storage) {
/** @var UserGlobalStoragesService */
$service = $this->service;
$storages = array_map(function ($storage) {
// remove configuration data, this must be kept private
$this->sanitizeStorage($storage);
}
return $storage->jsonSerialize(true);
}, $service->getUniqueStorages());
return new DataResponse(
$storages,
@ -135,7 +136,7 @@ class UserGlobalStoragesController extends StoragesController {
$this->sanitizeStorage($storage);
$data = $this->formatStorageForUI($storage)->jsonSerialize();
$data = $storage->jsonSerialize(true);
$isAdmin = $this->groupManager->isAdmin($this->userSession->getUser()->getUID());
$data['can_edit'] = $storage->getType() === StorageConfig::MOUNT_TYPE_PERSONAl || $isAdmin;
@ -189,7 +190,7 @@ class UserGlobalStoragesController extends StoragesController {
$this->sanitizeStorage($storage);
return new DataResponse(
$this->formatStorageForUI($storage),
$storage->jsonSerialize(true),
Http::STATUS_OK
);
}

@ -159,7 +159,7 @@ class UserStoragesController extends StoragesController {
$this->updateStorageStatus($newStorage);
return new DataResponse(
$this->formatStorageForUI($newStorage),
$newStorage->jsonSerialize(true),
Http::STATUS_CREATED
);
}
@ -219,7 +219,7 @@ class UserStoragesController extends StoragesController {
$this->updateStorageStatus($storage, $testOnly);
return new DataResponse(
$this->formatStorageForUI($storage),
$storage->jsonSerialize(true),
Http::STATUS_OK
);
}

@ -58,6 +58,10 @@ class SessionCredentials extends AuthMechanism {
throw new InsufficientDataForMeaningfulAnswerException('No session credentials saved');
}
if ($user === null) {
throw new StorageAuthException('Session unavailable');
}
if ($credentials->getUID() !== $user->getUID()) {
throw new StorageAuthException('Session credentials for storage owner not available');
}

@ -397,11 +397,17 @@ class StorageConfig implements \JsonSerializable {
/**
* Serialize config to JSON
*/
public function jsonSerialize(): array {
public function jsonSerialize(bool $obfuscate = false): array {
$result = [];
if (!is_null($this->id)) {
$result['id'] = $this->id;
}
// obfuscate sensitive data if requested
if ($obfuscate) {
$this->formatStorageForUI();
}
$result['mountPoint'] = $this->mountPoint;
$result['backend'] = $this->backend->getIdentifier();
$result['authMechanism'] = $this->authMechanism->getIdentifier();
@ -428,4 +434,19 @@ class StorageConfig implements \JsonSerializable {
$result['type'] = ($this->getType() === self::MOUNT_TYPE_PERSONAl) ? 'personal': 'system';
return $result;
}
protected function formatStorageForUI(): void {
/** @var DefinitionParameter[] $parameters */
$parameters = array_merge($this->getBackend()->getParameters(), $this->getAuthMechanism()->getParameters());
$options = $this->getBackendOptions();
foreach ($options as $key => $value) {
foreach ($parameters as $parameter) {
if ($parameter->getName() === $key && $parameter->getType() === DefinitionParameter::VALUE_PASSWORD) {
$this->setBackendOption($key, DefinitionParameter::UNMODIFIED_PLACEHOLDER);
break;
}
}
}
}
}

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files_External\Listener;
use OCA\Files_External\AppInfo\Application;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IConfig;
use OCP\Util;
/**
* @template-implements IEventListener<Event|LoadAdditionalScriptsEvent>
*/
class LoadAdditionalListener implements IEventListener {
public function __construct(
private IConfig $config,
private IInitialState $initialState,
) {}
public function handle(Event $event): void {
if (!($event instanceof LoadAdditionalScriptsEvent)) {
return;
}
$allowUserMounting = $this->config->getAppValue('files_external', 'allow_user_mounting', 'no') === 'yes';
$this->initialState->provideInitialState('allowUserMounting', $allowUserMounting);
Util::addScript(Application::APP_ID, 'main', 'files');
}
}

@ -35,6 +35,7 @@ namespace OCA\Files_External;
* permissions: int,
* id: int,
* class: string,
* config: array<array-key, mixed>,
* }
*/
class ResponseDefinitions {

@ -1,46 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Jesús Macias <jmacias@solidgear.es>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Julius Härtl <jus@bitgrid.net>
* @author Vincent Petry <vincent@nextcloud.com>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
$config = \OC::$server->getConfig();
$userSession = \OC::$server->getUserSession();
$showgridview = $config->getUserValue($userSession->getUser()->getUID(), 'files', 'show_grid', true);
$tmpl = new OCP\Template('files_external', 'list', '');
// gridview not available for ie
$tmpl->assign('showgridview', $showgridview);
/* Load Status Manager */
\OCP\Util::addStyle('files_external', 'external');
\OCP\Util::addScript('files_external', 'statusmanager');
\OCP\Util::addScript('files_external', 'templates');
\OCP\Util::addScript('files_external', 'rollingqueue');
OCP\Util::addScript('files_external', 'app');
OCP\Util::addScript('files_external', 'mountsfilelist');
$tmpl->printPage();

@ -0,0 +1,145 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { action } from './enterCredentialsAction'
import { expect } from '@jest/globals'
import { File, Folder, Permission } from '@nextcloud/files'
import { DefaultType, FileAction } from '../../../files/src/services/FileAction'
import type { Navigation } from '../../../files/src/services/Navigation'
import type { StorageConfig } from '../services/externalStorage'
import { STORAGE_STATUS } from '../utils/credentialsUtils'
const view = {
id: 'files',
name: 'Files',
} as Navigation
const externalStorageView = {
id: 'extstoragemounts',
name: 'External storage',
} as Navigation
describe('Enter credentials action conditions tests', () => {
test('Default values', () => {
const storage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
config: {
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('credentials-external-storage')
expect(action.displayName([storage], externalStorageView)).toBe('Enter missing credentials')
expect(action.iconSvgInline([storage], externalStorageView)).toBe('<svg>SvgMock</svg>')
expect(action.default).toBe(DefaultType.DEFAULT)
expect(action.order).toBe(-1000)
expect(action.inline!(storage, externalStorageView)).toBe(true)
})
})
describe('Enter credentials action enabled tests', () => {
const storage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
scope: 'system',
backend: 'SFTP',
config: {
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
const userProvidedStorage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
scope: 'system',
backend: 'SFTP',
config: {
status: STORAGE_STATUS.INCOMPLETE_CONF,
userProvided: true,
} as StorageConfig,
},
})
const globalAuthUserStorage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
scope: 'system',
backend: 'SFTP',
config: {
status: STORAGE_STATUS.INCOMPLETE_CONF,
authMechanism: 'password::global::user',
} as StorageConfig,
},
})
const notAStorage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
})
test('Disabled with on success storage', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([storage], externalStorageView)).toBe(false)
})
test('Disabled for multiple nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([storage, storage], view)).toBe(false)
})
test('Enabled for missing user auth storage', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([userProvidedStorage], view)).toBe(true)
})
test('Enabled for missing global user auth storage', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([globalAuthUserStorage], view)).toBe(true)
})
test('Disabled for normal nodes', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([notAStorage], view)).toBe(false)
})
})

@ -0,0 +1,110 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
// eslint-disable-next-line n/no-extraneous-import
import type { AxiosResponse } from 'axios'
import type { Node } from '@nextcloud/files'
import type { StorageConfig } from '../services/externalStorage'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import LoginSvg from '@mdi/svg/svg/login.svg?raw'
import Vue from 'vue'
import { registerFileAction, FileAction, DefaultType } from '../../../files/src/services/FileAction'
import { STORAGE_STATUS, isMissingAuthConfig } from '../utils/credentialsUtils'
import { isNodeExternalStorage } from '../utils/externalStorageUtils'
type OCSAuthResponse = {
ocs: {
meta: {
status: string
statuscode: number
message: string
},
data: {
user?: string,
password?: string,
}
}
}
export const action = new FileAction({
id: 'credentials-external-storage',
displayName: () => t('files', 'Enter missing credentials'),
iconSvgInline: () => LoginSvg,
enabled: (nodes: Node[]) => {
// Only works on single node
if (nodes.length !== 1) {
return false
}
const node = nodes[0]
if (!isNodeExternalStorage(node)) {
return false
}
const config = (node.attributes?.config || {}) as StorageConfig
if (isMissingAuthConfig(config)) {
return true
}
return false
},
async exec(node: Node) {
// always resolve auth request, we'll process the data afterwards
const response = await axios.get(generateOcsUrl('/apps/files_external/api/v1/auth'), {
validateStatus: () => true,
})
const data = (response?.data || {}) as OCSAuthResponse
if (data.ocs.data.user && data.ocs.data.password) {
const configResponse = await axios.put(generateUrl('apps/files_external/userglobalstorages/{id}', node.attributes), {
backendOptions: data.ocs.data,
}) as AxiosResponse<StorageConfig>
const config = configResponse.data
if (config.status !== STORAGE_STATUS.SUCCESS) {
showError(t('files_external', 'Unable to update this external storage config. {statusMessage}', {
statusMessage: config?.statusMessage || '',
}))
return null
}
// Success update config attribute
showSuccess(t('files_external', 'New configuration successfully saved'))
Vue.set(node.attributes, 'config', config)
}
return null
},
// Before openFolderAction
order: -1000,
default: DefaultType.DEFAULT,
inline: () => true,
})
registerFileAction(action)

@ -0,0 +1,96 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
// eslint-disable-next-line n/no-extraneous-import
import type { AxiosError } from 'axios'
import type { Node } from '@nextcloud/files'
import { showWarning } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import AlertSvg from '@mdi/svg/svg/alert-circle.svg?raw'
import Vue from 'vue'
import '../css/fileEntryStatus.scss'
import { getStatus, type StorageConfig } from '../services/externalStorage'
import { isMissingAuthConfig, STORAGE_STATUS } from '../utils/credentialsUtils'
import { isNodeExternalStorage } from '../utils/externalStorageUtils'
import { registerFileAction, FileAction } from '../../../files/src/services/FileAction'
export const action = new FileAction({
id: 'check-external-storage',
displayName: () => '',
iconSvgInline: () => '',
enabled: (nodes: Node[]) => {
return nodes.every(node => isNodeExternalStorage(node) === true)
},
exec: async () => null,
/**
* Use this function to check the storage availability
* We then update the node attributes directly.
*/
async renderInline(node: Node) {
let config = null as any as StorageConfig
try {
const response = await getStatus(node.attributes.id, node.attributes.scope === 'system')
config = response.data
Vue.set(node.attributes, 'config', config)
if (config.status !== STORAGE_STATUS.SUCCESS) {
throw new Error(config?.statusMessage || t('files_external', 'There was an error with this external storage.'))
}
return null
} catch (error) {
// If axios failed or if something else prevented
// us from getting the config
if ((error as AxiosError).response && !config) {
showWarning(t('files_external', 'We were unable to check the external storage {basename}', {
basename: node.basename,
}))
return null
}
// Checking if we really have an error
const isWarning = isMissingAuthConfig(config)
const overlay = document.createElement('span')
overlay.classList.add(`files-list__row-status--${isWarning ? 'warning' : 'error'}`)
const span = document.createElement('span')
span.className = 'files-list__row-status'
// Only show an icon for errors, warning like missing credentials
// have a dedicated inline action button
if (!isWarning) {
span.innerHTML = AlertSvg
span.title = (error as Error).message
}
span.prepend(overlay)
return span
}
},
order: 10,
})
registerFileAction(action)

@ -0,0 +1,140 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { action } from './openInFilesAction'
import { expect } from '@jest/globals'
import { File, Folder, Permission } from '@nextcloud/files'
import { DefaultType, FileAction } from '../../../files/src/services/FileAction'
import type { Navigation } from '../../../files/src/services/Navigation'
import type { StorageConfig } from '../services/externalStorage'
import { STORAGE_STATUS } from '../utils/credentialsUtils'
const view = {
id: 'files',
name: 'Files',
} as Navigation
const externalStorageView = {
id: 'extstoragemounts',
name: 'External storage',
} as Navigation
describe('Open in files action conditions tests', () => {
test('Default values', () => {
const storage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
config: {
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('open-in-files-external-storage')
expect(action.displayName([storage], externalStorageView)).toBe('Open in files')
expect(action.iconSvgInline([storage], externalStorageView)).toBe('')
expect(action.default).toBe(DefaultType.HIDDEN)
expect(action.order).toBe(-1000)
expect(action.inline).toBeUndefined()
})
test('Default values', () => {
const failingStorage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
config: {
status: STORAGE_STATUS.ERROR,
} as StorageConfig,
},
})
expect(action.displayName([failingStorage], externalStorageView)).toBe('Examine this faulty external storage configuration')
})
})
describe('Open in files action enabled tests', () => {
test('Enabled with on valid view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], externalStorageView)).toBe(true)
})
test('Disabled on wrong view', () => {
expect(action.enabled).toBeDefined()
expect(action.enabled!([], view)).toBe(false)
})
})
describe('Open in files action execute tests', () => {
test('Open in files', async () => {
const goToRouteMock = jest.fn()
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const storage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
config: {
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
const exec = await action.exec(storage, externalStorageView, '/')
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
expect(goToRouteMock).toBeCalledWith(null, { view: 'files' }, { dir: '/Foo/Bar' })
})
test('Open in files broken storage', async () => {
const confirmMock = jest.fn()
window.OC = { dialogs: { confirm: confirmMock } }
const storage = new Folder({
id: 1,
source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/Bar',
owner: 'admin',
root: '/files/admin',
permissions: Permission.ALL,
attributes: {
config: {
status: STORAGE_STATUS.ERROR,
} as StorageConfig,
},
})
const exec = await action.exec(storage, externalStorageView, '/')
// Silent action
expect(exec).toBe(null)
expect(confirmMock).toBeCalledTimes(1)
})
})

@ -0,0 +1,75 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { Node } from '@nextcloud/files'
import type { StorageConfig } from '../services/externalStorage'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import { registerFileAction, FileAction, DefaultType } from '../../../files/src/services/FileAction'
import { STORAGE_STATUS } from '../utils/credentialsUtils'
export const action = new FileAction({
id: 'open-in-files-external-storage',
displayName: (nodes: Node[]) => {
const config = nodes?.[0]?.attributes?.config as StorageConfig || { status: STORAGE_STATUS.INDETERMINATE }
if (config.status !== STORAGE_STATUS.SUCCESS) {
return t('files_external', 'Examine this faulty external storage configuration')
}
return t('files', 'Open in files')
},
iconSvgInline: () => '',
enabled: (nodes: Node[], view) => view.id === 'extstoragemounts',
async exec(node: Node) {
const config = node.attributes.config as StorageConfig
if (config?.status !== STORAGE_STATUS.SUCCESS) {
window.OC.dialogs.confirm(
t('files_external', 'There was an error with this external storage. Do you want to review this mount point config in the settings page?'),
t('files_external', 'External mount error'),
(redirect) => {
if (redirect === true) {
const scope = node.attributes.scope === 'personal' ? 'user' : 'admin'
window.location.href = generateUrl(`/settings/${scope}/externalstorages`)
}
},
)
return null
}
// Do not use fileid as we don't have that information
// from the external storage api
window.OCP.Files.Router.goToRoute(
null, // use default route
{ view: 'files' },
{ dir: node.path },
)
return null
},
// Before openFolderAction
order: -1000,
default: DefaultType.HIDDEN,
})
registerFileAction(action)

@ -0,0 +1,36 @@
.files-list__row-status {
display: flex;
width: 44px;
justify-content: center;
align-items: center;
height: 100%;
svg {
width: 24px;
height: 24px;
path {
fill: currentColor;
}
}
&--error,
&--warning {
position: absolute;
display: block;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: .1;
z-index: -1;
}
&--error {
background: var(--color-error);
}
&--warning {
background: var(--color-warning);
}
}

@ -0,0 +1,77 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type NavigationService from '../../files/src/services/Navigation'
import type { Navigation } from '../../files/src/services/Navigation'
import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import FolderNetworkSvg from '@mdi/svg/svg/folder-network.svg?raw'
import './actions/enterCredentialsAction'
import './actions/inlineStorageCheckAction'
import './actions/openInFilesAction'
import { getContents } from './services/externalStorage'
const allowUserMounting = loadState('files_external', 'allowUserMounting', false)
const Navigation = window.OCP.Files.Navigation as NavigationService
Navigation.register({
id: 'extstoragemounts',
name: t('files_external', 'External storage'),
caption: t('files_external', 'List of external storage.'),
emptyCaption: allowUserMounting
? t('files_external', 'There is no external storage configured. You can configure them in your Personal settings.')
: t('files_external', 'There is no external storage configured and you don\'t have the permission to configure them.'),
emptyTitle: t('files_external', 'No external storage'),
icon: FolderNetworkSvg,
order: 30,
columns: [
{
id: 'storage-type',
title: t('files_external', 'Storage type'),
render(node) {
const backend = node.attributes?.backend || t('files_external', 'Unknown')
const span = document.createElement('span')
span.textContent = backend
return span
},
},
{
id: 'scope',
title: t('files_external', 'Scope'),
render(node) {
const span = document.createElement('span')
let scope = t('files_external', 'Personal')
if (node.attributes?.scope === 'system') {
scope = t('files_external', 'System')
}
span.textContent = scope
return span
},
},
],
getContents,
} as Navigation)

@ -0,0 +1,104 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
// eslint-disable-next-line n/no-extraneous-import
import type { AxiosResponse } from 'axios'
import type { ContentsWithRoot } from '../../../files/src/services/Navigation'
import type { OCSResponse } from '../../../files_sharing/src/services/SharingService'
import { Folder, Permission } from '@nextcloud/files'
import { generateOcsUrl, generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { STORAGE_STATUS } from '../utils/credentialsUtils'
export const rootPath = `/files/${getCurrentUser()?.uid}`
export type StorageConfig = {
applicableUsers?: string[]
applicableGroups?: string[]
authMechanism: string
backend: string
backendOptions: Record<string, string>
can_edit: boolean
id: number
mountOptions?: Record<string, string>
mountPoint: string
priority: number
status: number
statusMessage: string
type: 'system' | 'user'
userProvided: boolean
}
/**
* https://github.com/nextcloud/server/blob/ac2bc2384efe3c15ff987b87a7432bc60d545c67/apps/files_external/lib/Controller/ApiController.php#L71-L97
*/
export type MountEntry = {
name: string
path: string,
type: 'dir',
backend: 'SFTP',
scope: 'system' | 'personal',
permissions: number,
id: number,
class: string
config: StorageConfig
}
const entryToFolder = (ocsEntry: MountEntry): Folder => {
const path = (ocsEntry.path + '/' + ocsEntry.name).replace(/^\//gm, '')
return new Folder({
id: ocsEntry.id,
source: generateRemoteUrl('dav' + rootPath + '/' + path),
root: rootPath,
owner: getCurrentUser()?.uid || null,
permissions: ocsEntry.config.status !== STORAGE_STATUS.SUCCESS
? Permission.NONE
: ocsEntry?.permissions || Permission.READ,
attributes: {
displayName: path,
...ocsEntry,
},
})
}
export const getContents = async (): Promise<ContentsWithRoot> => {
const response = await axios.get(generateOcsUrl('apps/files_external/api/v1/mounts')) as AxiosResponse<OCSResponse<MountEntry>>
const contents = response.data.ocs.data.map(entryToFolder)
return {
folder: new Folder({
id: 0,
source: generateRemoteUrl('dav' + rootPath),
root: rootPath,
owner: getCurrentUser()?.uid || null,
permissions: Permission.READ,
}),
contents,
}
}
export const getStatus = function(id: number, global = true) {
const type = global ? 'userglobalstorages' : 'userstorages'
return axios.get(generateUrl(`apps/files_external/${type}/${id}?testOnly=false`)) as Promise<AxiosResponse<StorageConfig>>
}

@ -0,0 +1,42 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { StorageConfig } from '../services/externalStorage'
// @see https://github.com/nextcloud/server/blob/ac2bc2384efe3c15ff987b87a7432bc60d545c67/lib/public/Files/StorageNotAvailableException.php#L41
export enum STORAGE_STATUS {
SUCCESS = 0,
ERROR = 1,
INDETERMINATE = 2,
INCOMPLETE_CONF = 3,
UNAUTHORIZED = 4,
TIMEOUT = 5,
NETWORK_ERROR = 6,
}
export const isMissingAuthConfig = function(config: StorageConfig) {
// If we don't know the status, assume it is ok
if (!config.status || config.status === STORAGE_STATUS.SUCCESS) {
return false
}
return config.userProvided || config.authMechanism === 'password::global::user'
}

@ -0,0 +1,39 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { FileType, Node } from '@nextcloud/files'
import type { MountEntry } from '../services/externalStorage'
export const isNodeExternalStorage = function(node: Node) {
// Not a folder, not a storage
if (node.type === FileType.File) {
return false
}
// No backend or scope, not a storage
const attributes = node.attributes as MountEntry
if (!attributes.scope || !attributes.backend) {
return false
}
// Specific markers that we're sure are ext storage only
return attributes.scope === 'personal' || attributes.scope === 'system'
}

@ -1,31 +0,0 @@
<?php /** @var \OCP\IL10N $l */ ?>
<div class="files-controls">
<div></div>
</div>
<div class="emptyfilelist emptycontent hidden">
<div class="icon-external"></div>
<h2><?php p($l->t('No external storage configured or you don\'t have the permission to configure them')); ?></h2>
</div>
<table class="files-filestable list-container <?php p($_['showgridview'] ? 'view-grid' : '') ?>">
<thead>
<tr>
<th class="hidden column-name">
<div class="column-name-container">
<a class="name sort columntitle" data-sort="name"><span><?php p($l->t('Name')); ?></span><span class="sort-indicator"></span></a>
</div>
</th>
<th id="headerBackend" class="hidden column-backend">
<a class="backend sort columntitle" data-sort="backend"><span><?php p($l->t('Storage type')); ?></span><span class="sort-indicator"></span></a>
</th>
<th id="headerScope" class="hidden column-scope column-last">
<a class="scope sort columntitle" data-sort="scope"><span><?php p($l->t('Scope')); ?></span><span class="sort-indicator"></span></a>
</th>
</tr>
</thead>
<tbody class="files-fileList">
</tbody>
<tfoot>
</tfoot>
</table>

@ -129,7 +129,7 @@ abstract class StoragesControllerTest extends \Test\TestCase {
$data = $response->getData();
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$this->assertEquals($storageConfig, $data);
$this->assertEquals($storageConfig->jsonSerialize(), $data);
}
public function testAddLocalStorageWhenDisabled() {
@ -201,7 +201,7 @@ abstract class StoragesControllerTest extends \Test\TestCase {
$data = $response->getData();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$this->assertEquals($storageConfig, $data);
$this->assertEquals($storageConfig->jsonSerialize(), $data);
}
public function mountPointNamesProvider() {

@ -48,9 +48,9 @@ export const action = new FileAction({
return null
},
default: DefaultType.HIDDEN,
// Before openFolderAction
order: -1000,
default: DefaultType.HIDDEN,
})
registerFileAction(action)

@ -45,7 +45,7 @@ describe('SharingService methods definitions', () => {
},
data: [],
},
} as OCSResponse,
} as OCSResponse<any>,
}
})
})

@ -31,14 +31,14 @@ import logger from './logger'
export const rootPath = `/files/${getCurrentUser()?.uid}`
export type OCSResponse = {
export type OCSResponse<T> = {
ocs: {
meta: {
status: string
statuscode: number
message: string
},
data: []
data: T[]
}
}
@ -87,7 +87,7 @@ const ocsEntryToNode = function(ocsEntry: any): Folder | File | null {
}
}
const getShares = function(shared_with_me = false): AxiosPromise<OCSResponse> {
const getShares = function(shared_with_me = false): AxiosPromise<OCSResponse<any>> {
const url = generateOcsUrl('apps/files_sharing/api/v1/shares')
return axios.get(url, {
headers,
@ -98,15 +98,15 @@ const getShares = function(shared_with_me = false): AxiosPromise<OCSResponse> {
})
}
const getSharedWithYou = function(): AxiosPromise<OCSResponse> {
const getSharedWithYou = function(): AxiosPromise<OCSResponse<any>> {
return getShares(true)
}
const getSharedWithOthers = function(): AxiosPromise<OCSResponse> {
const getSharedWithOthers = function(): AxiosPromise<OCSResponse<any>> {
return getShares()
}
const getRemoteShares = function(): AxiosPromise<OCSResponse> {
const getRemoteShares = function(): AxiosPromise<OCSResponse<any>> {
const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares')
return axios.get(url, {
headers,
@ -116,7 +116,7 @@ const getRemoteShares = function(): AxiosPromise<OCSResponse> {
})
}
const getPendingShares = function(): AxiosPromise<OCSResponse> {
const getPendingShares = function(): AxiosPromise<OCSResponse<any>> {
const url = generateOcsUrl('apps/files_sharing/api/v1/shares/pending')
return axios.get(url, {
headers,
@ -126,7 +126,7 @@ const getPendingShares = function(): AxiosPromise<OCSResponse> {
})
}
const getRemotePendingShares = function(): AxiosPromise<OCSResponse> {
const getRemotePendingShares = function(): AxiosPromise<OCSResponse<any>> {
const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares/pending')
return axios.get(url, {
headers,
@ -136,7 +136,7 @@ const getRemotePendingShares = function(): AxiosPromise<OCSResponse> {
})
}
const getDeletedShares = function(): AxiosPromise<OCSResponse> {
const getDeletedShares = function(): AxiosPromise<OCSResponse<any>> {
const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares')
return axios.get(url, {
headers,
@ -147,7 +147,7 @@ const getDeletedShares = function(): AxiosPromise<OCSResponse> {
}
export const getContents = async (sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, filterTypes: number[] = []): Promise<ContentsWithRoot> => {
const promises = [] as AxiosPromise<OCSResponse>[]
const promises = [] as AxiosPromise<OCSResponse<any>>[]
if (sharedWithYou) {
promises.push(getSharedWithYou(), getRemoteShares())

@ -112,7 +112,7 @@ describe('Sharing views contents', () => {
},
data: [],
},
} as OCSResponse,
} as OCSResponse<any>,
}
})

@ -28,7 +28,7 @@ import AccountGroupSvg from '@mdi/svg/svg/account-group.svg?raw'
import AccountSvg from '@mdi/svg/svg/account.svg?raw'
import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
import LinkSvg from '@mdi/svg/svg/link.svg?raw'
import ShareVariantSvg from '@mdi/svg/svg/share-variant.svg?raw'
import AccouontPlusSvg from '@mdi/svg/svg/account-plus.svg?raw'
import { getContents } from '../services/SharingService'
@ -49,7 +49,7 @@ export default () => {
emptyTitle: t('files_sharing', 'No shares'),
emptyCaption: t('files_sharing', 'Files and folders you shared or have been shared with you will show up here'),
icon: ShareVariantSvg,
icon: AccouontPlusSvg,
order: 20,
columns: [],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,65 @@
/**
* @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12
package-lock.json generated

@ -26166,9 +26166,9 @@
}
},
"node_modules/webdav": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.2.2.tgz",
"integrity": "sha512-CTnhTTKug7pKbMqcvrnGNr4rV9qhWXV1sLk1PpN4BOskqDT+cEfFx4Y4VlcFXUX6lSUFsQBm9Ka8+6dIe0doQQ==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.2.3.tgz",
"integrity": "sha512-u5wqJULZhB7IwO3qVD9r0ikt6SMHZ4P4YYtLJ6JrCmSoZuW6KvanXWJAA4LZDm548lK7aCNUsy0VxbBKBXAGrg==",
"dependencies": {
"@buttercup/fetch": "^0.1.1",
"base-64": "^1.0.0",
@ -46694,9 +46694,9 @@
"optional": true
},
"webdav": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.2.2.tgz",
"integrity": "sha512-CTnhTTKug7pKbMqcvrnGNr4rV9qhWXV1sLk1PpN4BOskqDT+cEfFx4Y4VlcFXUX6lSUFsQBm9Ka8+6dIe0doQQ==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.2.3.tgz",
"integrity": "sha512-u5wqJULZhB7IwO3qVD9r0ikt6SMHZ4P4YYtLJ6JrCmSoZuW6KvanXWJAA4LZDm548lK7aCNUsy0VxbBKBXAGrg==",
"requires": {
"@buttercup/fetch": "^0.1.1",
"base-64": "^1.0.0",

@ -72,19 +72,6 @@ module.exports = function(config) {
],
testFiles: ['apps/files_sharing/tests/js/*.js']
},
{
name: 'files_external',
srcFiles: [
// only test these files, others are not ready and mess
// up with the global namespace/classes/state
'apps/files_external/js/app.js',
'apps/files_external/js/templates.js',
'apps/files_external/js/mountsfilelist.js',
'apps/files_external/js/settings.js',
'apps/files_external/js/statusmanager.js'
],
testFiles: ['apps/files_external/tests/js/*.js']
},
'systemtags',
'files_trashbin',
];

@ -54,6 +54,9 @@ module.exports = {
'personal-settings': path.join(__dirname, 'apps/files/src', 'main-personal-settings.js'),
'reference-files': path.join(__dirname, 'apps/files/src', 'reference-files.js'),
},
files_external: {
main: path.join(__dirname, 'apps/files_external/src', 'main.ts'),
},
files_sharing: {
additionalScripts: path.join(__dirname, 'apps/files_sharing/src', 'additionalScripts.js'),
collaboration: path.join(__dirname, 'apps/files_sharing/src', 'collaborationresourceshandler.js'),

Loading…
Cancel
Save