Merge remote-tracking branch 'dashboard/master'

This commit is contained in:
Girish Ramakrishnan
2023-02-24 23:40:15 +01:00
168 changed files with 85765 additions and 0 deletions

4036
dashboard/src/js/client.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,890 @@
'use strict';
require.config({ paths: { 'vs': '3rdparty/vs' }});
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ngDrag', 'ui.bootstrap', 'ui.bootstrap.contextMenu']);
angular.module('Application').filter('prettyOwner', function () {
return function (uid) {
if (uid === 0) return 'root';
if (uid === 33) return 'www-data';
if (uid === 1000) return 'cloudron';
if (uid === 1001) return 'git';
return uid;
};
});
// disable sce for footer https://code.angularjs.org/1.5.8/docs/api/ng/service/$sce
app.config(function ($sceProvider) {
$sceProvider.enabled(false);
});
app.filter('trustUrl', ['$sce', function ($sce) {
return function (recordingUrl) {
return $sce.trustAsResourceUrl(recordingUrl);
};
}]);
// https://stackoverflow.com/questions/25621321/angularjs-ng-drag
var ngDragEventDirectives = {};
angular.forEach(
'drag dragend dragenter dragexit dragleave dragover dragstart drop'.split(' '),
function(eventName) {
var directiveName = 'ng' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
ngDragEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse/*, $rootScope */) {
return {
restrict: 'A',
compile: function($element, attr) {
var fn = $parse(attr[directiveName], null, true);
return function ngDragEventHandler(scope, element) {
element.on(eventName, function(event) {
var callback = function() {
fn(scope, {$event: event});
};
scope.$apply(callback);
});
};
}
};
}];
}
);
angular.module('ngDrag', []).directive(ngDragEventDirectives);
function sanitize(filePath) {
filePath = filePath.split('/').filter(function (a) { return !!a; }).reduce(function (a, v) {
if (v === '.'); // do nothing
else if (v === '..') a.pop();
else a.push(v);
return a;
}, []).map(function (p) {
// small detour to safely handle special characters and whitespace
return encodeURIComponent(decodeURIComponent(p));
}).join('/');
return filePath;
}
function isModalVisible() {
return !!document.getElementsByClassName('modal in').length;
}
var VIEW = {
LEFT: 'left',
RIGHT: 'right'
};
var OWNERS = [
{ name: 'cloudron', value: 1000 },
{ name: 'www-data', value: 33 },
{ name: 'git', value: 1001 },
{ name: 'root', value: 0 }
];
app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Client', function ($scope, $translate, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
// expose enums
$scope.VIEW = VIEW;
$scope.OWNERS = OWNERS;
$scope.initialized = false;
$scope.status = null;
$scope.client = Client;
$scope.title = '';
$scope.backendId = search.id;
$scope.backendType = search.type;
$scope.volumes = [];
$scope.splitView = !!window.localStorage.splitView;
$scope.activeView = VIEW.LEFT;
$scope.viewerOpen = false;
$scope.clipboard = []; // holds cut or copied entries
$scope.clipboardCut = false; // if action is cut or copy
// add a hook for children to refresh both tree views
$scope.children = [];
$scope.registerChild = function (child) { $scope.children.push(child); };
$scope.refresh = function () {
$scope.children.forEach(function (child) {
child.onRefresh();
});
};
function collectFiles(entry, callback) {
var pathFrom = entry.pathFrom;
if (entry.isDirectory) {
Client.filesGet($scope.backendId, $scope.backendType, entry.fullFilePath, 'data', function (error, result) {
if (error) return callback(error);
if (!result.entries) return callback(new Error('not a folder'));
// amend fullFilePath
result.entries.forEach(function (e) {
e.fullFilePath = sanitize(entry.fullFilePath + '/' + e.fileName);
e.pathFrom = pathFrom; // we stash the original path for pasting
});
var collectedFiles = [];
async.eachLimit(result.entries, 5, function (entry, callback) {
collectFiles(entry, function (error, result) {
if (error) return callback(error);
collectedFiles = collectedFiles.concat(result);
callback();
});
}, function (error) {
if (error) return callback(error);
callback(null, collectedFiles);
});
});
return;
}
callback(null, [ entry ]);
}
// entries need to be an actual copy
$scope.actionCut = function (cwd, entries) {
$scope.clipboard = entries; //$scope.selected.slice();
$scope.clipboard.forEach(function (entry) {
entry.fullFilePath = sanitize(cwd + '/' + entry.fileName);
});
$scope.clipboardCut = true;
};
// entries need to be an actual copy
$scope.actionCopy = function (cwd, entries) {
$scope.clipboard = entries; //$scope.selected.slice();
$scope.clipboard.forEach(function (entry) {
entry.fullFilePath = sanitize(cwd + '/' + entry.fileName);
entry.pathFrom = cwd; // we stash the original path for pasting
});
$scope.clipboardCut = false;
};
$scope.actionPaste = function (cwd, destinationEntry) {
if ($scope.clipboardCut) {
// move files
async.eachLimit($scope.clipboard, 5, function (entry, callback) {
var newFilePath = sanitize(cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fileName);
// TODO this will overwrite files in destination!
Client.filesRename($scope.backendId, $scope.backendType, entry.fullFilePath, newFilePath, callback);
}, function (error) {
if (error) return Client.error(error);
// clear clipboard
$scope.clipboard = [];
$scope.refresh();
});
} else {
// copy files
// first collect all files recursively
var collectedFiles = [];
async.eachLimit($scope.clipboard, 5, function (entry, callback) {
collectFiles(entry, function (error, result) {
if (error) return callback(error);
collectedFiles = collectedFiles.concat(result);
callback();
});
}, function (error) {
if (error) return Client.error(error);
async.eachLimit(collectedFiles, 5, function (entry, callback) {
var newFilePath = sanitize(cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fullFilePath.slice(entry.pathFrom.length));
// This will NOT overwrite but finds a unique new name to copy to
// we prefix with a / to ensure we don't do relative target paths
Client.filesCopy($scope.backendId, $scope.backendType, entry.fullFilePath, '/' + newFilePath, callback);
}, function (error) {
if (error) return Client.error(error);
// clear clipboard
$scope.clipboard = [];
$scope.refresh();
});
});
}
};
// handle uploads
$scope.uploadStatus = {
error: null,
busy: false,
fileName: '',
count: 0,
countDone: 0,
size: 0,
done: 0,
percentDone: 0,
files: [],
targetFolder: ''
};
$scope.uploadFiles = function (files, targetFolder, overwrite) {
if (!files || !files.length) return;
overwrite = !!overwrite;
// prevent it from getting closed
$('#uploadModal').modal({
backdrop: 'static',
keyboard: false
});
$scope.uploadStatus.files = files;
$scope.uploadStatus.targetFolder = targetFolder;
$scope.uploadStatus.error = null;
$scope.uploadStatus.busy = true;
$scope.uploadStatus.count = files.length;
$scope.uploadStatus.countDone = 0;
$scope.uploadStatus.size = 0;
$scope.uploadStatus.sizeDone = 0;
$scope.uploadStatus.done = 0;
$scope.uploadStatus.percentDone = 0;
for (var i = 0; i < files.length; ++i) {
$scope.uploadStatus.size += files[i].size;
}
async.eachSeries(files, function (file, callback) {
var filePath = sanitize(targetFolder + '/' + (file.webkitRelativePath || file.name));
$scope.uploadStatus.fileName = file.name;
Client.filesUpload($scope.backendId, $scope.backendType, filePath, file, overwrite, function (loaded) {
$scope.uploadStatus.percentDone = ($scope.uploadStatus.done+loaded) * 100 / $scope.uploadStatus.size;
$scope.uploadStatus.sizeDone = loaded;
}, function (error) {
if (error) return callback(error);
$scope.uploadStatus.done += file.size;
$scope.uploadStatus.percentDone = $scope.uploadStatus.done * 100 / $scope.uploadStatus.size;
$scope.uploadStatus.countDone++;
callback();
});
}, function (error) {
$scope.uploadStatus.busy = false;
if (error && error.statusCode === 409) {
$scope.uploadStatus.error = 'exists';
return;
} else if (error) {
console.error(error);
$scope.uploadStatus.error = 'generic';
return;
}
$('#uploadModal').modal('hide');
$scope.uploadStatus.fileName = '';
$scope.uploadStatus.count = 0;
$scope.uploadStatus.size = 0;
$scope.uploadStatus.sizeDone = 0;
$scope.uploadStatus.done = 0;
$scope.uploadStatus.percentDone = 100;
$scope.uploadStatus.files = [];
$scope.uploadStatus.targetFolder = '';
$scope.refresh();
});
};
$scope.retryUpload = function (overwrite) {
$scope.uploadFiles($scope.uploadStatus.files, $scope.uploadStatus.targetFolder, !!overwrite);
};
// file and folder upload hooks, stashing $scope.uploadCwd for now
$scope.uploadCwd = '';
$('#uploadFileInput').on('change', function (e ) {
$scope.uploadFiles(e.target.files || [], $scope.uploadCwd, false);
});
$scope.onUploadFile = function (cwd) {
$scope.uploadCwd = cwd;
$('#uploadFileInput').click();
};
$('#uploadFolderInput').on('change', function (e ) {
$scope.uploadFiles(e.target.files || [], $scope.uploadCwd, false);
});
$scope.onUploadFolder = function (cwd) {
$scope.uploadCwd = cwd;
$('#uploadFolderInput').click();
};
// handle delete
$scope.deleteEntries = {
busy: false,
error: null,
cwd: '',
entries: [],
show: function (cwd, entries) {
$scope.deleteEntries.error = null;
$scope.deleteEntries.cwd = cwd;
$scope.deleteEntries.entries = entries;
$('#entriesDeleteModal').modal('show');
},
submit: function () {
$scope.deleteEntries.busy = true;
async.eachLimit($scope.deleteEntries.entries, 5, function (entry, callback) {
var filePath = sanitize($scope.deleteEntries.cwd + '/' + entry.fileName);
Client.filesRemove($scope.backendId, $scope.backendType, filePath, callback);
}, function (error) {
$scope.deleteEntries.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#entriesDeleteModal').modal('hide');
});
}
};
// rename entry
$scope.renameEntry = {
busy: false,
error: null,
entry: null,
cwd: '',
newName: '',
show: function (cwd, entry) {
$scope.renameEntry.error = null;
$scope.renameEntry.cwd = cwd;
$scope.renameEntry.entry = entry;
$scope.renameEntry.newName = entry.fileName;
$scope.renameEntry.busy = false;
$('#renameEntryModal').modal('show');
},
submit: function () {
$scope.renameEntry.busy = true;
var oldFilePath = sanitize($scope.renameEntry.cwd + '/' + $scope.renameEntry.entry.fileName);
var newFilePath = sanitize(($scope.renameEntry.newName[0] === '/' ? '' : ($scope.renameEntry.cwd + '/')) + $scope.renameEntry.newName);
Client.filesRename($scope.backendId, $scope.backendType, oldFilePath, newFilePath, function (error) {
$scope.renameEntry.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#renameEntryModal').modal('hide');
});
}
};
// chown entries
$scope.chownEntries = {
busy: false,
error: null,
entries: [],
newOwner: 0,
recursive: false,
showRecursiveOption: false,
show: function (cwd, entries) {
$scope.chownEntries.error = null;
$scope.chownEntries.cwd = cwd;
$scope.chownEntries.entries = entries;
// set default uid from first file
$scope.chownEntries.newOwner = entries[0].uid;
$scope.chownEntries.busy = false;
// default for directories is recursive
$scope.chownEntries.recursive = !!entries.find(function (entry) { return entry.isDirectory; });
$scope.chownEntries.showRecursiveOption = false;
$('#chownEntriesModal').modal('show');
},
submit: function () {
$scope.chownEntries.busy = true;
async.eachLimit($scope.chownEntries.entries, 5, function (entry, callback) {
var filePath = sanitize($scope.chownEntries.cwd + '/' + entry.fileName);
Client.filesChown($scope.backendId, $scope.backendType, filePath, $scope.chownEntries.newOwner, $scope.chownEntries.recursive, callback);
}, function (error) {
$scope.chownEntries.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#chownEntriesModal').modal('hide');
});
}
};
// new file
$scope.newFile = {
busy: false,
error: null,
cwd: '',
name: '',
show: function (cwd) {
$scope.newFile.error = null;
$scope.newFile.name = '';
$scope.newFile.busy = false;
$scope.newFile.cwd = cwd;
$scope.newFileForm.$setUntouched();
$scope.newFileForm.$setPristine();
$('#newFileModal').modal('show');
},
submit: function () {
$scope.newFile.busy = true;
$scope.newFile.error = null;
var filePath = sanitize($scope.newFile.cwd + '/' + $scope.newFile.name);
Client.filesUpload($scope.backendId, $scope.backendType, filePath, new File([], $scope.newFile.name), false, function () {}, function (error) {
$scope.newFile.busy = false;
if (error && error.statusCode === 409) return $scope.newFile.error = 'exists';
if (error) return Client.error(error);
$scope.refresh();
$('#newFileModal').modal('hide');
});
}
};
// new folder
$scope.newFolder = {
busy: false,
error: null,
cwd: '',
name: '',
show: function (cwd) {
$scope.newFolder.error = null;
$scope.newFolder.name = '';
$scope.newFolder.busy = false;
$scope.newFolder.cwd = cwd;
$scope.newFolderForm.$setUntouched();
$scope.newFolderForm.$setPristine();
$('#newFolderModal').modal('show');
},
submit: function () {
$scope.newFolder.busy = true;
$scope.newFolder.error = null;
var filePath = sanitize($scope.newFolder.cwd + '/' + $scope.newFolder.name);
Client.filesCreateDirectory($scope.backendId, $scope.backendType, filePath, function (error) {
$scope.newFolder.busy = false;
if (error && error.statusCode === 409) return $scope.newFolder.error = 'exists';
if (error) return Client.error(error);
$scope.refresh();
$('#newFolderModal').modal('hide');
});
}
};
// extract archives
$scope.extractStatus = {
error: null,
busy: false,
fileName: ''
};
$scope.extractEntry = function (cwd, entry) {
var filePath = sanitize(cwd + '/' + entry.fileName);
if (entry.isDirectory) return;
// prevent it from getting closed
$('#extractModal').modal({
backdrop: 'static',
keyboard: false
});
$scope.extractStatus.fileName = entry.fileName;
$scope.extractStatus.error = null;
$scope.extractStatus.busy = true;
Client.filesExtract($scope.backendId, $scope.backendType, filePath, function (error) {
$scope.extractStatus.busy = false;
if (error) {
console.error(error);
$scope.extractStatus.error = $translate.instant('filemanager.extract.error', error.message);
return;
}
$('#extractModal').modal('hide');
$scope.refresh();
});
};
// split view handling
$scope.toggleSplitView = function () {
$scope.splitView = !$scope.splitView;
if (!$scope.splitView) {
$scope.activeView = VIEW.LEFT;
delete window.localStorage.splitView;
} else {
window.localStorage.splitView = true;
}
};
$scope.setActiveView = function (view) {
$scope.activeView = view;
};
// monaco text editor
var LANGUAGES = [];
require(['vs/editor/editor.main'], function() { LANGUAGES = monaco.languages.getLanguages(); });
function getLanguage(filename) {
var ext = '.' + filename.split('.').pop();
var language = LANGUAGES.find(function (l) {
if (!l.extensions) return false;
return !!l.extensions.find(function (e) { return e === ext; });
}) || '';
return language ? language.id : '';
}
$scope.textEditor = {
busy: false,
cwd: null,
entry: null,
editor: null,
unsaved: false,
visible: false,
show: function (cwd, entry) {
$scope.textEditor.cwd = cwd;
$scope.textEditor.entry = entry;
$scope.textEditor.busy = false;
$scope.textEditor.unsaved = false;
$scope.textEditor.visible = true;
// clear model if any
if ($scope.textEditor.editor && $scope.textEditor.editor.getModel()) $scope.textEditor.editor.setModel(null);
$scope.viewerOpen = true;
// document.getElementById('textEditorModal').style['display'] = 'flex';
var filePath = sanitize($scope.textEditor.cwd + '/' + entry.fileName);
var language = getLanguage(entry.fileName);
Client.filesGet($scope.backendId, $scope.backendType, filePath, 'data', function (error, result) {
if (error) return Client.error(error);
if (!$scope.textEditor.editor) {
$timeout(function () {
$scope.textEditor.editor = monaco.editor.create(document.getElementById('textEditorContainer'), {
value: result,
language: language,
theme: 'vs-dark'
});
$scope.textEditor.editor.getModel().onDidChangeContent(function () { $scope.textEditor.unsaved = true; });
}, 200);
} else {
$scope.textEditor.editor.setModel(monaco.editor.createModel(result, language));
$scope.textEditor.editor.getModel().onDidChangeContent(function () { $scope.textEditor.unsaved = true; }); // have to re-attach whenever model changes
}
});
},
save: function (callback) {
$scope.textEditor.busy = true;
var newContent = $scope.textEditor.editor.getValue();
var filePath = sanitize($scope.textEditor.cwd + '/' + $scope.textEditor.entry.fileName);
var file = new File([newContent], 'file');
Client.filesUpload($scope.backendId, $scope.backendType, filePath, file, true, function () {}, function (error) {
if (error) return Client.error(error);
$scope.refresh();
$timeout(function () {
$scope.textEditor.unsaved = false;
$scope.textEditor.busy = false;
if (typeof callback === 'function') return callback();
}, 1000);
});
},
close: function () {
$scope.textEditor.visible = false;
$scope.viewerOpen = false;
$('#textEditorCloseModal').modal('hide');
},
onClose: function () {
$scope.textEditor.visible = false;
$scope.viewerOpen = false;
$('#textEditorCloseModal').modal('hide');
},
saveAndClose: function () {
$scope.textEditor.save(function () {
$scope.textEditor.onClose();
});
},
maybeClose: function () {
if (!$scope.textEditor.unsaved) return $scope.textEditor.onClose();
$('#textEditorCloseModal').modal('show');
},
};
// restart app or mail logic
$scope.restartBusy = false;
$scope.onRestartApp = function () {
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getApp($scope.backendId, function (error, result) {
if (error) return callback(error);
if (result.installationState === ISTATES.INSTALLED) return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartApp($scope.backendId, function (error) {
if (error) console.error('Failed to restart app.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartBusy = false;
});
});
};
$scope.onRestartMail = function () {
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getService('mail', function (error, result) {
if (error) return callback(error);
if (result.status === 'active') return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartService('mail', function (error) {
if (error) console.error('Failed to restart mail.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartBusy = false;
});
});
};
// init code
function fetchVolumesInfo(mounts) {
$scope.volumes = [];
async.each(mounts, function (mount, callback) {
Client.getVolume(mount.volumeId, function (error, result) {
if (error) return callback(error);
$scope.volumes.push(result);
callback();
});
}, function (error) {
if (error) console.error('Failed to fetch volumes info.', error);
});
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
window.location.href = '/';
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
$scope.status = status;
console.log('Running filemanager version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
var getter;
if ($scope.backendType === 'app') {
getter = Client.getApp.bind(Client, $scope.backendId);
} else if ($scope.backendType === 'volume') {
getter = Client.getVolume.bind(Client, $scope.backendId);
} else if ($scope.backendType === 'mail') {
getter = function (next) { next(null, null); };
}
getter(function (error, result) {
if (error) {
$scope.initialized = true;
return;
}
// fine to do async
if ($scope.backendType === 'app') fetchVolumesInfo(result.mounts || []);
switch ($scope.backendType) {
case 'app':
$scope.title = result.label || result.fqdn;
$scope.rootDirLabel = '/app/data/';
$scope.applicationLink = 'https://' + result.fqdn;
break;
case 'volume':
$scope.title = result.name;
$scope.rootDirLabel = result.hostPath;
break;
case 'mail':
$scope.title = 'mail';
$scope.rootDirLabel = 'mail';
break;
}
window.document.title = $scope.title + ' - ' + $translate.instant('filemanager.title');
// now mark the Client to be ready
Client.setReady();
// openPath('');
$scope.initialized = true;
});
});
});
}
init();
// toplevel key input handling
window.addEventListener('keydown', function (event) {
if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 's') {
if (!$scope.textEditor.visible) return;
event.preventDefault();
$scope.$apply($scope.textEditor.save);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'c') {
if ($scope.textEditor.visible) return;
if ($scope.selected.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionCopy);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'x') {
if ($scope.textEditor.visible) return;
if ($scope.selected.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionCut);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'v') {
if ($scope.textEditor.visible) return;
if ($scope.clipboard.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionPaste);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'a') {
if ($scope.textEditor.visible) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionSelectAll);
} else if(event.key === 'Escape') {
if ($scope.textEditor.visible) return $scope.$apply($scope.textEditor.maybeClose);
else $scope.$apply(function () { $scope.selected = []; });
}
});
// setup all the dialog focus handling
['newFileModal', 'newFolderModal', 'renameEntryModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
// selects filename (without extension)
['renameEntryModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
var elem = $(this).find('[autofocus]:first');
var text = elem.val();
elem[0].setSelectionRange(0, text.indexOf('.'));
});
});
}]);

606
dashboard/src/js/index.js Normal file
View File

@@ -0,0 +1,606 @@
'use strict';
/* global angular:false */
/* global $:false */
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES */
// deal with accessToken in the query, this is passed for example on password reset and account setup upon invite
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
if (search.accessToken) {
localStorage.token = search.accessToken;
// strip the accessToken and expiresAt, then preserve the rest
delete search.accessToken;
delete search.expiresAt;
// this will reload the page as this is not a hash change
window.location.search = encodeURIComponent(Object.keys(search).map(function (key) { return key + '=' + search[key]; }).join('&'));
}
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.bootstrap-slider', 'ngTld', 'ui.multiselect']);
app.config(['NotificationProvider', function (NotificationProvider) {
NotificationProvider.setOptions({
delay: 5000,
startTop: 60,
positionX: 'left',
templateUrl: 'notification.html'
});
}]);
// configure resourceUrlWhitelist https://code.angularjs.org/1.5.8/docs/api/ng/provider/$sceDelegateProvider#resourceUrlWhitelist
app.config(function ($sceDelegateProvider) {
$sceDelegateProvider.resourceUrlWhitelist([
// Allow same origin resource loads.
'self',
// Allow loading from our assets domain.
'https://cloudron.io/**',
'https://staging.cloudron.io/**',
'https://dev.cloudron.io/**',
// Allow local development against the appstore pages
'http://localhost:5000/**'
]);
});
// setup all major application routes
app.config(['$routeProvider', function ($routeProvider) {
$routeProvider.when('/', {
redirectTo: '/apps'
}).when('/users', {
controller: 'UsersController',
templateUrl: 'views/users.html?<%= revision %>'
}).when('/app/:appId/:view?', {
controller: 'AppController',
templateUrl: 'views/app.html?<%= revision %>'
}).when('/appstore', {
controller: 'AppStoreController',
templateUrl: 'views/appstore.html?<%= revision %>'
}).when('/appstore/:appId', {
controller: 'AppStoreController',
templateUrl: 'views/appstore.html?<%= revision %>'
}).when('/apps', {
controller: 'AppsController',
templateUrl: 'views/apps.html?<%= revision %>'
}).when('/profile', {
controller: 'ProfileController',
templateUrl: 'views/profile.html?<%= revision %>'
}).when('/backups', {
controller: 'BackupsController',
templateUrl: 'views/backups.html?<%= revision %>'
}).when('/branding', {
controller: 'BrandingController',
templateUrl: 'views/branding.html?<%= revision %>'
}).when('/network', {
controller: 'NetworkController',
templateUrl: 'views/network.html?<%= revision %>'
}).when('/domains', {
controller: 'DomainsController',
templateUrl: 'views/domains.html?<%= revision %>'
}).when('/email', {
controller: 'EmailsController',
templateUrl: 'views/emails.html?<%= revision %>'
}).when('/emails-eventlog', {
controller: 'EmailsEventlogController',
templateUrl: 'views/emails-eventlog.html?<%= revision %>'
}).when('/emails-queue', {
controller: 'EmailsQueueController',
templateUrl: 'views/emails-queue.html?<%= revision %>'
}).when('/email/:domain/:view?', {
controller: 'EmailController',
templateUrl: 'views/email.html?<%= revision %>'
}).when('/notifications', {
controller: 'NotificationsController',
templateUrl: 'views/notifications.html?<%= revision %>'
}).when('/settings', {
controller: 'SettingsController',
templateUrl: 'views/settings.html?<%= revision %>'
}).when('/eventlog', {
controller: 'EventLogController',
templateUrl: 'views/eventlog.html?<%= revision %>'
}).when('/support', {
controller: 'SupportController',
templateUrl: 'views/support.html?<%= revision %>'
}).when('/system', {
controller: 'SystemController',
templateUrl: 'views/system.html?<%= revision %>'
}).when('/services', {
controller: 'ServicesController',
templateUrl: 'views/services.html?<%= revision %>'
}).when('/volumes', {
controller: 'VolumesController',
templateUrl: 'views/volumes.html?<%= revision %>'
}).otherwise({ redirectTo: '/'});
}]);
app.filter('capitalize', function () {
return function (s) {
return s.charAt(0).toUpperCase() + s.slice(1);
};
});
app.filter('activeTask', function () {
return function (app) {
if (!app) return false;
return app.taskId !== null;
};
});
app.filter('installSuccess', function () {
return function (app) {
if (!app) return false;
return app.installationState === ISTATES.INSTALLED;
};
});
app.filter('appIsInstalledAndHealthy', function () {
return function (app) {
if (!app) return false;
return (app.installationState === ISTATES.INSTALLED && app.health === HSTATES.HEALTHY && app.runState === RSTATES.RUNNING);
};
});
app.filter('applicationLink', function () {
return function(app) {
if (!app) return '';
// app links have http already in the fqdn
if (app.fqdn.indexOf('http') !== 0) return 'https://' + app.fqdn;
return app.fqdn;
};
});
app.filter('userManagementFilter', function () {
return function(apps, option) {
return apps.filter(function (app) {
if (option.id === '') return true;
if (option.id === 'sso') return !!(app.manifest.optionalSso || app.manifest.addons.ldap || app.manifest.addons.proxyAuth);
if (option.id === 'nosso') return app.manifest.optionalSso || (!app.manifest.addons.ldap && !app.manifest.addons.proxyAuth);
if (option.id === 'email') return !!app.manifest.addons.email;
return false;
});
};
});
// this appears when an item in app grid is clicked
app.filter('prettyAppErrorMessage', function () {
return function (app) {
if (!app) return '';
if (app.installationState === ISTATES.INSTALLED) {
// app.health can also be null to indicate insufficient data
if (app.health === HSTATES.UNHEALTHY) return 'The app is not responding to health checks. Check the logs for any error messages.';
}
if (app.error.reason === 'Access Denied') {
if (app.error.domain) return 'The DNS record for this location is not setup correctly. Please verify your DNS settings and repair this app.';
} else if (app.error.reason === 'Already Exists') {
if (app.error.domain) return 'The DNS record for this location already exists. Cloudron does not remove existing DNS records. Manually remove the DNS record and then click on repair.';
}
return app.error.message;
};
});
// this appears as tool tip in app grid
app.filter('appProgressMessage', function () {
return function (app) {
var message = app.message || (app.error ? app.error.message : '');
return message;
};
});
// see apps.js $scope.states
app.filter('selectedStateFilter', ['Client', function (Client) {
return function selectedStateFilter(apps, selectedState) {
return apps.filter(function (app) {
if (!selectedState || !selectedState.state) return true;
if (selectedState.state === 'running') return app.runState === RSTATES.RUNNING && app.health === HSTATES.HEALTHY && app.installationState === ISTATES.INSTALLED;
if (selectedState.state === 'stopped') return app.runState === RSTATES.STOPPED;
if (selectedState.state === 'update_available') return !!(Client.getConfig().update[app.id] && Client.getConfig().update[app.id].manifest.version && Client.getConfig().update[app.id].manifest.version !== app.manifest.version);
return app.runState === RSTATES.RUNNING && (app.health !== HSTATES.HEALTHY || app.installationState !== ISTATES.INSTALLED); // not responding
});
};
}]);
app.filter('selectedGroupAccessFilter', function () {
return function selectedGroupAccessFilter(apps, group) {
return apps.filter(function (app) {
if (!group.id) return true; // case for no filter entry
if (!app.accessRestriction) return true;
if (!app.accessRestriction.groups) return false;
if (app.accessRestriction.groups.indexOf(group.id) !== -1) return true;
return false;
});
};
});
app.filter('selectedTagFilter', function () {
return function selectedTagFilter(apps, selectedTags) {
return apps.filter(function (app) {
if (selectedTags.length === 0) return true;
if (!app.tags) return false;
for (var i = 0; i < selectedTags.length; i++) {
if (app.tags.indexOf(selectedTags[i]) === -1) return false;
}
return true;
});
};
});
app.filter('selectedDomainFilter', function () {
return function selectedDomainFilter(apps, selectedDomain) {
return apps.filter(function (app) {
if (selectedDomain._alldomains) return true; // magic domain for single select, see apps.js ALL_DOMAINS_DOMAIN
if (app.type === APP_TYPES.LINK) return false;
if (selectedDomain.domain === app.domain) return true;
if (app.aliasDomains.find(function (ad) { return ad.domain === selectedDomain.domain; })) return true;
if (app.redirectDomains.find(function (ad) { return ad.domain === selectedDomain.domain; })) return true;
return false;
});
};
});
app.filter('appSearchFilter', function () {
return function appSearchFilter(apps, appSearch) {
return apps.filter(function (app) {
if (!appSearch) return true;
appSearch = appSearch.toLowerCase();
return app.fqdn.indexOf(appSearch) !== -1
|| (app.label && app.label.toLowerCase().indexOf(appSearch) !== -1)
|| (app.manifest.title && app.manifest.title.toLowerCase().indexOf(appSearch) !== -1)
|| (appSearch.length >=6 && app.id.indexOf(appSearch) !== -1);
});
};
});
app.filter('prettyDomains', function () {
return function prettyDomains(domains) {
return domains.map(function (d) { return d.domain; }).join(', ');
};
});
app.filter('installationActive', function () {
return function (app) {
if (app.installationState === ISTATES.ERROR) return false;
if (app.installationState === ISTATES.INSTALLED) return false;
return true;
};
});
// this appears in the app grid
app.filter('installationStateLabel', function () {
return function(app) {
if (!app) return '';
var waiting = app.progress === 0 ? ' (Queued)' : '';
switch (app.installationState) {
case ISTATES.PENDING_INSTALL:
return 'Installing' + waiting;
case ISTATES.PENDING_CLONE:
return 'Cloning' + waiting;
case ISTATES.PENDING_LOCATION_CHANGE:
case ISTATES.PENDING_CONFIGURE:
case ISTATES.PENDING_RECREATE_CONTAINER:
case ISTATES.PENDING_DEBUG:
return 'Configuring' + waiting;
case ISTATES.PENDING_RESIZE:
return 'Resizing' + waiting;
case ISTATES.PENDING_DATA_DIR_MIGRATION:
return 'Migrating data' + waiting;
case ISTATES.PENDING_UNINSTALL: return 'Uninstalling' + waiting;
case ISTATES.PENDING_RESTORE: return 'Restoring' + waiting;
case ISTATES.PENDING_IMPORT: return 'Importing' + waiting;
case ISTATES.PENDING_UPDATE: return 'Updating' + waiting;
case ISTATES.PENDING_BACKUP: return 'Backing up' + waiting;
case ISTATES.PENDING_START: return 'Starting' + waiting;
case ISTATES.PENDING_STOP: return 'Stopping' + waiting;
case ISTATES.PENDING_RESTART: return 'Restarting' + waiting;
case ISTATES.ERROR: {
if (app.error && app.error.message === 'ETRYAGAIN') return 'DNS Error';
return 'Error';
}
case ISTATES.INSTALLED: {
if (app.debugMode) {
return 'Recovery Mode';
} else if (app.runState === RSTATES.RUNNING) {
if (!app.health) return 'Starting...'; // no data yet
if (app.type === APP_TYPES.LINK) return '';
if (app.health === HSTATES.HEALTHY) return 'Running';
return 'Not responding'; // dead/exit/unhealthy
} else if (app.runState === RSTATES.STOPPED) return 'Stopped';
else return app.runState;
}
default: return app.installationState;
}
};
});
app.filter('taskName', function () {
return function(installationState) {
switch (installationState) {
case ISTATES.PENDING_INSTALL: return 'install';
case ISTATES.PENDING_CLONE: return 'clone';
case ISTATES.PENDING_LOCATION_CHANGE: return 'location change';
case ISTATES.PENDING_CONFIGURE: return 'configure';
case ISTATES.PENDING_RECREATE_CONTAINER: return 'create container';
case ISTATES.PENDING_DEBUG: return 'debug';
case ISTATES.PENDING_RESIZE: return 'resize';
case ISTATES.PENDING_DATA_DIR_MIGRATION: return 'data migration';
case ISTATES.PENDING_UNINSTALL: return 'uninstall';
case ISTATES.PENDING_RESTORE: return 'restore';
case ISTATES.PENDING_IMPORT: return 'import';
case ISTATES.PENDING_UPDATE: return 'update';
case ISTATES.PENDING_BACKUP: return 'backup';
case ISTATES.PENDING_START: return 'start app';
case ISTATES.PENDING_STOP: return 'stop app';
case ISTATES.PENDING_RESTART: return 'restart app';
default: return installationState || '';
}
};
});
app.filter('errorSuggestion', function () {
return function (error) {
if (!error) return '';
switch (error.reason) {
case ERROR.ACCESS_DENIED:
if (error.domain) return 'Check the DNS credentials of ' + error.domain.domain + ' in the Domains & Certs view';
return '';
case ERROR.COLLECTD_ERROR: return 'Check if collectd is running on the server';
case ERROR.DATABASE_ERROR: return 'Check if MySQL database is running on the server';
case ERROR.DOCKER_ERROR: return 'Check if docker is running on the server';
case ERROR.DNS_ERROR: return 'Check if the DNS service of the domain is running';
case ERROR.LOGROTATE_ERROR: return 'Check if logrotate is running on the server';
case ERROR.NETWORK_ERROR: return 'Check if there are any network issues on the server';
case ERROR.REVERSEPROXY_ERROR: return 'Check if nginx is running on the server';
default: return '';
}
};
});
app.filter('readyToUpdate', function () {
return function (apps) {
return apps.every(function (app) {
return (app.installationState === ISTATES.ERROR) || (app.installationState === ISTATES.INSTALLED);
});
};
});
app.filter('inProgressApps', function () {
return function (apps) {
return apps.filter(function (app) {
return app.installationState !== ISTATES.ERROR && app.installationState !== ISTATES.INSTALLED;
});
};
});
app.filter('prettyHref', function () {
return function (input) {
if (!input) return input;
if (input.indexOf('http://') === 0) return input.slice('http://'.length);
if (input.indexOf('https://') === 0) return input.slice('https://'.length);
return input;
};
});
app.filter('prettyEmailAddresses', function () {
return function prettyEmailAddresses(addresses) {
if (!addresses) return '';
if (addresses === '<>') return '<>';
if (Array.isArray(addresses)) return addresses.map(function (a) { return a.slice(1, -1); }).join(', ');
return addresses.slice(1, -1);
};
});
// custom directive for dynamic names in forms
// See http://stackoverflow.com/questions/23616578/issue-registering-form-control-with-interpolated-name#answer-23617401
app.directive('laterName', function () { // (2)
return {
restrict: 'A',
require: ['?ngModel', '^?form'], // (3)
link: function postLink(scope, elem, attrs, ctrls) {
attrs.$set('name', attrs.laterName);
var modelCtrl = ctrls[0]; // (3)
var formCtrl = ctrls[1]; // (3)
if (modelCtrl && formCtrl) {
modelCtrl.$name = attrs.name; // (4)
formCtrl.$addControl(modelCtrl); // (2)
scope.$on('$destroy', function () {
formCtrl.$removeControl(modelCtrl); // (5)
});
}
}
};
});
app.run(['$route', '$rootScope', '$location', function ($route, $rootScope, $location) {
var original = $location.path;
$location.path = function (path, reload) {
if (reload === false) {
var lastRoute = $route.current;
var un = $rootScope.$on('$locationChangeSuccess', function () {
$route.current = lastRoute;
un();
});
}
return original.apply($location, [path]);
};
}]);
app.directive('ngClickSelect', function () {
return {
restrict: 'AC',
link: function (scope, element/*, attrs */) {
element.bind('click', function () {
var selection = window.getSelection();
var range = document.createRange();
range.selectNodeContents(this);
selection.removeAllRanges();
selection.addRange(range);
});
}
};
});
// handles various states and triggers a href or configure/repair view
// used by attaching to controller $scope
// if $scope.appPostInstallConfirm.show(app); exists it will be called if not yet confirmed
function onAppClick(app, $event, isOperator, $scope) {
function stopEvent() {
$event.originalEvent.stopPropagation();
$event.originalEvent.preventDefault();
}
if (app.installationState !== ISTATES.INSTALLED) {
if (app.installationState === ISTATES.ERROR && isOperator) $scope.showAppConfigure(app, 'repair');
return stopEvent();
}
// app.health can also be null to indicate insufficient data
if (!app.health) return stopEvent();
if (app.runState === RSTATES.STOPPED) return stopEvent();
if (app.runState === RSTATES.STOPPED) return stopEvent();
if (app.health === HSTATES.UNHEALTHY || app.health === HSTATES.ERROR || app.health === HSTATES.DEAD) {
if (isOperator) $scope.showAppConfigure(app, 'repair');
return stopEvent();
}
if (app.pendingPostInstallConfirmation && $scope.appPostInstallConfirm) {
$scope.appPostInstallConfirm.show(app);
return stopEvent();
}
}
app.directive('ngClickReveal', function () {
return {
restrict: 'A',
link: function (scope, element, attrs) {
element.addClass('hand');
var value = '';
scope.$watch(attrs.ngClickReveal, function (newValue, oldValue) {
if (newValue !== oldValue) {
element.html('<i>hidden</i>');
value = newValue;
}
});
element.bind('click', function () {
element.text(value);
});
}
};
});
// https://codepen.io/webmatze/pen/isuHh
app.directive('tagInput', function () {
return {
restrict: 'E',
scope: {
inputTags: '=taglist'
},
require: '^form',
link: function ($scope, element, attrs, formCtrl) {
$scope.defaultWidth = 200;
$scope.tagText = ''; // current tag being edited
$scope.placeholder = attrs.placeholder;
$scope.tagArray = function () {
if ($scope.inputTags === undefined) {
return [];
}
return $scope.inputTags.split(' ').filter(function (tag) {
return tag !== '';
});
};
$scope.addTag = function () {
var tagArray = $scope.tagArray();
// prevent adding empty or existing items
if ($scope.tagText.length === 0 || tagArray.indexOf($scope.tagText) !== -1) {
return $scope.tagText = '';
}
tagArray.push($scope.tagText);
$scope.inputTags = tagArray.join(' ');
return $scope.tagText = '';
};
$scope.deleteTag = function (key) {
var tagArray;
tagArray = $scope.tagArray();
if (tagArray.length > 0 && $scope.tagText.length === 0 && key === undefined) {
tagArray.pop();
} else {
if (key !== undefined) {
tagArray.splice(key, 1);
}
}
formCtrl.$setDirty();
return $scope.inputTags = tagArray.join(' ');
};
$scope.$watch('tagText', function (newVal, oldVal) {
var tempEl;
if (!(newVal === oldVal && newVal === undefined)) {
tempEl = $('<span>' + newVal + '</span>').appendTo('body');
$scope.inputWidth = tempEl.width() + 5;
if ($scope.inputWidth < $scope.defaultWidth) {
$scope.inputWidth = $scope.defaultWidth;
}
return tempEl.remove();
}
});
element.bind('click', function () {
element[0].firstChild.lastChild.focus();
});
element.bind('keydown', function (e) {
var key = e.which;
if (key === 9 || key === 13) {
e.preventDefault();
}
if (key === 8) {
return $scope.$apply('deleteTag()');
}
});
element.bind('keyup', function (e) {
var key = e.which;
if (key === 9 || key === 13 || key === 32) {
e.preventDefault();
return $scope.$apply('addTag()');
}
});
},
template:
'<div class="tag-input-container">' +
'<div class="btn-group input-tag" data-ng-repeat="tag in tagArray()">' +
'<button type="button" class="btn btn-xs btn-primary" disabled>{{ tag }}</button>' +
'<button type="button" class="btn btn-xs btn-primary" data-ng-click="deleteTag($index)">&times;</button>' +
'</div>' +
'<input type="text" data-ng-model="tagText" ng-blur="addTag()" placeholder="{{placeholder}}"/>' +
'</div>'
};
});
app.config(['fitTextConfigProvider', function (fitTextConfigProvider) {
fitTextConfigProvider.config = {
loadDelay: 250,
compressor: 0.9,
min: 8,
max: 24
};
}]);

198
dashboard/src/js/login.js Normal file
View File

@@ -0,0 +1,198 @@
'use strict';
/* global angular, $, showdown */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies']);
app.filter('markdown2html', function () {
var converter = new showdown.Converter({
simplifiedAutoLink: true,
strikethrough: true,
tables: true,
openLinksInNewWindow: true
});
return function (text) {
return converter.makeHtml(text);
};
});
// disable sce for footer https://code.angularjs.org/1.5.8/docs/api/ng/service/$sce
app.config(function ($sceProvider) {
$sceProvider.enabled(false);
});
app.config(['$translateProvider', function ($translateProvider) {
$translateProvider.useStaticFilesLoader({
prefix: 'translation/',
suffix: '.json'
});
$translateProvider.preferredLanguage('en');
$translateProvider.fallbackLanguage('en');
}]);
// Add shorthand "tr" filter to avoid having ot use "translate"
// This is a copy of the code at https://github.com/angular-translate/angular-translate/blob/master/src/filter/translate.js
// If we find out how to get that function handle somehow dynamically we can use that, otherwise the copy is required
function translateFilterFactory($parse, $translate) {
var translateFilter = function (translationId, interpolateParams, interpolation, forceLanguage) {
if (!angular.isObject(interpolateParams)) {
var ctx = this || {
'__SCOPE_IS_NOT_AVAILABLE': 'More info at https://github.com/angular/angular.js/commit/8863b9d04c722b278fa93c5d66ad1e578ad6eb1f'
};
interpolateParams = $parse(interpolateParams)(ctx);
}
return $translate.instant(translationId, interpolateParams, interpolation, forceLanguage);
};
if ($translate.statefulFilter()) {
translateFilter.$stateful = true;
}
return translateFilter;
}
translateFilterFactory.displayName = 'translateFilterFactory';
app.filter('tr', translateFilterFactory);
app.controller('LoginController', ['$scope', '$translate', '$http', function ($scope, $translate, $http) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.indexOf('=') === -1 ? [item, true] : [item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=')+1)]; }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.initialized = false;
$scope.mode = '';
$scope.busy = false;
$scope.error = false;
$scope.status = null;
$scope.username = '';
$scope.password = '';
$scope.totpToken = '';
$scope.passwordResetIdentifier = '';
$scope.newPassword = '';
$scope.newPasswordRepeat = '';
var API_ORIGIN = '<%= apiOrigin %>' || window.location.origin;
$scope.onLogin = function () {
$scope.busy = true;
$scope.error = false;
var data = {
username: $scope.username,
password: $scope.password,
totpToken: $scope.totpToken
};
function error() {
$scope.busy = false;
$scope.error = true;
$scope.password = '';
$scope.loginForm.$setPristine();
setTimeout(function () { $('#inputPassword').focus(); }, 200);
}
$http.post(API_ORIGIN + '/api/v1/cloudron/login', data).success(function (data, status) {
if (status !== 200) return error();
localStorage.token = data.accessToken;
// prevent redirecting to random domains
var returnTo = search.returnTo || '/';
if (returnTo.indexOf('/') !== 0) returnTo = '/';
window.location.href = returnTo;
}).error(error);
};
$scope.onPasswordReset = function () {
$scope.busy = true;
var data = {
identifier: $scope.passwordResetIdentifier
};
function done() {
$scope.busy = false;
$scope.mode = 'passwordResetDone';
}
$http.post(API_ORIGIN + '/api/v1/cloudron/password_reset_request', data).success(done).error(done);
};
$scope.onNewPassword = function () {
$scope.busy = true;
var data = {
resetToken: search.resetToken,
password: $scope.newPassword,
totpToken: $scope.totpToken
};
function error(data, status) {
console.log('error', status);
$scope.busy = false;
if (status === 401) $scope.error = data.message;
else if (status === 409) $scope.error = 'Ask your admin for an invite link first';
else $scope.error = 'Unknown error';
}
$http.post(API_ORIGIN + '/api/v1/cloudron/password_reset', data).success(function (data, status) {
if (status !== 202) return error(data, status);
// set token to autologin
localStorage.token = data.accessToken;
$scope.mode = 'newPasswordDone';
}).error(function (data, status) {
error(data, status);
});
};
$scope.showPasswordReset = function () {
window.document.title = 'Password Reset Request';
$scope.mode = 'passwordReset';
$scope.passwordResetIdentifier = '';
setTimeout(function () { $('#inputPasswordResetIdentifier').focus(); }, 200);
};
$scope.showLogin = function () {
if ($scope.status) window.document.title = $scope.status.cloudronName + ' Login';
$scope.mode = 'login';
$scope.error = false;
setTimeout(function () { $('#inputUsername').focus(); }, 200);
};
$scope.showNewPassword = function () {
window.document.title = 'Set New Password';
$scope.mode = 'newPassword';
setTimeout(function () { $('#inputNewPassword').focus(); }, 200);
};
$http.get(API_ORIGIN + '/api/v1/cloudron/status').success(function (data, status) {
$scope.initialized = true;
if (status !== 200) return;
if (data.language) $translate.use(data.language);
if ($scope.mode === 'login') window.document.title = data.cloudronName + ' Login';
$scope.status = data;
}).error(function () {
$scope.initialized = false;
});
// Init into the correct view
if (search.passwordReset) {
$scope.showPasswordReset();
} else if (search.resetToken) {
$scope.showNewPassword();
} else if (search.accessToken || search.access_token) { // auto-login feature
localStorage.token = search.accessToken || search.access_token;
window.location.href = '/';
} else {
$scope.showLogin();
}
}]);

202
dashboard/src/js/logs.js Normal file
View File

@@ -0,0 +1,202 @@
'use strict';
/* global angular */
/* global moment */
/* global $ */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification']);
app.controller('LogsController', ['$scope', '$translate', 'Client', function ($scope, $translate, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.initialized = false;
$scope.client = Client;
$scope.selected = '';
$scope.activeEventSource = null;
$scope.lines = 100;
$scope.selectedAppInfo = null;
$scope.selectedTaskInfo = null;
function ab2str(buf) {
return String.fromCharCode.apply(null, new Uint16Array(buf));
}
$scope.clear = function () {
var logViewer = $('.logs-container');
logViewer.empty();
};
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
var entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
function escapeHtml(string) {
return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) {
return entityMap[s];
});
}
function showLogs() {
if (!$scope.selected) return;
var func;
if ($scope.selected.type === 'platform') func = Client.getPlatformLogs;
else if ($scope.selected.type === 'service') func = Client.getServiceLogs;
else if ($scope.selected.type === 'task') func = Client.getTaskLogs;
else if ($scope.selected.type === 'app') func = Client.getAppLogs;
func($scope.selected.value, true /* follow */, $scope.lines, function handleLogs(error, result) {
if (error) return console.error(error);
$scope.activeEventSource = result;
result.onmessage = function handleMessage(message) {
var data;
try {
data = JSON.parse(message.data);
} catch (e) {
return console.error(e);
}
// check if we want to auto scroll (this is before the appending, as that skews the check)
var tmp = $('.logs-container');
var autoScroll = tmp[0].scrollTop > (tmp[0].scrollHeight - tmp.innerHeight() - 24);
var logLine = $('<div class="log-line">');
// realtimeTimestamp is 0 if line is blank or some parse error
var timeString = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
logLine.html('<span class="time">' + timeString + ' </span>' + window.ansiToHTML(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message))));
tmp.append(logLine);
if (autoScroll) tmp[0].lastChild.scrollIntoView({ behavior: 'instant', block: 'end' });
};
});
}
function select(ids, callback) {
if (ids.id && ids.id.indexOf('redis:') === 0) {
$scope.selected = {
name: 'Redis',
type: 'service',
value: ids.id,
url: Client.makeURL('/api/v1/services/' + ids.id + '/logs')
};
callback();
} else if (ids.id) {
var BUILT_IN_LOGS = [
{ name: 'Box', type: 'platform', value: 'box', url: Client.makeURL('/api/v1/cloudron/logs/box') },
{ name: 'Graphite', type: 'service', value: 'graphite', url: Client.makeURL('/api/v1/services/graphite/logs') },
{ name: 'MongoDB', type: 'service', value: 'mongodb', url: Client.makeURL('/api/v1/services/mongodb/logs') },
{ name: 'MySQL', type: 'service', value: 'mysql', url: Client.makeURL('/api/v1/services/mysql/logs') },
{ name: 'PostgreSQL', type: 'service', value: 'postgresql', url: Client.makeURL('/api/v1/services/postgresql/logs') },
{ name: 'Mail', type: 'service', value: 'mail', url: Client.makeURL('/api/v1/services/mail/logs') },
{ name: 'Docker', type: 'service', value: 'docker', url: Client.makeURL('/api/v1/services/docker/logs') },
{ name: 'Nginx', type: 'service', value: 'nginx', url: Client.makeURL('/api/v1/services/nginx/logs') },
{ name: 'Unbound', type: 'service', value: 'unbound', url: Client.makeURL('/api/v1/services/unbound/logs') },
{ name: 'SFTP', type: 'service', value: 'sftp', url: Client.makeURL('/api/v1/services/sftp/logs') },
{ name: 'TURN/STUN', type: 'service', value: 'turn', url: Client.makeURL('/api/v1/services/turn/logs') },
];
$scope.selected = BUILT_IN_LOGS.find(function (e) { return e.value === ids.id; });
callback();
} else if (ids.crashId) {
$scope.selected = {
type: 'platform',
value: 'crash-' + ids.crashId,
name: 'Crash',
url: Client.makeURL('/api/v1/cloudron/logs/crash-' + ids.crashId)
};
callback();
} else if (ids.appId) {
Client.getApp(ids.appId, function (error, app) {
if (error) return callback(error);
$scope.selectedAppInfo = app;
$scope.selected = {
type: 'app',
value: app.id,
name: app.fqdn + ' (' + app.manifest.title + ')',
url: Client.makeURL('/api/v1/apps/' + app.id + '/logs'),
addons: app.manifest.addons
};
callback();
});
} else if (ids.taskId) {
Client.getTask(ids.taskId, function (error, task) {
if (error) return callback(error);
$scope.selectedTaskInfo = task;
$scope.selected = {
type: 'task',
value: task.id,
name: task.type,
url: Client.makeURL('/api/v1/tasks/' + task.id + '/logs')
};
callback();
});
}
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
window.location.href = '/';
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
console.log('Running log version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
Client.refreshConfig(function (error) {
if (error) return Client.initError(error, init);
select({ id: search.id, taskId: search.taskId, appId: search.appId, crashId: search.crashId }, function (error) {
if (error) return Client.initError(error, init);
// now mark the Client to be ready
Client.setReady();
$scope.initialized = true;
showLogs();
});
});
});
});
}
init();
$translate([ 'logs.title' ]).then(function (tr) {
if (tr['logs.title'] !== 'logs.title') window.document.title = tr['logs.title'];
});
}]);

217
dashboard/src/js/main.js Normal file
View File

@@ -0,0 +1,217 @@
'use strict';
/* global angular */
/* global $ */
angular.module('Application').controller('MainController', ['$scope', '$route', '$timeout', '$location', '$interval', 'Notification', 'Client', function ($scope, $route, $timeout, $location, $interval, Notification, Client) {
$scope.initialized = false; // used to animate the UI
$scope.user = Client.getUserInfo();
$scope.installedApps = Client.getInstalledApps();
$scope.config = {};
$scope.client = Client;
$scope.subscription = {};
$scope.notificationCount = 0;
$scope.hideNavBarActions = $location.path() === '/logs';
$scope.backgroundImageUrl = '';
$scope.reboot = {
busy: false,
show: function () {
$scope.reboot.busy = false;
$('#rebootModal').modal('show');
},
submit: function () {
$scope.reboot.busy = true;
Client.reboot(function (error) {
if (error) return Client.error(error);
$('#rebootModal').modal('hide');
// trigger refetch to show offline banner
$timeout(function () { Client.getStatus(function () {}); }, 5000);
});
}
};
$scope.isActive = function (url) {
if (!$route.current) return false;
return $route.current.$$route.originalPath.indexOf(url) === 0;
};
$scope.logout = function (event) {
event.stopPropagation();
$scope.initialized = false;
Client.logout();
};
$scope.openSubscriptionSetup = function () {
Client.openSubscriptionSetup($scope.subscription);
};
// NOTE: this function is exported and called from the appstore.js
$scope.updateSubscriptionStatus = function () {
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
$scope.subscription = subscription;
});
};
function refreshNotifications() {
if (!Client.getUserInfo().isAtLeastAdmin) return;
Client.getNotifications({ acknowledged: false }, 1, 100, function (error, results) { // counter maxes out at 100
if (error) console.error(error);
else $scope.notificationCount = results.length;
});
}
// update state of acknowledged notification
$scope.notificationAcknowledged = function () {
refreshNotifications();
};
function redirectOnMandatory2FA() {
if (Client.getConfig().mandatory2FA && !Client.getUserInfo().twoFactorAuthenticationEnabled) {
$location.path('/profile').search({ setup2fa: true });
}
}
// Make it redirect if the browser URL is changed directly - https://forum.cloudron.io/topic/7510/bug-in-2fa-force
$scope.$on('$routeChangeStart', function (/* event */) {
if ($scope.initialized) redirectOnMandatory2FA();
});
var gPlatformStatusNotification = null;
function trackPlatformStatus() {
Client.getPlatformStatus(function (error, result) {
if (error) return console.error('Failed to get platform status.', error);
// see box/src/platform.js
if (result.message === 'Ready') {
if (gPlatformStatusNotification) {
gPlatformStatusNotification.kill();
gPlatformStatusNotification = null;
}
return;
}
if (!gPlatformStatusNotification) {
var options = { title: 'Platform status', message: result.message, delay: 'notimeout', replaceMessage: true, closeOnClick: false };
Notification.primary(options).then(function (result) {
gPlatformStatusNotification = result;
$timeout(trackPlatformStatus, 5000);
});
} else {
gPlatformStatusNotification.message = result.message;
$timeout(trackPlatformStatus, 5000);
}
});
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
// WARNING if anything about the routing is changed here test these use-cases:
//
// 1. Caas
// 3. selfhosted restore
// 4. local development with gulp develop
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
if (status.restore.active || status.restore.errorMessage) { // show the error message in restore page
window.location.href = '/restore.html' + window.location.search;
} else {
window.location.href = (status.adminFqdn ? '/setup.html' : '/setupdns.html') + window.location.search;
}
return;
}
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setupdns.html' + window.location.search;
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
console.log('Running dashboard version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
Client.refreshConfig(function (error) {
if (error) return Client.initError(error, init);
Client.refreshAvailableLanguages(function (error) {
if (error) return Client.initError(error, init);
Client.refreshInstalledApps(function (error) {
if (error) return Client.initError(error, init);
// now mark the Client to be ready
Client.setReady();
$scope.config = Client.getConfig();
if (Client.getUserInfo().hasBackgroundImage) {
document.getElementById('mainContentContainer').style.backgroundImage = 'url("' + Client.getBackgroundImageUrl() + '")';
document.getElementById('mainContentContainer').classList.add('has-background');
}
$scope.initialized = true;
redirectOnMandatory2FA();
$interval(refreshNotifications, 60 * 1000);
refreshNotifications();
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
$scope.subscription = subscription;
// only track platform status if we are registered
trackPlatformStatus();
});
});
});
});
});
});
}
Client.onConfig(function (config) {
if (config.cloudronName) {
document.title = config.cloudronName;
}
});
init();
// setup all the dialog focus handling
['updateModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
}]);

369
dashboard/src/js/restore.js Normal file
View File

@@ -0,0 +1,369 @@
'use strict';
/* global $, angular, tld, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('RestoreController', ['$scope', 'Client', function ($scope, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.client = Client;
$scope.busy = false;
$scope.error = {};
$scope.message = ''; // progress
// variables here have to match the import config logic!
$scope.provider = '';
$scope.bucket = '';
$scope.prefix = '';
$scope.mountPoint = '';
$scope.accessKeyId = '';
$scope.secretAccessKey = '';
$scope.gcsKey = { keyFileName: '', content: '' };
$scope.region = '';
$scope.endpoint = '';
$scope.backupFolder = '';
$scope.remotePath = '';
$scope.instanceId = '';
$scope.acceptSelfSignedCerts = false;
$scope.format = 'tgz';
$scope.advancedVisible = false;
$scope.password = '';
$scope.encryptedFilenames = true;
$scope.encrypted = false; // only used if a backup config contains that flag
$scope.setupToken = '';
$scope.skipDnsSetup = false;
$scope.mountOptions = {
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
user: '',
seal: false,
port: 22,
privateKey: ''
};
$scope.sysinfo = {
provider: 'generic',
ipv4: '',
ifname: ''
};
$scope.sysinfoProvider = [
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.s3Regions = REGIONS_S3;
$scope.wasabiRegions = REGIONS_WASABI;
$scope.doSpacesRegions = REGIONS_DIGITALOCEAN;
$scope.exoscaleSosRegions = REGIONS_EXOSCALE;
$scope.scalewayRegions = REGIONS_SCALEWAY;
$scope.linodeRegions = REGIONS_LINODE;
$scope.ovhRegions = REGIONS_OVH;
$scope.ionosRegions = REGIONS_IONOS;
$scope.upcloudRegions = REGIONS_UPCLOUD;
$scope.vultrRegions = REGIONS_VULTR;
$scope.storageProviders = STORAGE_PROVIDERS;
$scope.formats = BACKUP_FORMATS;
$scope.s3like = function (provider) {
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos'
|| provider === 'digitalocean-spaces' || provider === 'wasabi' || provider === 'scaleway-objectstorage'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2'
|| provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2';
};
$scope.mountlike = function (provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs';
};
$scope.restore = function () {
$scope.error = {};
$scope.busy = true;
var backupConfig = {
provider: $scope.provider,
format: $scope.format,
};
if ($scope.password) {
backupConfig.password = $scope.password;
backupConfig.encryptedFilenames = $scope.encryptedFilenames;
}
// only set provider specific fields, this will clear them in the db
if ($scope.s3like(backupConfig.provider)) {
backupConfig.bucket = $scope.bucket;
backupConfig.prefix = $scope.prefix;
backupConfig.accessKeyId = $scope.accessKeyId;
backupConfig.secretAccessKey = $scope.secretAccessKey;
if ($scope.endpoint) backupConfig.endpoint = $scope.endpoint;
if (backupConfig.provider === 's3') {
if ($scope.region) backupConfig.region = $scope.region;
delete backupConfig.endpoint;
} else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') {
backupConfig.region = backupConfig.region || 'us-east-1';
backupConfig.acceptSelfSignedCerts = $scope.acceptSelfSignedCerts;
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
} else if (backupConfig.provider === 'exoscale-sos') {
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'wasabi') {
backupConfig.region = $scope.wasabiRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'scaleway-objectstorage') {
backupConfig.region = $scope.scalewayRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'linode-objectstorage') {
backupConfig.region = $scope.linodeRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'ovh-objectstorage') {
backupConfig.region = $scope.ovhRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'ionos-objectstorage') {
backupConfig.region = $scope.ionosRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'vultr-objectstorage') {
backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'upcloud-objectstorage') {
var m = /^.*\.(.*)\.upcloudobjects.com$/.exec(backupConfig.endpoint);
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
}
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.bucket;
backupConfig.prefix = $scope.prefix;
try {
var serviceAccountKey = JSON.parse($scope.gcsKey.content);
backupConfig.projectId = serviceAccountKey.project_id;
backupConfig.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!backupConfig.projectId || !backupConfig.credentials || !backupConfig.credentials.client_email || !backupConfig.credentials.private_key) {
throw 'fields_missing';
}
} catch (e) {
$scope.error.generic = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.error.gcsKeyInput = true;
$scope.busy = false;
return;
}
} else if ($scope.mountlike(backupConfig.provider)) {
backupConfig.prefix = $scope.prefix;
backupConfig.mountOptions = {};
if (backupConfig.provider === 'cifs' || backupConfig.provider === 'sshfs' || backupConfig.provider === 'nfs') {
backupConfig.mountOptions.host = $scope.mountOptions.host;
backupConfig.mountOptions.remoteDir = $scope.mountOptions.remoteDir;
if (backupConfig.provider === 'cifs') {
backupConfig.mountOptions.username = $scope.mountOptions.username;
backupConfig.mountOptions.password = $scope.mountOptions.password;
backupConfig.mountOptions.seal = $scope.mountOptions.seal;
} else if (backupConfig.provider === 'sshfs') {
backupConfig.mountOptions.user = $scope.mountOptions.user;
backupConfig.mountOptions.port = $scope.mountOptions.port;
backupConfig.mountOptions.privateKey = $scope.mountOptions.privateKey;
}
} else if (backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') {
backupConfig.mountOptions.diskPath = $scope.mountOptions.diskPath;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountPoint = $scope.mountPoint;
}
} else if (backupConfig.provider === 'filesystem') {
backupConfig.backupFolder = $scope.backupFolder;
}
if ($scope.remotePath.indexOf('/') === -1) {
$scope.error.generic = 'Backup id must include the directory path';
$scope.error.remotePath = true;
$scope.busy = false;
return;
}
if ($scope.remotePath.indexOf('box') === -1) {
$scope.error.generic = 'Backup id must contain "box"';
$scope.error.remotePath = true;
$scope.busy = false;
return;
}
var version = $scope.remotePath.match(/_v(\d+.\d+.\d+)/);
if (!version) {
$scope.error.generic = 'Backup id is missing version information';
$scope.error.remotePath = true;
$scope.busy = false;
return;
}
var sysinfoConfig = {
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ipv4 = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
Client.restore(backupConfig, $scope.remotePath.replace(/\.tar\.gz(\.enc)?$/, ''), version ? version[1] : '', sysinfoConfig, $scope.skipDnsSetup, $scope.setupToken, function (error) {
$scope.busy = false;
if (error) {
if (error.statusCode === 424) {
$scope.error.generic = error.message;
if (error.message.indexOf('AWS Access Key Id') !== -1) {
$scope.error.accessKeyId = true;
$scope.accessKeyId = '';
$scope.configureBackupForm.accessKeyId.$setPristine();
$('#inputConfigureBackupAccessKeyId').focus();
} else if (error.message.indexOf('not match the signature') !== -1 ) {
$scope.error.secretAccessKey = true;
$scope.secretAccessKey = '';
$scope.configureBackupForm.secretAccessKey.$setPristine();
$('#inputConfigureBackupSecretAccessKey').focus();
} else if (error.message.toLowerCase() === 'access denied') {
$scope.error.bucket = true;
$scope.bucket = '';
$scope.configureBackupForm.bucket.$setPristine();
$('#inputConfigureBackupBucket').focus();
} else if (error.message.indexOf('ECONNREFUSED') !== -1) {
$scope.error.generic = 'Unknown region';
$scope.error.region = true;
$scope.configureBackupForm.region.$setPristine();
$('#inputConfigureBackupDORegion').focus();
} else if (error.message.toLowerCase() === 'wrong region') {
$scope.error.generic = 'Wrong S3 Region';
$scope.error.region = true;
$scope.configureBackupForm.region.$setPristine();
$('#inputConfigureBackupS3Region').focus();
} else {
$('#inputConfigureBackupBucket').focus();
}
} else {
$scope.error.generic = error.message;
}
return;
}
waitForRestore();
});
};
function waitForRestore() {
$scope.busy = true;
Client.getStatus(function (error, status) {
if (!error && !status.restore.active) { // restore finished
if (status.restore.errorMessage) {
$scope.busy = false;
$scope.error.generic = status.restore.errorMessage;
} else { // restore worked, redirect to admin page
window.location.href = '/';
}
return;
}
if (!error) $scope.message = status.restore.message;
setTimeout(waitForRestore, 5000);
});
}
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
document.getElementById('gcsKeyFileInput').onchange = readFileLocally($scope.gcsKey, 'content', 'keyFileName');
document.getElementById('backupConfigFileInput').onchange = function (event) {
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read backup config');
var backupConfig;
try {
backupConfig = JSON.parse(result.target.result);
} catch (e) {
console.error('Unable to parse backup config');
return;
}
$scope.$apply(function () {
// we assume property names match here, this does not yet work for gcs keys
Object.keys(backupConfig).forEach(function (k) {
if (k in $scope) $scope[k] = backupConfig[k];
});
// this allows the config to potentially have a raw password (though our UI sets it to placeholder)
if ($scope.mountOptions.password === SECRET_PLACEHOLDER) $scope.mountOptions.password = '';
});
};
reader.readAsText(event.target.files[0]);
};
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (status.restore.active) return waitForRestore();
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage;
if (status.activated) {
window.location.href = '/';
return;
}
$scope.status = status;
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.initialized = true;
});
}
init();
}]);

118
dashboard/src/js/setup.js Normal file
View File

@@ -0,0 +1,118 @@
'use strict';
/* global angular */
/* global $ */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupController', ['$scope', 'Client', function ($scope, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.client = Client;
$scope.view = '';
$scope.initialized = false;
$scope.setupToken = '';
$scope.owner = {
error: null,
busy: false,
email: '',
displayName: '',
username: '',
password: '',
submit: function () {
$scope.owner.busy = true;
$scope.owner.error = null;
var data = {
username: $scope.owner.username,
password: $scope.owner.password,
email: $scope.owner.email,
displayName: $scope.owner.displayName,
setupToken: $scope.setupToken
};
Client.createAdmin(data, function (error) {
if (error && error.statusCode === 400) {
$scope.owner.busy = false;
if (error.message === 'Invalid email') {
$scope.owner.error = { email: error.message };
$scope.owner.email = '';
$scope.ownerForm.email.$setPristine();
setTimeout(function () { $('#inputEmail').focus(); }, 200);
} else {
$scope.owner.error = { username: error.message };
$scope.owner.username = '';
$scope.ownerForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
}
return;
} else if (error) {
$scope.owner.busy = false;
console.error('Internal error', error);
$scope.owner.error = { generic: error.message };
return;
}
setView('finished');
});
}
};
function redirectIfNeeded(status) {
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return;
}
// if we are here from the ip first go to the real domain if already setup
if (status.adminFqdn && status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/setup.html';
return;
}
// if we don't have a domain yet, first go to domain setup
if (!status.adminFqdn) {
window.location.href = '/setupdns.html';
return;
}
if (status.activated) {
window.location.href = '/';
return;
}
}
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
} else {
$scope.view = 'owner';
}
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
redirectIfNeeded(status);
setView(search.view);
$scope.setupToken = search.setupToken;
$scope.initialized = true;
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
});
}
init();
}]);

View File

@@ -0,0 +1,148 @@
'use strict';
/* global angular, $, showdown */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies']);
app.filter('markdown2html', function () {
var converter = new showdown.Converter({
simplifiedAutoLink: true,
strikethrough: true,
tables: true,
openLinksInNewWindow: true
});
return function (text) {
return converter.makeHtml(text);
};
});
// disable sce for footer https://code.angularjs.org/1.5.8/docs/api/ng/service/$sce
app.config(function ($sceProvider) {
$sceProvider.enabled(false);
});
app.config(['$translateProvider', function ($translateProvider) {
$translateProvider.useStaticFilesLoader({
prefix: 'translation/',
suffix: '.json'
});
$translateProvider.useLocalStorage();
$translateProvider.preferredLanguage('en');
$translateProvider.fallbackLanguage('en');
}]);
// Add shorthand "tr" filter to avoid having ot use "translate"
// This is a copy of the code at https://github.com/angular-translate/angular-translate/blob/master/src/filter/translate.js
// If we find out how to get that function handle somehow dynamically we can use that, otherwise the copy is required
function translateFilterFactory($parse, $translate) {
'use strict';
var translateFilter = function (translationId, interpolateParams, interpolation, forceLanguage) {
if (!angular.isObject(interpolateParams)) {
var ctx = this || {
'__SCOPE_IS_NOT_AVAILABLE': 'More info at https://github.com/angular/angular.js/commit/8863b9d04c722b278fa93c5d66ad1e578ad6eb1f'
};
interpolateParams = $parse(interpolateParams)(ctx);
}
return $translate.instant(translationId, interpolateParams, interpolation, forceLanguage);
};
if ($translate.statefulFilter()) {
translateFilter.$stateful = true;
}
return translateFilter;
}
translateFilterFactory.displayName = 'translateFilterFactory';
app.filter('tr', translateFilterFactory);
app.controller('SetupAccountController', ['$scope', '$translate', '$http', function ($scope, $translate, $http) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.indexOf('=') === -1 ? [item, true] : [item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=')+1)]; }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
var API_ORIGIN = '<%= apiOrigin %>' || window.location.origin;
$scope.initialized = false;
$scope.busy = false;
$scope.error = null;
$scope.view = 'setup';
$scope.status = null;
$scope.profileLocked = !!search.profileLocked;
$scope.existingUsername = !!search.username;
$scope.username = search.username || '';
$scope.displayName = search.displayName || '';
$scope.password = '';
$scope.passwordRepeat = '';
$scope.onSubmit = function () {
$scope.busy = true;
$scope.error = null;
var data = {
inviteToken: search.inviteToken,
password: $scope.password
};
if (!$scope.profileLocked) {
if (!$scope.existingUsername) data.username = $scope.username;
data.displayName = $scope.displayName;
}
function error(data, status) {
$scope.busy = false;
if (status === 401) {
$scope.view = 'invalidToken';
} else if (status === 409) {
$scope.error = {
username: true,
message: 'Username already taken'
};
$scope.setupAccountForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
} else if (status === 400) {
$scope.error = {
message: data.message
};
if (data.message.indexOf('Username') === 0) {
$scope.setupAccountForm.username.$setPristine();
$scope.error.username = true;
}
} else {
$scope.error = { message: 'Unknown error. Please try again later.' };
console.error(status, data);
}
}
$http.post(API_ORIGIN + '/api/v1/cloudron/setup_account', data).success(function (data, status) {
if (status !== 201) return error(data, status);
// set token to autologin
localStorage.token = data.accessToken;
$scope.view = 'done';
}).error(error);
};
if (!$scope.existingUsername && $scope.profileLocked) {
$scope.view = 'noUsername';
$scope.initialized = true;
} else {
$http.get(API_ORIGIN + '/api/v1/cloudron/status').success(function (data, status) {
$scope.initialized = true;
if (status !== 200) return;
if (data.language) $translate.use(data.language);
$scope.status = data;
}).error(function () {
$scope.initialized = false;
});
}
}]);

View File

@@ -0,0 +1,326 @@
'use strict';
/* global $, tld, angular, Clipboard */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', function ($scope, $http, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.state = null; // 'initialized', 'waitingForDnsSetup', 'waitingForBox'
$scope.error = {};
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.isDomain = false;
$scope.isSubdomain = false;
$scope.advancedVisible = false;
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
{ name: 'Let\'s Encrypt Prod - Wildcard', value: 'letsencrypt-prod-wildcard' },
{ name: 'Let\'s Encrypt Staging', value: 'letsencrypt-staging' },
{ name: 'Let\'s Encrypt Staging - Wildcard', value: 'letsencrypt-staging-wildcard' },
{ name: 'Self-Signed', value: 'fallback' }, // this is not 'Custom' because we don't allow user to upload certs during setup phase
];
$scope.sysinfo = {
provider: 'generic',
ipv4: '',
ifname: ''
};
$scope.sysinfoProvider = [
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
};
// If we migrate the api origin we have to poll the new location
if (search.admin_fqdn) Client.apiOrigin = 'https://' + search.admin_fqdn;
$scope.$watch('dnsCredentials.domain', function (newVal) {
if (!newVal) {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else if (!tld.getDomain(newVal) || newVal[newVal.length-1] === '.') {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else {
$scope.isDomain = true;
$scope.isSubdomain = tld.getDomain(newVal) !== newVal;
}
});
// keep in sync with domains.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.dnsCredentials = {
busy: false,
domain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
gandiApiKey: '',
cloudflareEmail: '',
cloudflareToken: '',
cloudflareTokenType: 'GlobalApiKey',
cloudflareDefaultProxyStatus: false,
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
hetznerToken: '',
vultrToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
}
};
$scope.setDefaultTlsProvider = function () {
var dnsProvider = $scope.dnsCredentials.provider;
// wildcard LE won't work without automated DNS
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod';
} else {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod-wildcard';
}
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName');
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.error = {};
var provider = $scope.dnsCredentials.provider;
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
config.secretAccessKey = $scope.dnsCredentials.secretAccessKey;
} else if (provider === 'gcdns') {
try {
var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content);
config.projectId = serviceAccountKey.project_id;
config.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!config.projectId || !config.credentials || !config.credentials.client_email || !config.credentials.private_key) {
throw new Error('One or more fields are missing in the JSON');
}
} catch (e) {
$scope.error.dnsCredentials = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.dnsCredentials.busy = false;
return;
}
} else if (provider === 'digitalocean') {
config.token = $scope.dnsCredentials.digitalOceanToken;
} else if (provider === 'gandi') {
config.token = $scope.dnsCredentials.gandiApiKey;
} else if (provider === 'godaddy') {
config.apiKey = $scope.dnsCredentials.godaddyApiKey;
config.apiSecret = $scope.dnsCredentials.godaddyApiSecret;
} else if (provider === 'cloudflare') {
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
config.token = $scope.dnsCredentials.vultrToken;
} else if (provider === 'namecom') {
config.username = $scope.dnsCredentials.nameComUsername;
config.token = $scope.dnsCredentials.nameComToken;
} else if (provider === 'namecheap') {
config.token = $scope.dnsCredentials.namecheapApiKey;
config.username = $scope.dnsCredentials.namecheapUsername;
} else if (provider === 'netcup') {
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
}
var tlsConfig = {
provider: $scope.dnsCredentials.tlsConfig.provider,
wildcard: false
};
if ($scope.dnsCredentials.tlsConfig.provider.indexOf('-wildcard') !== -1) {
tlsConfig.provider = tlsConfig.provider.replace('-wildcard', '');
tlsConfig.wildcard = true;
}
var sysinfoConfig = {
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ipv4 = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
var data = {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
config: config,
tlsConfig: tlsConfig
},
sysinfoConfig: sysinfoConfig,
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
return;
}
waitForDnsSetup();
});
};
function waitForDnsSetup() {
$scope.state = 'waitingForDnsSetup';
Client.getStatus(function (error, status) {
if (!error && !status.setup.active) {
if (!status.adminFqdn || status.setup.errorMessage) { // setup reset or errored. start over
$scope.error.setup = status.setup.errorMessage;
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/setup.html' + (window.location.search);
}
return;
}
$scope.message = status.setup.message;
setTimeout(waitForDnsSetup, 5000);
});
}
function initialize() {
Client.getStatus(function (error, status) {
if (error) {
// During domain migration, the box code restarts and can result in getStatus() failing temporarily
console.error(error);
$scope.state = 'waitingForBox';
return $timeout(initialize, 3000);
}
// domain is currently like a lock flag
if (status.adminFqdn) return waitForDnsSetup();
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
} else if (status.provider === 'linode' || status.provider === 'linode-oneclick' || status.provider === 'linode-stackscript') {
$scope.dnsCredentials.provider = 'linode';
} else if (status.provider === 'vultr' || status.provider === 'vultr-mp') {
$scope.dnsCredentials.provider = 'vultr';
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
$scope.dnsCredentials.provider = 'route53';
}
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.provider = status.provider;
$scope.state = 'initialized';
setTimeout(function () { $("[autofocus]:first").focus(); }, 100);
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
initialize();
}]);

View File

@@ -0,0 +1,376 @@
'use strict';
/* global angular, $, Terminal, AttachAddon, FitAddon, ISTATES */
// create main application module
angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification']);
angular.module('Application').controller('TerminalController', ['$scope', '$translate', '$timeout', '$location', 'Client', function ($scope, $translate, $timeout, $location, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.apps = [];
$scope.selected = '';
$scope.terminal = null;
$scope.terminalSocket = null;
$scope.fitAddon = null;
$scope.restartAppBusy = false;
$scope.appBusy = false;
$scope.selectedAppInfo = null;
$scope.schedulerTasks = [];
$scope.downloadFile = {
error: '',
filePath: '',
busy: false,
downloadUrl: function () {
if (!$scope.downloadFile.filePath) return '';
var filePath = encodeURIComponent($scope.downloadFile.filePath);
return Client.apiOrigin + '/api/v1/apps/' + $scope.selected.value + '/download?file=' + filePath + '&access_token=' + Client.getToken();
},
show: function () {
$scope.downloadFile.busy = false;
$scope.downloadFile.error = '';
$scope.downloadFile.filePath = '';
$('#downloadFileModal').modal('show');
},
submit: function () {
$scope.downloadFile.busy = true;
Client.checkDownloadableFile($scope.selected.value, $scope.downloadFile.filePath, function (error) {
$scope.downloadFile.busy = false;
if (error) {
$scope.downloadFile.error = 'The requested file does not exist.';
return;
}
// we have to click the link to make the browser do the download
// don't know how to prevent the browsers
$('#fileDownloadLink')[0].click();
$('#downloadFileModal').modal('hide');
});
}
};
$scope.uploadProgress = {
busy: false,
total: 0,
current: 0,
show: function () {
$scope.uploadProgress.total = 0;
$scope.uploadProgress.current = 0;
$('#uploadProgressModal').modal('show');
},
hide: function () {
$('#uploadProgressModal').modal('hide');
}
};
$scope.uploadFile = function () {
var fileUpload = document.querySelector('#fileUpload');
fileUpload.onchange = function (e) {
if (e.target.files.length === 0) return;
$scope.uploadProgress.busy = true;
$scope.uploadProgress.show();
Client.uploadFile($scope.selected.value, e.target.files[0], function progress(e) {
$scope.uploadProgress.total = e.total;
$scope.uploadProgress.current = e.loaded;
}, function (error) {
if (error) console.error(error);
$scope.uploadProgress.busy = false;
$scope.uploadProgress.hide();
});
};
fileUpload.click();
};
$scope.usesAddon = function (addon) {
if (!$scope.selected || !$scope.selected.addons) return false;
return !!Object.keys($scope.selected.addons).find(function (a) { return a === addon; });
};
function reset() {
if ($scope.terminal) {
$scope.terminal.dispose();
$scope.terminal = null;
}
if ($scope.terminalSocket) {
$scope.terminalSocket = null;
}
$scope.selectedAppInfo = null;
}
$scope.restartApp = function () {
$scope.restartAppBusy = true;
$scope.appBusy = true;
var appId = $scope.selected.value;
function waitUntilRestarted(callback) {
refreshApp(appId, function (error, result) {
if (error) return callback(error);
if (result.installationState === ISTATES.INSTALLED) return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartApp(appId, function (error) {
if (error) console.error('Failed to restart app.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartAppBusy = false;
$scope.appBusy = false;
});
});
};
function createTerminalSocket(callback) {
var appId = $scope.selected.value;
Client.createExec(appId, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, function (error, execId) {
if (error) return callback(error);
try {
// websocket cannot use relative urls
var url = Client.apiOrigin.replace('https', 'wss') + '/api/v1/apps/' + appId + '/exec/' + execId + '/startws?tty=true&rows=' + $scope.terminal.rows + '&columns=' + $scope.terminal.cols + '&access_token=' + Client.getToken();
$scope.terminalSocket = new WebSocket(url);
$scope.terminal.loadAddon(new AttachAddon.AttachAddon($scope.terminalSocket));
$scope.terminalSocket.onclose = function () {
// retry in one second
$scope.terminalReconnectTimeout = setTimeout(function () {
showTerminal(true);
}, 1000);
};
callback();
} catch (e) {
callback(e);
}
});
}
function refreshApp(id, callback) {
Client.getApp(id, function (error, result) {
if (error) return callback(error);
$scope.selectedAppInfo = result;
callback(null, result);
});
}
function showTerminal(retry) {
reset();
if (!$scope.selected) return;
var appId = $scope.selected.value;
refreshApp(appId, function (error) {
if (error) return console.error(error);
var result = $scope.selectedAppInfo;
$scope.schedulerTasks = result.manifest.addons.scheduler ? Object.keys(result.manifest.addons.scheduler).map(function (k) { return { name: k, command: result.manifest.addons.scheduler[k].command }; }) : [];
$scope.terminal = new Terminal();
$scope.fitAddon = new FitAddon.FitAddon();
$scope.terminal.loadAddon($scope.fitAddon);
$scope.terminal.open(document.querySelector('#terminalContainer'));
window.terminal = $scope.terminal;
// Let the browser handle paste
$scope.terminal.attachCustomKeyEventHandler(function (e) {
if (e.key === 'v' && (e.ctrlKey || e.metaKey)) return false;
});
if (retry) $scope.terminal.writeln('Reconnecting...');
else $scope.terminal.writeln('Connecting...');
// we have to give it some time to setup the terminal to make it fit, there is no event unfortunately
setTimeout(function () {
if (!$scope.terminal) return;
// this is here so that the text wraps correctly after the fit!
// var YELLOW = '\u001b[33m'; // https://gist.github.com/dainkaplan/4651352
// var NC = '\u001b[0m';
// $scope.terminal.writeln(YELLOW + 'If you resize the browser window, press Ctrl+D to start a new session with the current size.' + NC);
// we have to first write something on reconnect after app restart..not sure why
$scope.fitAddon.fit();
// create exec container after we fit() since we cannot resize exec container post-creation
createTerminalSocket(function (error) { if (error) console.error(error); });
$scope.terminal.focus();
}, 1000);
});
}
$scope.terminalInject = function (addon, extra) {
if (!$scope.terminalSocket) return;
var cmd, manifestVersion = $scope.selected.manifest.manifestVersion;
if (addon === 'mysql') {
if (manifestVersion === 1) {
cmd = 'mysql --user=${MYSQL_USERNAME} --password=${MYSQL_PASSWORD} --host=${MYSQL_HOST} ${MYSQL_DATABASE}';
} else {
cmd = 'mysql --user=${CLOUDRON_MYSQL_USERNAME} --password=${CLOUDRON_MYSQL_PASSWORD} --host=${CLOUDRON_MYSQL_HOST} ${CLOUDRON_MYSQL_DATABASE}';
}
} else if (addon === 'postgresql') {
if (manifestVersion === 1) {
cmd = 'PGPASSWORD=${POSTGRESQL_PASSWORD} psql -h ${POSTGRESQL_HOST} -p ${POSTGRESQL_PORT} -U ${POSTGRESQL_USERNAME} -d ${POSTGRESQL_DATABASE}';
} else {
cmd = 'PGPASSWORD=${CLOUDRON_POSTGRESQL_PASSWORD} psql -h ${CLOUDRON_POSTGRESQL_HOST} -p ${CLOUDRON_POSTGRESQL_PORT} -U ${CLOUDRON_POSTGRESQL_USERNAME} -d ${CLOUDRON_POSTGRESQL_DATABASE}';
}
} else if (addon === 'mongodb') {
if (manifestVersion === 1) {
cmd = 'mongo -u "${MONGODB_USERNAME}" -p "${MONGODB_PASSWORD}" ${MONGODB_HOST}:${MONGODB_PORT}/${MONGODB_DATABASE}';
} else {
cmd = 'mongosh -u "${CLOUDRON_MONGODB_USERNAME}" -p "${CLOUDRON_MONGODB_PASSWORD}" ${CLOUDRON_MONGODB_HOST}:${CLOUDRON_MONGODB_PORT}/${CLOUDRON_MONGODB_DATABASE}';
}
} else if (addon === 'redis') {
if (manifestVersion === 1) {
cmd = 'redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDIS_PASSWORD}"';
} else {
cmd = 'redis-cli -h "${CLOUDRON_REDIS_HOST}" -p "${CLOUDRON_REDIS_PORT}" -a "${CLOUDRON_REDIS_PASSWORD}"';
}
} else if (addon === 'scheduler' && extra) {
cmd = extra.command;
}
if (!cmd) return;
cmd += ' ';
$scope.terminalSocket.send(cmd);
$scope.terminal.focus();
};
// terminal right click handling
$scope.terminalClear = function () {
if (!$scope.terminal) return;
$scope.terminal.clear();
$scope.terminal.focus();
};
$scope.terminalCopy = function () {
if (!$scope.terminal) return;
// execCommand('copy') would copy any selection from the page, so do this only if terminal has a selection
if (!$scope.terminal.getSelection()) return;
document.execCommand('copy');
$scope.terminal.focus();
};
$('.contextMenuBackdrop').on('click', function () {
$('#terminalContextMenu').hide();
$('.contextMenuBackdrop').hide();
$scope.terminal.focus();
});
$('#terminalContainer').on('contextmenu', function (e) {
if (!$scope.terminal) return true;
e.preventDefault();
$('.contextMenuBackdrop').show();
$('#terminalContextMenu').css({
display: 'block',
left: e.pageX,
top: e.pageY
});
return false;
});
window.addEventListener('resize', function () {
if ($scope.fitAddon) $scope.fitAddon.fit();
});
Client.getStatus(function (error, status) {
if (error) return $scope.error(error);
if (!status.activated) {
console.log('Not activated yet, closing or redirecting', status);
window.close();
window.location.href = '/';
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
console.log('Running terminal version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return $scope.error(error);
Client.refreshConfig(function (error) {
if (error) return $scope.error(error);
refreshApp(search.id, function (error, app) {
$scope.selected = {
type: 'app',
value: app.id,
name: app.fqdn + ' (' + app.manifest.title + ')',
addons: app.manifest.addons,
manifest: app.manifest
};
// now mark the Client to be ready
Client.setReady();
$scope.initialized = true;
showTerminal();
});
});
});
});
$translate([ 'terminal.title' ]).then(function (tr) {
if (tr['terminal.title'] !== 'terminal.title') window.document.title = tr['terminal.title'];
});
// setup all the dialog focus handling
['downloadFileModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
}]);

40
dashboard/src/js/utils.js Normal file
View File

@@ -0,0 +1,40 @@
/* This file contains helpers which should not be part of client.js */
angular.module('Application').directive('passwordReveal', function () {
var svgEye = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye" class="svg-inline--fa fa-eye fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z"></path></svg>';
var svgEyeSlash = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye-slash" class="svg-inline--fa fa-eye-slash fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z"></path></svg>';
return {
link: function (scope, elements) {
var element = elements[0];
if (!element.parentNode) {
console.error('Wrong password-reveal directive usage. Element has no parent.');
return;
}
var eye = document.createElement('i');
eye.innerHTML = svgEyeSlash;
eye.style.width = '18px';
eye.style.height = '18px';
eye.style.position = 'relative';
eye.style.float = 'right';
eye.style.marginTop = '-24px';
eye.style.marginRight = '10px';
eye.style.cursor = 'pointer';
eye.addEventListener('click', function () {
if (element.type === 'password') {
element.type = 'text';
eye.innerHTML = svgEye;
} else {
element.type = 'password';
eye.innerHTML = svgEyeSlash;
}
});
element.parentNode.style.position = 'relative';
element.parentNode.insertBefore(eye, element.nextSibling);
}
};
});