GUACAMOLE-926: Implement logic for translating CSV rows to connection objects.

This commit is contained in:
James Muehlner
2023-02-02 01:56:33 +00:00
parent fac76ef0cb
commit a6af634d86
9 changed files with 431 additions and 185 deletions

View File

@@ -25,42 +25,81 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
// Required services
const connectionParseService = $injector.get('connectionParseService');
const connectionService = $injector.get('connectionService');
const connectionService = $injector.get('connectionService');
// Required types
const ParseError = $injector.get('ParseError');
const TranslatableMessage = $injector.get('TranslatableMessage');
function handleSuccess(data) {
console.log("OMG SUCCESS: ", data)
}
// Set any caught error message to the scope for display
const handleError = error => {
console.error(error);
$scope.error = error;
}
// Clear the current error
const clearError = () => delete $scope.error;
function processData(type, data) {
let requestBody;
// The function that will process all the raw data and return a list of
// patches to be submitted to the API
let processDataCallback;
// Parse the data based on the provided mimetype
switch(type) {
case "application/json":
case "text/json":
requestBody = connectionParseService.parseJSON(data);
processDataCallback = connectionParseService.parseJSON;
break;
case "text/csv":
requestBody = connectionParseService.parseCSV(data);
processDataCallback = connectionParseService.parseCSV;
break;
case "application/yaml":
case "application/x-yaml":
case "text/yaml":
case "text/x-yaml":
requestBody = connectionParseService.parseYAML(data);
processDataCallback = connectionParseService.parseYAML;
break;
default:
handleError(new ParseError({
message: 'Invalid file type: ' + type,
key: 'CONNECTION_IMPORT.INVALID_FILE_TYPE',
variables: { TYPE: type }
}));
return;
}
// Make the call to process the data into a series of patches
processDataCallback(data)
console.log(requestBody);
// Send the data off to be imported if parsing is successful
.then(handleSuccess)
// Display any error found while parsing the file
.catch(handleError);
}
$scope.upload = function() {
// Clear any error message from the previous upload attempt
clearError();
const files = angular.element('#file')[0].files;
if (files.length <= 0) {
console.error("TODO: This should be a proper error tho");
handleError(new ParseError({
message: 'No file supplied',
key: 'CONNECTION_IMPORT.ERROR_NO_FILE_SUPPLIED'
}));
return;
}

View File

@@ -19,16 +19,26 @@
/* global _ */
// A suffix that indicates that a particular header refers to a parameter
const PARAMETER_SUFFIX = ' (parameter)';
// A suffix that indicates that a particular header refers to an attribute
const ATTRIBUTE_SUFFIX = ' (attribute)';
/**
* A service for parsing user-provided CSV connection data for bulk import.
*/
angular.module('import').factory('connectionCSVService',
['$injector', function connectionCSVService($injector) {
// Required types
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 $q = $injector.get('$q');
const $routeParams = $injector.get('$routeParams');
const schemaService = $injector.get('schemaService');
const service = {};
@@ -88,14 +98,254 @@ angular.module('import').factory('connectionCSVService',
}
/**
* Given a CSV header row, create and return a promise that will resolve to
* a function that can take a CSV data row and return a connection object.
* If an error occurs while parsing a particular row, the resolved function
* will throw a ParseError describing the failure.
*
* The provided CSV must contain columns for name and protocol. Optionally,
* the parentIdentifier of the target parent connection group, or a connection
* name path e.g. "ROOT/parent/child" may be included. Additionallty,
* connection parameters or attributes can be included.
*
* The names of connection attributes and parameters are not guaranteed to
* be mutually exclusive, so the CSV import format supports a distinguishing
* suffix. A column may be explicitly declared to be a parameter using a
* " (parameter)" suffix, or an attribute using an " (attribute)" suffix.
* No suffix is required if the name is unique across connections and
* attributes.
*
* If a parameter or attribute name conflicts with the standard
* "name", "protocol", "group", or "parentIdentifier" fields, the suffix is
* required.
*
* This returned object will be very similar to the Connection type, with
* the exception that a human-readable "group" field may be present.
*
* If a failure occurs while attempting to create the transformer function,
* the promise will be rejected with a ParseError describing the failure.
*
* @returns {Promise.<Function.<String[], Object>>}
* A promise that will resolve to a function that translates a CSV data
* row (array of strings) to a connection object.
* row (array of strings) to a connection object.
*/
service.getCSVTransformer = function getCSVTransformer(headerRow) {
// A promise that will be resolved with the transformer or rejected if
// an error occurs
const deferred = $q.defer();
getFieldLookups().then(({attributes, protocolParameters}) => {
// All configuration required to generate a function that can
// transform a row of CSV into a connection object.
// NOTE: This is a single object instead of a collection of variables
// to ensure that no stale references are used - e.g. when one getter
// invokes another getter
const transformConfig = {
// Callbacks for required fields
nameGetter: undefined,
protocolGetter: undefined,
// Callbacks for a parent group ID or group path
groupGetter: _.noop,
parentIdentifierGetter: _.noop,
// Callbacks that will generate either connection attributes or
// parameters. These callbacks will return a {type, name, value}
// object containing the type ("parameter" or "attribute"),
// the name of the attribute or parameter, and the corresponding
// value.
parameterOrAttributeGetters: []
};
// A set of all headers that have been seen so far. If any of these
// are duplicated, the CSV is invalid.
const headerSet = {};
// Iterate through the headers one by one
headerRow.forEach((rawHeader, index) => {
// Trim to normalize all headers
const header = rawHeader.trim();
// Check if the header is duplicated
if (headerSet[header]) {
deferred.reject(new ParseError({
message: 'Duplicate CSV Header: ' + header,
translatableMessage: new TranslatableMessage({
key: 'CONNECTION_IMPORT.ERROR_DUPLICATE_CSV_HEADER',
variables: { HEADER: header }
})
}));
return;
}
// Mark that this particular header has already been seen
headerSet[header] = true;
// A callback that returns the field at the current index
const fetchFieldAtIndex = row => row[index];
// Set up the name callback
if (header == 'name')
transformConfig.nameGetter = fetchFieldAtIndex;
// Set up the protocol callback
else if (header == 'protocol')
transformConfig.protocolGetter = fetchFieldAtIndex;
// Set up the group callback
else if (header == 'group')
transformConfig.groupGetter = fetchFieldAtIndex;
// Set up the group parent ID callback
else if (header == 'parentIdentifier')
transformConfig.parentIdentifierGetter = fetchFieldAtIndex;
// At this point, any other header might refer to a connection
// parameter or to an attribute
// A field may be explicitly specified as a parameter
else if (header.endsWith(PARAMETER_SUFFIX)) {
// Push as an explicit parameter getter
const parameterName = header.replace(PARAMETER_SUFFIX);
transformConfig.parameterOrAttributeGetters.push(
row => ({
type: 'parameter',
name: parameterName,
value: fetchFieldAtIndex(row)
})
);
}
// A field may be explicitly specified as a parameter
else if (header.endsWith(ATTRIBUTE_SUFFIX)) {
// Push as an explicit attribute getter
const attributeName = header.replace(ATTRIBUTE_SUFFIX);
transformConfig.parameterOrAttributeGetters.push(
row => ({
type: 'attribute',
name: parameterName,
value: fetchFieldAtIndex(row)
})
);
}
// The field is ambiguous, either an attribute or parameter,
// so the getter will have to determine this for every row
else
transformConfig.parameterOrAttributeGetters.push(row => {
// The name is just the value of the current header
const name = header;
// The value is at the index that matches the position
// of the header
const value = fetchFieldAtIndex(row);
// The protocol may determine whether a field is
// a parameter or an attribute (or both)
const protocol = transformConfig.protocolGetter(row);
// Determine if the field refers to an attribute or a
// parameter (or both, which is an error)
const isAttribute = !!attributes[name];
const isParameter = !!_.get(
protocolParameters, [protocol, name]);
// If there is both an attribute and a protocol-specific
// parameter with the provided name, it's impossible to
// figure out which this should be
if (isAttribute && isParameter)
throw new ParseError({
message: 'Ambiguous CSV Header: ' + header,
key: 'CONNECTION_IMPORT.ERROR_AMBIGUOUS_CSV_HEADER',
variables: { HEADER: header }
});
// It's neither an attribute or a parameter
else if (!isAttribute && !isParameter)
throw new ParseError({
message: 'Invalid CSV Header: ' + header,
key: 'CONNECTION_IMPORT.ERROR_INVALID_CSV_HEADER',
variables: { HEADER: header }
});
// Choose the appropriate type
const type = isAttribute ? 'attributes' : 'parameters';
return { type, name, value };
});
});
// Fail if the name wasn't provided
if (!transformConfig.nameGetter)
return deferred.reject(new ParseError({
message: 'The connection name must be provided',
key: 'CONNECTION_IMPORT.ERROR_REQUIRED_NAME'
}));
// Fail if the protocol wasn't provided
if (!transformConfig.protocolGetter)
return deferred.reject(new ParseError({
message: 'The connection protocol must be provided',
key: 'CONNECTION_IMPORT.ERROR_REQUIRED_PROTOCOL'
}));
// The function to transform a CSV row into a connection object
deferred.resolve(function transformCSVRow(row) {
const {
nameGetter, protocolGetter,
parentIdentifierGetter, groupGetter,
parameterOrAttributeGetters
} = transformConfig;
// Set name and protocol
const name = nameGetter(row);
const protocol = protocolGetter(row);
// Set the parent group ID and/or group path
const group = groupGetter && groupGetter(row);
const parentIdentifier = (
parentIdentifierGetter && parentIdentifierGetter(row));
return {
// Simple fields that are not protocol-specific
...{
name,
protocol,
parentIdentifier,
group
},
// Fields that might potentially be either attributes or
// parameters, depending on the protocol
...parameterOrAttributeGetters.reduce((values, getter) => {
// Determine the type, name, and value
const { type, name, value } = getter(row);
// Set the value and continue on to the next attribute
// or parameter
values[type][name] = value;
return values;
}, {parameters: {}, attributes: {}})
}
});
});
return deferred.promise;
};
return service;

View File

@@ -38,15 +38,17 @@ angular.module('import').factory('connectionParseService',
const $q = $injector.get('$q');
const $routeParams = $injector.get('$routeParams');
const schemaService = $injector.get('schemaService');
const connectionCSVService = $injector.get('connectionCSVService');
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.
* data is an array, and contains at least one connection entry. Returns an
* error if any of these basic checks fails.
*
* @throws {ParseError}
* returns {ParseError}
* An error describing the parsing failure, if one of the basic checks
* fails.
*/
@@ -54,80 +56,20 @@ angular.module('import').factory('connectionParseService',
// Make sure that the file data parses to an array (connection list)
if (!(parsedData instanceof Array))
throw new ParseError({
return new ParseError({
message: 'Import data must be a list of connections',
translatableMessage: new TranslatableMessage({
key: 'SETTINGS_CONNECTION_IMPORT.ERROR_ARRAY_REQUIRED'
})
key: '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'
})
return new ParseError({
message: 'The provided file is empty',
key: 'CONNECTION_IMPORT.ERROR_EMPTY_FILE'
});
}
/**
* 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
@@ -181,68 +123,9 @@ angular.module('import').factory('connectionParseService',
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
* Convert a provided CSV 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.
@@ -259,36 +142,44 @@ angular.module('import').factory('connectionParseService',
*/
service.parseCSV = function parseCSV(csvData) {
const deferredConnections = $q.defer();
// Convert to an array of arrays, one per CSV row (including the header)
// NOTE: skip_empty_lines is required, or a trailing newline will error
let parsedData;
try {
parsedData = parseCSVData(csvData, {skip_empty_lines: true});
}
return $q.all({
fieldLookups : getFieldLookups(),
groupLookups : getGroupLookups()
})
.then(function lookupsReady({fieldLookups, groupLookups}) {
// If the CSV parser throws an error, reject with that error. No
// translation key will be available here.
catch(error) {
console.error(error);
const deferred = $q.defer();
deferred.reject(error);
return deferred.promise;
}
// 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)
const checkError = performBasicChecks(connectionData);
if (checkError) {
const deferred = $q.defer();
deferred.reject(checkError);
return deferred.promise;
}
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);
});
// The header row - an array of string header values
const header = parsedData[0];
return deferredConnections.promise;
return connectionCSVService.getCSVTransformer(header).then(
// If the transformer was successfully generated, apply it to the
// data rows
// TODO: Also apply the group -> parentIdentifier transform
csvTransformer => connectionData.map(csvTransformer)
);
};
@@ -303,7 +194,7 @@ angular.module('import').factory('connectionParseService',
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided CSV.
* connection in the provided YAML.
*/
service.parseYAML = function parseYAML(yamlData) {
@@ -311,7 +202,9 @@ angular.module('import').factory('connectionParseService',
const parsedData = parseYAMLData(yamlData);
// Check that the data is the correct format, and not empty
performBasicChecks(parsedData);
const checkError = performBasicChecks(connectionData);
if (checkError)
return $q.defer().reject(checkError);
// Convert to an array of Connection objects and return
const deferredConnections = $q.defer();
@@ -332,7 +225,7 @@ angular.module('import').factory('connectionParseService',
*
* @return {Promise.<Connection[]>}
* A promise resolving to an array of Connection objects, one for each
* connection in the provided CSV.
* connection in the provided JSON.
*/
service.parseJSON = function parseJSON(jsonData) {
@@ -340,7 +233,9 @@ angular.module('import').factory('connectionParseService',
const parsedData = JSON.parse(yamlData);
// Check that the data is the correct format, and not empty
performBasicChecks(parsedData);
const checkError = performBasicChecks(connectionData);
if (checkError)
return $q.defer().reject(checkError);
// Convert to an array of Connection objects and return
const deferredConnections = $q.defer();

View File

@@ -0,0 +1,22 @@
/*
* 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.
*/
.import .parseError {
color: red;
}

View File

@@ -1,11 +1,23 @@
<div class="settings-view import">
<div class="header">
<h2>{{'SETTINGS_CONNECTION_IMPORT.HEADER' | translate}}</h2>
<h2>{{'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>
<!-- The translatable error message, if one is set -->
<p
class="parseError" ng-show="error.key"
translate="{{error.key}}" translate-values="{{error.variables}}"
></p>
<!-- The base message, if no translatable message is available -->
<p class="parseError" ng-show="!error.key && error.message">
{{error.message}}
</p>
</div>

View File

@@ -44,13 +44,22 @@ angular.module('import').factory('ParseError', [function defineParseError() {
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.
* The key associated with the translation string that used when
* displaying this message.
*
* @type TranslatableMessage
* @type String
*/
this.translatableMessage = template.translatableMessage;
this.key = template.key;
/**
* The object which should be passed through to the translation service
* for the sake of variable substitution. Each property of the provided
* object will be substituted for the variable of the same name within
* the translation string.
*
* @type Object
*/
this.variables = template.variables;
};

View File

@@ -35,6 +35,7 @@ angular.module('index', [
'client',
'clipboard',
'home',
'import',
'login',
'manage',
'navigation',

View File

@@ -11,7 +11,7 @@
<a class="import-connections button"
ng-show="canCreateConnections()"
href="#/settings/{{dataSource | escape}}/import/">{{'SETTINGS_CONNECTIONS.ACTION_IMPORT_CONNECTIONS' | translate}}</a>
href="#/import/{{dataSource | escape}}/connection/">{{'SETTINGS_CONNECTIONS.ACTION_IMPORT_CONNECTIONS' | translate}}</a>
<a class="add-connection button"
ng-show="canCreateConnections()"

View File

@@ -184,6 +184,31 @@
},
"CONNECTION_IMPORT": {
"HEADER": "Connection Import",
"ERROR_AMBIGUOUS_CSV_HEADER":
"Ambiguous CSV Header \"{HEADER}\" could be either a connection attribute or parameter",
"ERROR_ARRAY_REQUIRED":
"The provided file must contain a list of connections",
"ERROR_DUPLICATE_CSV_HEADER":
"Duplicate CSV Header: {HEADER}",
"ERROR_EMPTY_FILE": "The provided file is empty",
"ERROR_INVALID_CSV_HEADER":
"Invalid CSV Header \"{HEADER}\" is neither an attribute or parameter",
"ERROR_INVALID_FILE_TYPE":
"Invalid import file type \"{TYPE}\"",
"ERROR_NO_FILE_SUPPLIED": "Please select a file to import",
"ERROR_REQUIRED_GROUP":
"Either group or parentIdentifier must be specified, but not both",
"ERROR_REQUIRED_PROTOCOL":
"No connection protocol found in the provided file",
"ERROR_REQUIRED_NAME":
"No connection name found in the provided file"
},
"DATA_SOURCE_DEFAULT" : {
"NAME" : "Default (XML)"
},
@@ -923,13 +948,6 @@
},
"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",