Merge pull request #118 from glyptodon/batch-delete-tunnel

GUAC-1132: Implement batch deletion of tunnels.
This commit is contained in:
James Muehlner
2015-03-19 13:12:37 -07:00
8 changed files with 178 additions and 74 deletions

View File

@@ -28,6 +28,7 @@ import org.glyptodon.guacamole.auth.jdbc.connectiongroup.ConnectionGroupDirector
import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionDirectory; import org.glyptodon.guacamole.auth.jdbc.connection.ConnectionDirectory;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.google.inject.Provider; import com.google.inject.Provider;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import org.glyptodon.guacamole.GuacamoleException; import org.glyptodon.guacamole.GuacamoleException;
import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject; import org.glyptodon.guacamole.auth.jdbc.base.RestrictedObject;
@@ -130,9 +131,22 @@ public class UserContext extends RestrictedObject
} }
@Override @Override
public ConnectionRecord getActiveConnection(String tunnelUUID) public Collection<ConnectionRecord> getActiveConnections(Collection<String> tunnelUUIDs)
throws GuacamoleException { throws GuacamoleException {
return tunnelService.getActiveConnection(getCurrentUser(), tunnelUUID);
// Look up active connections for each given tunnel UUID
Collection<ConnectionRecord> records = new ArrayList<ConnectionRecord>(tunnelUUIDs.size());
for (String tunnelUUID : tunnelUUIDs) {
// Add corresponding record only if it exists
ConnectionRecord record = tunnelService.getActiveConnection(getCurrentUser(), tunnelUUID);
if (record != null)
records.add(record);
}
return records;
} }
} }

View File

@@ -113,24 +113,24 @@ public interface UserContext {
throws GuacamoleException; throws GuacamoleException;
/** /**
* Returns the connection record associated with the active connection * Returns the connection records associated with the active connections
* having the tunnel with the given UUID. The active connection will only * having the tunnels with the given UUIDs. An active connection will only
* be returned if the current user has access. * be returned if the current user has access.
* *
* @param tunnelUUID * @param tunnelUUIDs
* The UUID of the tunnel whose associated connection record should be * The UUIDs of the tunnels whose associated connection records should
* returned. * be returned.
* *
* @return * @return
* The connection record associated with the active connection having * A collection of all connection records associated with the active
* the tunnel with the given UUID, if any, or null if no such * connections having the tunnels with the given UUIDs, if any, or an
* connection exists. * empty collection if no such connections exist.
* *
* @throws GuacamoleException * @throws GuacamoleException
* If an error occurs while reading active connection records, or if * If an error occurs while reading active connection records, or if
* permission is denied. * permission is denied.
*/ */
ConnectionRecord getActiveConnection(String tunnelUUID) Collection<ConnectionRecord> getActiveConnections(Collection<String> tunnelUUIDs)
throws GuacamoleException; throws GuacamoleException;
} }

View File

@@ -175,9 +175,9 @@ public class SimpleUserContext implements UserContext {
} }
@Override @Override
public ConnectionRecord getActiveConnection(String tunnelUUID) public Collection<ConnectionRecord> getActiveConnections(Collection<String> tunnelUUID)
throws GuacamoleException { throws GuacamoleException {
return null; return Collections.EMPTY_LIST;
} }
} }

View File

@@ -24,22 +24,25 @@ package org.glyptodon.guacamole.net.basic.rest.tunnel;
import com.google.inject.Inject; import com.google.inject.Inject;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import org.glyptodon.guacamole.GuacamoleClientException;
import org.glyptodon.guacamole.GuacamoleException; import org.glyptodon.guacamole.GuacamoleException;
import org.glyptodon.guacamole.GuacamoleResourceNotFoundException;
import org.glyptodon.guacamole.GuacamoleUnsupportedException; import org.glyptodon.guacamole.GuacamoleUnsupportedException;
import org.glyptodon.guacamole.net.GuacamoleTunnel; import org.glyptodon.guacamole.net.GuacamoleTunnel;
import org.glyptodon.guacamole.net.auth.ConnectionRecord; import org.glyptodon.guacamole.net.auth.ConnectionRecord;
import org.glyptodon.guacamole.net.auth.UserContext; import org.glyptodon.guacamole.net.auth.UserContext;
import org.glyptodon.guacamole.net.basic.rest.APIPatch;
import org.glyptodon.guacamole.net.basic.rest.AuthProviderRESTExposure; import org.glyptodon.guacamole.net.basic.rest.AuthProviderRESTExposure;
import org.glyptodon.guacamole.net.basic.rest.PATCH;
import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService; import org.glyptodon.guacamole.net.basic.rest.auth.AuthenticationService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -74,7 +77,8 @@ public class TunnelRESTService {
* performing the operation. * performing the operation.
* *
* @return * @return
* The tunnels of all active connections visible to the current user. * A map of the tunnels of all active connections visible to the
* current user, where the key of each entry is the tunnel's UUID.
* *
* @throws GuacamoleException * @throws GuacamoleException
* If an error occurs while retrieving the tunnels. * If an error occurs while retrieving the tunnels.
@@ -82,19 +86,21 @@ public class TunnelRESTService {
@GET @GET
@Path("/") @Path("/")
@AuthProviderRESTExposure @AuthProviderRESTExposure
public List<APITunnel> getTunnels(@QueryParam("token") String authToken) public Map<String, APITunnel> getTunnels(@QueryParam("token") String authToken)
throws GuacamoleException { throws GuacamoleException {
UserContext userContext = authenticationService.getUserContext(authToken); UserContext userContext = authenticationService.getUserContext(authToken);
// Retrieve all active tunnels // Retrieve all active tunnels
List<APITunnel> apiTunnels = new ArrayList<APITunnel>(); Map<String, APITunnel> apiTunnels = new HashMap<String, APITunnel>();
for (ConnectionRecord record : userContext.getActiveConnections()) { for (ConnectionRecord record : userContext.getActiveConnections()) {
// Locate associated tunnel and UUID // Locate associated tunnel and UUID
GuacamoleTunnel tunnel = record.getTunnel(); GuacamoleTunnel tunnel = record.getTunnel();
if (tunnel != null) if (tunnel != null) {
apiTunnels.add(new APITunnel(record, tunnel.getUUID().toString())); APITunnel apiTunnel = new APITunnel(record, tunnel.getUUID().toString());
apiTunnels.put(apiTunnel.getUUID(), apiTunnel);
}
} }
@@ -103,37 +109,58 @@ public class TunnelRESTService {
} }
/** /**
* Deletes the tunnel having the given UUID, effectively closing the * Applies the given tunnel patches. This operation currently only supports
* tunnel and killing the associated connection. * deletion of tunnels through the "remove" patch operation. Deleting a
* tunnel effectively closing the tunnel and kills the associated
* connection. The path of each patch operation is of the form "/UUID"
* where UUID is the UUID of the tunnel being modified.
* *
* @param authToken * @param authToken
* The authentication token that is used to authenticate the user * The authentication token that is used to authenticate the user
* performing the operation. * performing the operation.
* *
* @param tunnelUUID * @param patches
* The UUID associated with the tunnel being deleted. * The tunnel patches to apply for this request.
* *
* @throws GuacamoleException * @throws GuacamoleException
* If an error occurs while deleting the tunnel. * If an error occurs while deleting the tunnels.
*/ */
@DELETE @PATCH
@Path("/{tunnelUUID}") @Path("/")
@AuthProviderRESTExposure @AuthProviderRESTExposure
public void deleteTunnel(@QueryParam("token") String authToken, public void patchTunnels(@QueryParam("token") String authToken,
@PathParam("tunnelUUID") String tunnelUUID) List<APIPatch<String>> patches) throws GuacamoleException {
throws GuacamoleException {
// Attempt to get all requested tunnels
UserContext userContext = authenticationService.getUserContext(authToken); UserContext userContext = authenticationService.getUserContext(authToken);
// Retrieve specified tunnel // Build list of tunnels to delete
ConnectionRecord record = userContext.getActiveConnection(tunnelUUID); Collection<String> tunnelUUIDs = new ArrayList<String>(patches.size());
if (record == null) for (APIPatch<String> patch : patches) {
throw new GuacamoleResourceNotFoundException("No such tunnel: \"" + tunnelUUID + "\"");
// Close tunnel, if not already closed // Only remove is supported
GuacamoleTunnel tunnel = record.getTunnel(); if (patch.getOp() != APIPatch.Operation.remove)
if (tunnel != null && tunnel.isOpen()) throw new GuacamoleUnsupportedException("Only the \"remove\" operation is supported when patching tunnels.");
tunnel.close();
// Retrieve and validate path
String path = patch.getPath();
if (!path.startsWith("/"))
throw new GuacamoleClientException("Patch paths must start with \"/\".");
// Add UUID
tunnelUUIDs.add(path.substring(1));
}
// Close each tunnel, if not already closed
Collection<ConnectionRecord> records = userContext.getActiveConnections(tunnelUUIDs);
for (ConnectionRecord record : records) {
GuacamoleTunnel tunnel = record.getTunnel();
if (tunnel != null && tunnel.isOpen())
tunnel.close();
}
} }

View File

@@ -74,11 +74,11 @@ angular.module('manage').controller('manageSessionsController', ['$scope', '$inj
$scope.connections = {}; $scope.connections = {};
/** /**
* The count of currently selected tunnel wrappers. * Map of all currently-selected tunnel wrappers by UUID.
* *
* @type Number * @type Object.<String, ActiveTunnelWrapper>
*/ */
var selectedWrapperCount = 0; var selectedWrappers = {};
/** /**
* Adds the given connection to the internal set of visible * Adds the given connection to the internal set of visible
@@ -126,9 +126,9 @@ angular.module('manage').controller('manageSessionsController', ['$scope', '$inj
// Wrap all active tunnels for sake of display // Wrap all active tunnels for sake of display
$scope.wrappers = []; $scope.wrappers = [];
tunnels.forEach(function wrapActiveTunnel(tunnel) { for (var tunnelUUID in tunnels) {
$scope.wrappers.push(new ActiveTunnelWrapper(tunnel)); $scope.wrappers.push(new ActiveTunnelWrapper(tunnels[tunnelUUID]));
}); }
}); });
@@ -147,12 +147,24 @@ angular.module('manage').controller('manageSessionsController', ['$scope', '$inj
}; };
/**
* An action to be provided along with the object sent to showStatus which
* closes the currently-shown status dialog.
*/
var ACKNOWLEDGE_ACTION = {
name : "MANAGE_SESSION.ACTION_ACKNOWLEDGE",
// Handle action
callback : function acknowledgeCallback() {
guacNotification.showStatus(false);
}
};
/** /**
* An action to be provided along with the object sent to showStatus which * An action to be provided along with the object sent to showStatus which
* closes the currently-shown status dialog. * closes the currently-shown status dialog.
*/ */
var CANCEL_ACTION = { var CANCEL_ACTION = {
name : "MANAGE_USER.ACTION_CANCEL", name : "MANAGE_SESSION.ACTION_CANCEL",
// Handle action // Handle action
callback : function cancelCallback() { callback : function cancelCallback() {
guacNotification.showStatus(false); guacNotification.showStatus(false);
@@ -178,7 +190,31 @@ angular.module('manage').controller('manageSessionsController', ['$scope', '$inj
* confirmation. * confirmation.
*/ */
var deleteSessionsImmediately = function deleteSessionsImmediately() { var deleteSessionsImmediately = function deleteSessionsImmediately() {
// TODO: Use a batch delete function to delete the sessions.
// Perform deletion
tunnelService.deleteActiveTunnels(Object.keys(selectedWrappers))
.success(function tunnelsDeleted() {
// Remove deleted tunnels from wrapper array
$scope.wrappers = $scope.wrappers.filter(function tunnelStillExists(wrapper) {
return !(wrapper.tunnel.uuid in selectedWrappers);
});
// Clear selection
selectedWrappers = {};
})
// Notify of any errors
.error(function tunnelDeletionFailed(error) {
guacNotification.showStatus({
'className' : 'error',
'title' : 'MANAGE_SESSION.DIALOG_HEADER_ERROR',
'text' : error.message,
'actions' : [ ACKNOWLEDGE_ACTION ]
});
});
}; };
/** /**
@@ -201,20 +237,31 @@ angular.module('manage').controller('manageSessionsController', ['$scope', '$inj
* true if selected sessions can be deleted, false otherwise. * true if selected sessions can be deleted, false otherwise.
*/ */
$scope.canDeleteSessions = function canDeleteSessions() { $scope.canDeleteSessions = function canDeleteSessions() {
return selectedWrapperCount > 0;
// We can delete sessions if at least one is selected
for (var tunnelUUID in selectedWrappers)
return true;
return false;
}; };
/** /**
* Called whenever a tunnel wrapper changes selected status. * Called whenever a tunnel wrapper changes selected status.
* *
* @param {Boolean} selected * @param {ActiveTunnelWrapper} wrapper
* Whether the wrapper is now selected. * The wrapper whose selected status has changed.
*/ */
$scope.wrapperSelectionChange = function wrapperSelectionChange(selected) { $scope.wrapperSelectionChange = function wrapperSelectionChange(wrapper) {
if (selected)
selectedWrapperCount++; // Add wrapper to map if selected
if (wrapper.checked)
selectedWrappers[wrapper.tunnel.uuid] = wrapper;
// Otherwise, remove wrapper from map
else else
selectedWrapperCount--; delete selectedWrappers[wrapper.tunnel.uuid];
}; };
}]); }]);

View File

@@ -32,7 +32,11 @@ THE SOFTWARE.
<div class="sessions"> <div class="sessions">
<p>{{'MANAGE_SESSION.HELP_SESSIONS' | translate}}</p> <p>{{'MANAGE_SESSION.HELP_SESSIONS' | translate}}</p>
<button class="delete-sessions" ng-disabled="!canDeleteSessions()" ng-click="deleteSessions()">{{'MANAGE_SESSION.ACTION_DELETE' | translate}}</button>
<!-- Form action buttons -->
<div class="action-buttons">
<button class="delete-sessions danger" ng-disabled="!canDeleteSessions()" ng-click="deleteSessions()">{{'MANAGE_SESSION.ACTION_DELETE' | translate}}</button>
</div>
<!-- List of current user sessions --> <!-- List of current user sessions -->
<table class="session-list"> <table class="session-list">
@@ -56,7 +60,7 @@ THE SOFTWARE.
<tbody> <tbody>
<tr ng-repeat="wrapper in wrapperPage" class="session"> <tr ng-repeat="wrapper in wrapperPage" class="session">
<td> <td>
<input ng-change="wrapperSelectionChange(wrapper.checked)" type="checkbox" ng-model="wrapper.checked" /> <input ng-change="wrapperSelectionChange(wrapper)" type="checkbox" ng-model="wrapper.checked" />
</td> </td>
<td> <td>
{{wrapper.tunnel.username}} {{wrapper.tunnel.username}}

View File

@@ -30,12 +30,12 @@ angular.module('rest').factory('tunnelService', ['$http', 'authenticationService
/** /**
* Makes a request to the REST API to get the list of active tunnels, * Makes a request to the REST API to get the list of active tunnels,
* returning a promise that provides an array of @link{ActiveTunnel} * returning a promise that provides a map of @link{ActiveTunnel}
* objects if successful. * objects if successful.
* *
* @returns {Promise.<ActiveTunnel[]>} * @returns {Promise.<Object.<String, ActiveTunnel>>}
* A promise which will resolve with an array of @link{ActiveTunnel} * A promise which will resolve with a map of @link{ActiveTunnel}
* objects upon success. * objects, where each key is the UUID of the corresponding tunnel.
*/ */
service.getActiveTunnels = function getActiveTunnels() { service.getActiveTunnels = function getActiveTunnels() {
@@ -54,31 +54,41 @@ angular.module('rest').factory('tunnelService', ['$http', 'authenticationService
}; };
/** /**
* Makes a request to the REST API to delete the tunnel having the given * Makes a request to the REST API to delete the tunnels having the given
* UUID, effectively disconnecting the tunnel, returning a promise that can * UUIDs, effectively disconnecting the tunnels, returning a promise that
* be used for processing the results of the call. * can be used for processing the results of the call.
* *
* @param {String} uuid * @param {String[]} uuids
* The UUID of the tunnel to delete. * The UUIDs of the tunnels to delete.
* *
* @returns {Promise} * @returns {Promise}
* A promise for the HTTP call which will succeed if and only if the * A promise for the HTTP call which will succeed if and only if the
* delete operation is successful. * delete operation is successful.
*/ */
service.deleteActiveTunnel = function deleteActiveTunnel(uuid) { service.deleteActiveTunnels = function deleteActiveTunnels(uuids) {
// Build HTTP parameters set // Build HTTP parameters set
var httpParameters = { var httpParameters = {
token : authenticationService.getCurrentToken() token : authenticationService.getCurrentToken()
}; };
// Delete connection // Convert provided array of UUIDs to a patch
return $http({ var tunnelPatch = [];
method : 'DELETE', uuids.forEach(function addTunnelPatch(uuid) {
url : 'api/tunnels/' + encodeURIComponent(uuid), tunnelPatch.push({
params : httpParameters op : 'remove',
path : '/' + uuid
});
}); });
// Perform tunnel deletion via PATCH
return $http({
method : 'PATCH',
url : 'api/tunnels',
params : httpParameters,
data : tunnelPatch
});
}; };
return service; return service;

View File

@@ -236,12 +236,14 @@
"MANAGE_SESSION" : { "MANAGE_SESSION" : {
"ACTION_DELETE" : "Kill all selected sessions", "ACTION_ACKNOWLEDGE" : "@:APP.ACTION_ACKNOWLEDGE",
"ACTION_CANCEL" : "@:APP.ACTION_CANCEL",
"ACTION_DELETE" : "Kill Sessions",
"DIALOG_HEADER_CONFIRM_DELETE" : "Kill Sessions", "DIALOG_HEADER_CONFIRM_DELETE" : "Kill Sessions",
"DIALOG_HEADER_ERROR" : "Error", "DIALOG_HEADER_ERROR" : "Error",
"HELP_SESSIONS" : "Click to kill a user session.", "HELP_SESSIONS" : "All currently-active Guacamole sessions are listed here. If you wish to kill one or more sessions, check the box next to those sessions and click \"Kill Sessions\". Killing a session will immediately disconnect the user from the associated connection.",
"SECTION_HEADER_SESSIONS" : "Sessions", "SECTION_HEADER_SESSIONS" : "Sessions",
@@ -250,7 +252,7 @@
"TABLE_HEADER_SESSION_REMOTEHOST" : "Remote host", "TABLE_HEADER_SESSION_REMOTEHOST" : "Remote host",
"TABLE_HEADER_SESSION_CONNECTION_NAME" : "Connection name", "TABLE_HEADER_SESSION_CONNECTION_NAME" : "Connection name",
"TEXT_CONFIRM_DELETE" : "Are you sure you want to kill these sessions?" "TEXT_CONFIRM_DELETE" : "Are you sure you want to kill all selected sessions? The users using these sessions will be immediately disconnected."
}, },