Skip to content

Commit 6d28ce9

Browse files
committed
GUACAMOLE-2250: Add an option to create groups on connection import.
1 parent 4245fbb commit 6d28ce9

6 files changed

Lines changed: 334 additions & 154 deletions

File tree

guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js

Lines changed: 226 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
109109
const $location = $injector.get('$location');
110110
const $q = $injector.get('$q');
111111
const $routeParams = $injector.get('$routeParams');
112+
const connectionGroupService = $injector.get('connectionGroupService');
112113
const connectionParseService = $injector.get('connectionParseService');
113114
const connectionService = $injector.get('connectionService');
114115
const guacNotification = $injector.get('guacNotification');
@@ -117,9 +118,12 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
117118
const userGroupService = $injector.get('userGroupService');
118119

119120
// Required types
121+
const Connection = $injector.get('Connection');
122+
const ConnectionGroup = $injector.get('ConnectionGroup');
120123
const ConnectionImportConfig = $injector.get('ConnectionImportConfig');
121124
const DirectoryPatch = $injector.get('DirectoryPatch');
122125
const Error = $injector.get('Error');
126+
const ImportConnection = $injector.get('ImportConnection');
123127
const ParseError = $injector.get('ParseError');
124128
const PermissionSet = $injector.get('PermissionSet');
125129
const User = $injector.get('User');
@@ -140,6 +144,13 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
140144
*/
141145
$scope.patchFailure = null;;
142146

147+
/**
148+
* The specific array of patches sent to the API. This is
149+
* required to correctly map API errors back to file rows.
150+
* @type {Patch[]}
151+
*/
152+
$scope.patches = null;
153+
143154
/**
144155
* True if the file is fully uploaded and ready to be processed, or false
145156
* otherwise.
@@ -345,6 +356,174 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
345356
return $q.all({ ...userRequests, ...groupRequests });
346357
}
347358

359+
/**
360+
* Ensures all connection groups specified in the import file exist within
361+
* the given data source. This function flattens the existing group tree,
362+
* identifies missing path segments, and creates them sequentially to
363+
* maintain hierarchical integrity. It also updates the connection objects
364+
* with the correct internal parent identifiers.
365+
*
366+
* @param {DataSource} dataSource
367+
* The data source to which the connection groups should be added.
368+
*
369+
* @param {ParseResult} parseResult
370+
* The result of parsing the user-supplied import file, containing
371+
* the connection objects and their desired group paths. It may be
372+
* modified inside the function to actualize connection parent
373+
* identifiers.
374+
*
375+
* @returns {Promise}
376+
* A promise which resolves to a map of all full group paths to
377+
* their corresponding internal identifiers.
378+
*/
379+
function ensureConnectionGroups(dataSource, parseResult) {
380+
381+
if (!$scope.importConfig.createMissingGroups)
382+
return $q.resolve({});
383+
384+
return connectionGroupService.getConnectionGroupTree(dataSource).then(rootGroup => {
385+
386+
const pathMap = {};
387+
const flatten = (group, prefix = "") => {
388+
// The first node is always "ROOT"
389+
const currentPath = prefix ? (prefix + "/" + group.name) : "ROOT";
390+
391+
// Map the path string to the REAL system identifier
392+
if (group.identifier) {
393+
pathMap[currentPath] = group.identifier;
394+
}
395+
396+
if (group.childConnectionGroups) {
397+
group.childConnectionGroups.forEach(c => flatten(c, currentPath));
398+
}
399+
};
400+
401+
flatten(rootGroup);
402+
403+
let sequence = $q.resolve();
404+
405+
// Iterate through connections
406+
_.forEach(parseResult.connectionObjects, (connectionObject) => {
407+
const fullPath = connectionObject.group; // e.g., "ROOT/Folder/Subfolder"
408+
if (!fullPath) return;
409+
410+
const segments = fullPath.split('/');
411+
let currentPath = "ROOT";
412+
413+
segments.forEach(segment => {
414+
// Ignore empty segments and the literal "ROOT" segment in the path string
415+
if (!segment || segment === 'ROOT') return;
416+
417+
const parentPath = currentPath;
418+
const nextPath = parentPath + "/" + segment;
419+
currentPath = nextPath;
420+
421+
sequence = sequence.then(() => {
422+
// If this absolute path already exists in our map, skip creation
423+
if (pathMap[nextPath]) return;
424+
425+
// Create the new group under the parent's REAL identifier
426+
const newGroup = new ConnectionGroup({
427+
name: segment,
428+
parentIdentifier: pathMap[parentPath]
429+
});
430+
431+
return connectionGroupService.saveConnectionGroup(dataSource, newGroup)
432+
.then(() => {
433+
// Update the map with the identifier returned by the server
434+
pathMap[nextPath] = newGroup.identifier;
435+
});
436+
});
437+
});
438+
});
439+
440+
return sequence.then(() => {
441+
// Now that all groups are created and pathMap is full,
442+
// link each connection to its parent identifier.
443+
parseResult.connectionObjects.forEach(connectionObject => {
444+
if (!connectionObject.parentIdentifier) {
445+
connectionObject.parentIdentifier =
446+
pathMap[connectionObject.group] || pathMap['ROOT'];
447+
}
448+
});
449+
450+
return pathMap;
451+
});
452+
});
453+
}
454+
455+
/**
456+
* Converts the parsed connection data into a set of administrative
457+
* patches. Each patch represents an atomic operation to create or
458+
* update a connection, including its attributes, parameters, and
459+
* its placement within the connection group hierarchy.
460+
*
461+
* @param {ImportConfig} importConfig
462+
* The configuration object defining how the import should be
463+
* processed and which fields should be prioritized.
464+
*
465+
* @param {ParseResult} parseResult
466+
* The result of parsing the user-supplied import file, used as
467+
* the source data for the generated patches.
468+
*
469+
* @returns {Patch[]}
470+
* An array of patches representing the operations required to
471+
* synchronize the state of the data source with the import file.
472+
*/
473+
function createConnectionPatches(importConfig, parseResult) {
474+
475+
const patches = [];
476+
477+
parseResult.connectionObjects.forEach(connectionObject => {
478+
479+
// The value for the patch is a full-fledged Connection
480+
const value = new Connection(connectionObject);
481+
482+
// If a new connection is being created
483+
if (connectionObject.importMode
484+
=== ImportConnection.ImportMode.CREATE)
485+
486+
// Add a patch for creating the connection
487+
patches.push(new DirectoryPatch({
488+
op: DirectoryPatch.Operation.ADD,
489+
path: '/',
490+
value
491+
}));
492+
493+
// The connection is being replaced, and permissions are only being
494+
// added, not replaced
495+
else if (importConfig.existingPermissionMode ===
496+
ConnectionImportConfig.ExistingPermissionMode.PRESERVE)
497+
498+
// Add a patch for replacing the connection
499+
patches.push(new DirectoryPatch({
500+
op: DirectoryPatch.Operation.REPLACE,
501+
path: '/' + connectionObject.identifier,
502+
value
503+
}));
504+
505+
// The connection is being replaced, and permissions are also being
506+
// replaced
507+
else {
508+
509+
// Add a patch for removing the existing connection
510+
patches.push(new DirectoryPatch({
511+
op: DirectoryPatch.Operation.REMOVE,
512+
path: '/' + connectionObject.identifier
513+
}));
514+
515+
// Add a second patch for creating the replacement connection
516+
patches.push(new DirectoryPatch({
517+
op: DirectoryPatch.Operation.ADD,
518+
path: '/',
519+
value
520+
}));
521+
522+
}
523+
});
524+
return patches;
525+
};
526+
348527
/**
349528
* Process a successfully parsed import file, creating any specified
350529
* connections, creating and granting permissions to any specified users
@@ -368,54 +547,56 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
368547

369548
const dataSource = $routeParams.dataSource;
370549

371-
// First, attempt to create the connections
372-
connectionService.patchConnections(dataSource, parseResult.patches)
373-
.then(connectionResponse =>
374-
375-
// If connection creation is successful, create users and groups
376-
createUsersAndGroups(parseResult).then(() =>
377-
378-
// Grant any new permissions to users and groups. NOTE: Any
379-
// existing permissions for updated connections will NOT be
380-
// removed - only new permissions will be added.
381-
grantConnectionPermissions(parseResult, connectionResponse)
382-
.then(() => {
383-
384-
$scope.processing = false;
385-
386-
// Display a success message if everything worked
387-
guacNotification.showStatus({
388-
className : 'success',
389-
title : 'IMPORT.DIALOG_HEADER_SUCCESS',
390-
text : {
391-
key: 'IMPORT.INFO_CONNECTIONS_IMPORTED_SUCCESS',
392-
variables: { NUMBER: parseResult.connectionCount }
393-
},
394-
395-
// Add a button to acknowledge and redirect to
396-
// the connection listing page
397-
actions : [{
398-
name : 'IMPORT.ACTION_ACKNOWLEDGE',
399-
callback : () => {
400-
401-
// Close the notification
402-
guacNotification.showStatus(false);
403-
404-
// Redirect to connection list page
405-
$location.url('/settings/' + dataSource + '/connections');
406-
}
407-
}]
408-
});
409-
}))
410-
411-
// If an error occurs while trying to create users or groups,
412-
// display the error to the user.
413-
.catch(handleError)
414-
)
550+
$scope.processing = true;
415551

552+
// 1. Create missing groups
553+
ensureConnectionGroups(dataSource, parseResult)
554+
.then(pathMap => {
555+
556+
// 2. Create the patches using the freshly created group IDs
557+
$scope.patches = createConnectionPatches($scope.importConfig,
558+
parseResult, pathMap);
559+
560+
// 3. Import the connections
561+
return connectionService.patchConnections(dataSource, $scope.patches)
562+
.then(connectionResponse => {
563+
564+
// 4. Create Users/Groups and Grant Permissions
565+
return createUsersAndGroups(parseResult).then(() => {
566+
567+
// 5. Grant permissions
568+
return grantConnectionPermissions(parseResult, connectionResponse)
569+
.then(() => {
570+
571+
$scope.processing = false;
572+
guacNotification.showStatus({
573+
className: 'success',
574+
title: 'IMPORT.DIALOG_HEADER_SUCCESS',
575+
text: {
576+
key: 'IMPORT.INFO_CONNECTIONS_IMPORTED_SUCCESS',
577+
variables: { NUMBER: parseResult.connectionCount }
578+
},
579+
actions: [{
580+
name: 'IMPORT.ACTION_ACKNOWLEDGE',
581+
callback: () => {
582+
guacNotification.showStatus(false);
583+
$location.url('/settings/' + dataSource + '/connections');
584+
}
585+
}]
586+
});
587+
});
588+
})
589+
// If an error occurs while trying to create users or groups,
590+
// display the error to the user.
591+
.catch(error => {
592+
$scope.processing = false;
593+
handleError(error);
594+
});
595+
});
596+
})
416597
// If an error occurred when the call to create the connections was made,
417598
// skip any further processing - the user will have a chance to fix the
418-
// problems and try again
599+
// problems and try again.
419600
.catch(patchFailure => {
420601
$scope.processing = false;
421602
$scope.patchFailure = patchFailure;

0 commit comments

Comments
 (0)