GUACAMOLE-926: Parse YAML, CSV, JSON on frontend and submit to API.

This commit is contained in:
James Muehlner
2023-01-24 19:20:03 +00:00
parent e6bd12ee4c
commit ec33c81f1a
64 changed files with 5093 additions and 9 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,13 +12,18 @@
"angular-translate-interpolation-messageformat": "^2.19.0",
"angular-translate-loader-static-files": "^2.19.0",
"blob-polyfill": ">=7.0.20220408",
"csv": "^6.2.5",
"datalist-polyfill": "^1.25.1",
"file-saver": "^2.0.5",
"jquery": "^3.6.4",
"jstz": "^2.1.1",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"yaml": "^2.2.1"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/preset-env": "^7.20.2",
"babel-loader": "^8.3.0",
"clean-webpack-plugin": "^4.0.0",
"closure-webpack-plugin": "^2.6.1",
"copy-webpack-plugin": "^5.1.2",

View File

@@ -126,6 +126,15 @@ angular.module('index').config(['$routeProvider', '$locationProvider',
resolve : { routeToUserHomePage: routeToUserHomePage }
})
// Connection import page
.when('/settings/:dataSource/import', {
title : 'APP.NAME',
bodyClassName : 'settings',
templateUrl : 'app/settings/templates/settingsImport.html',
controller : 'importConnectionsController',
resolve : { updateCurrentToken: updateCurrentToken }
})
// Management screen
.when('/settings/:dataSource?/:tab', {
title : 'APP.NAME',

View File

@@ -154,6 +154,46 @@ angular.module('rest').factory('connectionService', ['$injector',
}
};
/**
* Makes a request to the REST API to create multiple connections, returning a
* a promise that can be used for processing the results of the call. This
* operation is atomic - if any errors are encountered during the connection
* creation process, the entire request will fail, and no connections will be
* created.
*
* @param {Connection[]} connections The connections to create.
*
* @returns {Promise}
* A promise for the HTTP call which will succeed if and only if the
* create operation is successful.
*/
service.createConnections = function createConnections(dataSource, connections) {
// An object containing a PATCH operation to create each connection
const patchBody = connections.map(connection => ({
op: "add",
path: "/",
value: connection
}));
// Make a PATCH request to create the connections
return authenticationService.request({
method : 'PATCH',
url : 'api/session/data/' + encodeURIComponent(dataSource) + '/connections',
data : patchBody
})
// Clear the cache
.then(function connectionUpdated(){
cacheService.connections.removeAll();
// Clear users cache to force reload of permissions for this
// newly updated connection
cacheService.users.removeAll();
});
}
/**
* Makes a request to the REST API to delete a connection,

View File

@@ -82,4 +82,4 @@ angular.module('rest').factory('RelatedObjectPatch', [function defineRelatedObje
return RelatedObjectPatch;
}]);
}]);

View File

@@ -20,7 +20,7 @@
/**
* The controller for the session recording player page.
*/
angular.module('manage').controller('connectionHistoryPlayerController', ['$scope', '$injector',
angular.module('settings').controller('connectionHistoryPlayerController', ['$scope', '$injector',
function connectionHistoryPlayerController($scope, $injector) {
// Required services

View File

@@ -0,0 +1,78 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* The controller for the connection import page.
*/
angular.module('settings').controller('importConnectionsController', ['$scope', '$injector',
function importConnectionsController($scope, $injector) {
// Required services
const connectionImportParseService = $injector.get('connectionImportParseService');
const connectionService = $injector.get('connectionService');
function processData(type, data) {
let requestBody;
// Parse the data based on the provided mimetype
switch(type) {
case "application/json":
case "text/json":
requestBody = connectionImportParseService.parseJSON(data);
break;
case "text/csv":
requestBody = connectionImportParseService.parseCSV(data);
break;
case "application/yaml":
case "application/x-yaml":
case "text/yaml":
case "text/x-yaml":
requestBody = connectionImportParseService.parseYAML(data);
break;
}
console.log(requestBody);
}
$scope.upload = function() {
const files = angular.element('#file')[0].files;
if (files.length <= 0) {
console.error("TODO: This should be a proper error tho");
return;
}
// The file that the user uploaded
const file = files[0];
// Call processData when the data is ready
const reader = new FileReader();
reader.onloadend = (e => processData(file.type, e.target.result));
// Read all the data into memory and call processData when done
reader.readAsBinaryString(file);
}
}]);

View File

@@ -20,7 +20,7 @@
/**
* The controller for the general settings page.
*/
angular.module('manage').controller('settingsController', ['$scope', '$injector',
angular.module('settings').controller('settingsController', ['$scope', '$injector',
function settingsController($scope, $injector) {
// Required services

View File

@@ -0,0 +1,355 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* 'License'); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* global _ */
import { parse as parseCSVData } from 'csv-parse/lib/sync'
import { parse as parseYAMLData } from 'yaml'
/**
* A service for parsing user-provided JSON, YAML, or JSON connection data into
* an appropriate format for bulk uploading using the PATCH REST endpoint.
*/
angular.module('settings').factory('connectionImportParseService',
['$injector', function connectionImportParseService($injector) {
// Required types
const Connection = $injector.get('Connection');
const ParseError = $injector.get('ParseError');
const TranslatableMessage = $injector.get('TranslatableMessage');
// Required services
const $q = $injector.get('$q');
const $routeParams = $injector.get('$routeParams');
const schemaService = $injector.get('schemaService');
const connectionGroupService = $injector.get('connectionGroupService');
const service = {};
/**
* Perform basic checks, common to all file types - namely that the parsed
* data is an array, and contains at least one connection entry.
*
* @throws {ParseError}
* An error describing the parsing failure, if one of the basic checks
* fails.
*/
function performBasicChecks(parsedData) {
// Make sure that the file data parses to an array (connection list)
if (!(parsedData instanceof Array))
throw new ParseError({
message: 'Import data must be a list of connections',
translatableMessage: new TranslatableMessage({
key: 'SETTINGS_CONNECTION_IMPORT.ERROR_ARRAY_REQUIRED'
})
});
// Make sure that the connection list is not empty - contains at least
// one connection
if (!parsedData.length)
throw new ParseError({
message: 'The provided CSV file is empty',
translatableMessage: new TranslatableMessage({
key: 'SETTINGS_CONNECTION_IMPORT.ERROR_EMPTY_FILE'
})
});
}
/**
* Convert a provided YAML representation of a connection list into a JSON
* string to be submitted to the PATCH REST endpoint. The returned JSON
* string will contain a PATCH operation to create each connection in the
* provided list.
*
* @param {String} yamlData
* The YAML-encoded connection list to convert to a PATCH request body.
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided CSV.
*/
service.parseYAML = function parseYAML(yamlData) {
// Parse from YAML into a javascript array
const parsedData = parseYAMLData(yamlData);
// Check that the data is the correct format, and not empty
performBasicChecks(parsedData);
// Convert to an array of Connection objects and return
const deferredConnections = $q.defer();
deferredConnections.resolve(
parsedData.map(connection => new Connection(connection)));
return deferredConnections.promise;
};
/**
* Returns a promise that resolves to an object detailing the connection
* attributes for the current data source, as well as the connection
* paremeters for every protocol, for the current data source.
*
* The object that the promise will contain an "attributes" key that maps to
* a set of attribute names, and a "protocolParameters" key that maps to an
* object mapping protocol names to sets of parameter names for that protocol.
*
* The intended use case for this object is to determine if there is a
* connection parameter or attribute with a given name, by e.g. checking the
* path `.protocolParameters[protocolName]` to see if a protocol exists,
* checking the path `.protocolParameters[protocolName][fieldName]` to see
* if a parameter exists for a given protocol, or checking the path
* `.attributes[fieldName]` to check if a connection attribute exists.
*
* @returns {Promise.<Object>}
*/
function getFieldLookups() {
// The current data source - the one that the connections will be
// imported into
const dataSource = $routeParams.dataSource;
// Fetch connection attributes and protocols for the current data source
return $q.all({
attributes : schemaService.getConnectionAttributes(dataSource),
protocols : schemaService.getProtocols(dataSource)
})
.then(function connectionStructureRetrieved({attributes, protocols}) {
return {
// Translate the forms and fields into a flat map of attribute
// name to `true` boolean value
attributes: attributes.reduce(
(attributeMap, form) => {
form.fields.forEach(
field => attributeMap[field.name] = true);
return attributeMap
}, {}),
// Translate the protocol definitions into a map of protocol
// name to map of field name to `true` boolean value
protocolParameters: _.mapValues(
protocols, protocol => protocol.connectionForms.reduce(
(protocolFieldMap, form) => {
form.fields.forEach(
field => protocolFieldMap[field.name] = true);
return protocolFieldMap;
}, {}))
};
});
}
/**
* Returns a promise that resolves to an object mapping potential groups
* that might be encountered in an imported connection to group identifiers.
*
* The idea is that a user-provided import file might directly specify a
* parentIdentifier, or it might specify a named group path like "ROOT",
* "ROOT/parent", or "ROOT/parent/child". This object resolved by the
* promise returned from this function will map all of the above to the
* identifier of the appropriate group, if defined.
*
* @returns {Promise.<Object>}
*/
function getGroupLookups() {
// The current data source - defines all the groups that the connections
// might be imported into
const dataSource = $routeParams.dataSource;
const deferredGroupLookups = $q.defer();
connectionGroupService.getConnectionGroupTree(dataSource).then(
rootGroup => {
const groupLookup = {};
// Add the specified group to the lookup, appending all specified
// prefixes, and then recursively call saveLookups for all children
// of the group, appending to the prefix for each level
function saveLookups(prefix, group) {
// To get the path for the current group, add the name
const currentPath = prefix + group.name;
// Add the current path to the lookup
groupLookup[currentPath] = group.identifier;
// Add each child group to the lookup
const nextPrefix = currentPath + "/";
_.forEach(group.childConnectionGroups,
childGroup => saveLookups(nextPrefix, childGroup));
}
// Start at the root group
saveLookups("", rootGroup);
// Resolve with the now fully-populated lookups
deferredGroupLookups.resolve(groupLookup);
});
return deferredGroupLookups.promise;
}
/*
// Example Connection JSON
{
"attributes": {
"failover-only": "true",
"guacd-encryption": "none",
"guacd-hostname": "potato",
"guacd-port": "1234",
"ksm-user-config-enabled": "true",
"max-connections": "1",
"max-connections-per-user": "1",
"weight": "1"
},
"name": "Bloatato",
"parameters": {
"audio-servername": "heyoooooooo",
"clipboard-encoding": "",
"color-depth": "",
"create-recording-path": "",
"cursor": "remote",
"dest-host": "pooootato",
"dest-port": "4444",
"disable-copy": "",
"disable-paste": "true",
"enable-audio": "true",
"enable-sftp": "true",
"force-lossless": "true",
"hostname": "potato",
"password": "taste",
"port": "4321",
"read-only": "",
"recording-exclude-mouse": "",
"recording-exclude-output": "",
"recording-include-keys": "",
"recording-name": "heyoooooo",
"recording-path": "/path/to/goo",
"sftp-disable-download": "",
"sftp-disable-upload": "",
"sftp-hostname": "what what good sir",
"sftp-port": "",
"sftp-private-key": "lol i'll never tell",
"sftp-server-alive-interval": "",
"swap-red-blue": "true",
"username": "test",
"wol-send-packet": "",
"wol-udp-port": "",
"wol-wait-time": ""
},
// or a numeric identifier - we will probably want to offer a way to allow
// them to specify a path like "ROOT/parent/child" or just "/parent/child" or
// something like that
// TODO: Call the
"parentIdentifier": "ROOT",
"protocol": "vnc"
}
*/
/**
* Convert a provided JSON representation of a connection list into a JSON
* string to be submitted to the PATCH REST endpoint. The returned JSON
* string will contain a PATCH operation to create each connection in the
* provided list.
*
* TODO: Describe disambiguation suffixes, e.g. hostname (parameter), and
* that we will accept without the suffix if it's unambigous. (or not? how about not?)
*
* @param {String} csvData
* The JSON-encoded connection list to convert to a PATCH request body.
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided CSV.
*/
service.parseCSV = function parseCSV(csvData) {
const deferredConnections = $q.defer();
return $q.all({
fieldLookups : getFieldLookups(),
groupLookups : getGroupLookups()
})
.then(function lookupsReady({fieldLookups, groupLookups}) {
const {attributes, protocolParameters} = fieldLookups;
console.log({attributes, protocolParameters}, groupLookups);
// Convert to an array of arrays, one per CSV row (including the header)
const parsedData = parseCSVData(csvData);
// Slice off the header row to get the data rows
const connectionData = parsedData.slice(1);
// Check that the provided CSV is not empty (the parser always
// returns an array)
performBasicChecks(connectionData);
// The header row - an array of string header values
const header = parsedData[0];
// TODO: Connectionify this
deferredConnections.resolve(connectionData);
});
return deferredConnections.promise;
};
/**
* Convert a provided JSON representation of a connection list into a JSON
* string to be submitted to the PATCH REST endpoint. The returned JSON
* string will contain a PATCH operation to create each connection in the
* provided list.
*
* @param {String} jsonData
* The JSON-encoded connection list to convert to a PATCH request body.
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided CSV.
*/
service.parseJSON = function parseJSON(jsonData) {
// Parse from JSON into a javascript array
const parsedData = JSON.parse(yamlData);
// Check that the data is the correct format, and not empty
performBasicChecks(parsedData);
// Convert to an array of Connection objects and return
const deferredConnections = $q.defer();
deferredConnections.resolve(
parsedData.map(connection => new Connection(connection)));
return deferredConnections.promise;
};
return service;
}]);

View File

@@ -20,7 +20,8 @@
a.button.add-user,
a.button.add-user-group,
a.button.add-connection,
a.button.add-connection-group {
a.button.add-connection-group,
a.button.import-connections {
font-size: 0.8em;
padding-left: 1.8em;
position: relative;
@@ -29,7 +30,8 @@ a.button.add-connection-group {
a.button.add-user::before,
a.button.add-user-group::before,
a.button.add-connection::before,
a.button.add-connection-group::before {
a.button.add-connection-group::before,
a.button.import-connections::before {
content: ' ';
position: absolute;
@@ -59,3 +61,7 @@ a.button.add-connection::before {
a.button.add-connection-group::before {
background-image: url('images/action-icons/guac-group-add.svg');
}
a.button.import-connections::before {
background-image: url('images/action-icons/guac-monitor-add-many.svg');
}

View File

@@ -9,6 +9,10 @@
<!-- Form action buttons -->
<div class="action-buttons">
<a class="import-connections button"
ng-show="canCreateConnections()"
href="#/settings/{{dataSource | escape}}/import/">{{'SETTINGS_CONNECTIONS.ACTION_IMPORT_CONNECTIONS' | translate}}</a>
<a class="add-connection button"
ng-show="canCreateConnections()"
href="#/manage/{{dataSource | escape}}/connections/">{{'SETTINGS_CONNECTIONS.ACTION_NEW_CONNECTION' | translate}}</a>

View File

@@ -0,0 +1,11 @@
<div class="settings-view import">
<div class="header">
<h2>{{'SETTINGS_CONNECTION_IMPORT.HEADER' | translate}}</h2>
<guac-user-menu></guac-user-menu>
</div>
<input type="file" id="file" name="file"/>
<button ng-click="upload()">Add</button>
</div>

View File

@@ -0,0 +1,59 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Service which defines the ParseError class.
*/
angular.module('settings').factory('ParseError', [function defineParseError() {
/**
* An error representing a parsing failure when attempting to convert
* user-provided data into a list of Connection objects.
*
* @constructor
* @param {ParseError|Object} [template={}]
* The object whose properties should be copied within the new
* ParseError.
*/
var ParseError = function ParseError(template) {
// Use empty object by default
template = template || {};
/**
* A human-readable message describing the error that occurred.
*
* @type String
*/
this.message = template.message;
/**
* A message which can be translated using the translation service,
* consisting of a translation key and optional set of substitution
* variables.
*
* @type TranslatableMessage
*/
this.translatableMessage = template.translatableMessage;
};
return ParseError;
}]);

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg3828"
sodipodi:docname="guac-monitor-add-many.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3832" />
<sodipodi:namedview
id="namedview3830"
pagecolor="#5b5b5b"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="true"
showgrid="false"
inkscape:zoom="6.546875"
inkscape:cx="33.603819"
inkscape:cy="22.377088"
inkscape:window-width="2048"
inkscape:window-height="1025"
inkscape:window-x="0"
inkscape:window-y="28"
inkscape:window-maximized="1"
inkscape:current-layer="svg3828" />
<g
transform="translate(-19.073 1.734) scale(.10947)"
style="fill:#fff"
id="g3822">
<path
style="fill:#fff;fill-opacity:1;stroke:#000;stroke-width:.00956892;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0"
d="M4 .656c-2.215 0-4 1.927-4 4.313V48.03c0 2.386 1.785 4.282 4 4.282h56c2.215 0 4-1.896 4-4.282V4.97c0-2.386-1.785-4.313-4-4.313H4zm10.313 10.938h35.374c1.4 0 2.532 1.093 2.532 2.469v24.843c0 1.376-1.132 2.469-2.532 2.469H14.313c-1.4 0-2.532-1.093-2.532-2.469V14.063c0-1.376 1.132-2.47 2.531-2.47z"
transform="translate(174.22 231.813) scale(5.22525)"
id="path3818" />
<rect
ry="18"
rx="18"
y="524.187"
x="244.268"
height="38.636"
width="197.179"
style="fill:#fff;fill-opacity:1;stroke:#000;stroke-width:.05000001;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0"
id="rect3820" />
</g>
<path
style="fill:#ffffff;stroke:none"
d="m 48.309,0.865 h 7.518 V 23 h -7.518 z"
id="path3824" />
<path
style="fill:#ffffff;stroke:none"
d="m 63.135,8.173 v 7.518 H 41 V 8.173 Z"
id="path3826" />
<path
style="fill:#ffffff;stroke:none"
d="m 48.309,27 h 7.518 v 22.135 h -7.518 z"
id="path3824-2" />
<path
style="fill:#ffffff;stroke:none"
d="m 63.135,34.308 v 7.518 H 41 v -7.518 z"
id="path3826-8" />
<path
style="fill:#ffffff;stroke:none"
d="m 22.174,0.865 h 7.518 V 23 h -7.518 z"
id="path3824-4" />
<path
style="fill:#ffffff;stroke:none"
d="m 37,8.173 v 7.518 H 14.865 V 8.173 Z"
id="path3826-7" />
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -5,7 +5,7 @@
"APP" : {
"NAME" : "Apache Guacamole",
"VERSION" : "${project.version}",
"VERSION" : "1.5.0",
"ACTION_ACKNOWLEDGE" : "OK",
"ACTION_CANCEL" : "Cancel",
@@ -906,6 +906,7 @@
"SETTINGS_CONNECTIONS" : {
"ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
"ACTION_IMPORT_CONNECTIONS" : "Import",
"ACTION_NEW_CONNECTION" : "New Connection",
"ACTION_NEW_CONNECTION_GROUP" : "New Group",
"ACTION_NEW_SHARING_PROFILE" : "New Sharing Profile",
@@ -922,6 +923,13 @@
},
"SETTINGS_CONNECTION_IMPORT": {
"HEADER": "Connection Import",
"ERROR_ARRAY_REQUIRED": "The provided file must contain a list of connections"
},
"SETTINGS_PREFERENCES" : {
"ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",

View File

@@ -47,6 +47,22 @@ module.exports = {
module: {
rules: [
// NOTE: This is required in order to parse ES2020 language features,
// like the optional chaining and nullish coalescing operators. It
// specifically needs to operate on the node-modules directory since
// Webpack 4 cannot handle such language features.
{
test: /\.js$/i,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env']
]
}
}
},
// Automatically extract imported CSS for later reference within separate CSS file
{
test: /\.css$/i,