blob: b4688203f78d7b96b85b96e63667d351ca265a9b [file] [log] [blame]
/*
* 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 _ */
/**
* The allowed MIME type for CSV files.
*
* @type String
*/
const CSV_MIME_TYPE = 'text/csv';
/**
* A fallback regular expression for CSV filenames, if no MIME type is provided
* by the browser. Any file that matches this regex will be considered to be a
* CSV file.
*
* @type RegExp
*/
const CSV_FILENAME_REGEX = /\.csv$/i;
/**
* The allowed MIME type for JSON files.
*
* @type String
*/
const JSON_MIME_TYPE = 'application/json';
/**
* A fallback regular expression for JSON filenames, if no MIME type is provided
* by the browser. Any file that matches this regex will be considered to be a
* JSON file.
*
* @type RegExp
*/
const JSON_FILENAME_REGEX = /\.json$/i;
/**
* The allowed MIME types for YAML files.
* NOTE: There is no registered MIME type for YAML files. This may result in a
* wide variety of possible browser-supplied MIME types.
*
* @type String[]
*/
const YAML_MIME_TYPES = [
'text/x-yaml',
'text/yaml',
'text/yml',
'application/x-yaml',
'application/x-yml',
'application/yaml',
'application/yml'
];
/**
* A fallback regular expression for YAML filenames, if no MIME type is provided
* by the browser. Any file that matches this regex will be considered to be a
* YAML file.
*
* @type RegExp
*/
const YAML_FILENAME_REGEX = /\.ya?ml$/i;
/**
* Possible signatures for zip files (which include most modern Microsoft office
* documents - most notable excel). If any file, regardless of extension, has
* these starting bytes, it's invalid and must be rejected.
* For more, see https://en.wikipedia.org/wiki/List_of_file_signatures and
* https://en.wikipedia.org/wiki/Magic_number_(programming)#Magic_numbers_in_files.
*
* @type String[]
*/
const ZIP_SIGNATURES = [
'PK\u0003\u0004',
'PK\u0005\u0006',
'PK\u0007\u0008'
];
/*
* All file types supported for connection import.
*
* @type {String[]}
*/
const LEGAL_MIME_TYPES = [CSV_MIME_TYPE, JSON_MIME_TYPE, ...YAML_MIME_TYPES];
/**
* The controller for the connection import page.
*/
angular.module('import').controller('importConnectionsController', ['$scope', '$injector',
function importConnectionsController($scope, $injector) {
// Required services
const $location = $injector.get('$location');
const $q = $injector.get('$q');
const $routeParams = $injector.get('$routeParams');
const connectionParseService = $injector.get('connectionParseService');
const connectionService = $injector.get('connectionService');
const guacNotification = $injector.get('guacNotification');
const permissionService = $injector.get('permissionService');
const userService = $injector.get('userService');
const userGroupService = $injector.get('userGroupService');
// Required types
const ConnectionImportConfig = $injector.get('ConnectionImportConfig');
const DirectoryPatch = $injector.get('DirectoryPatch');
const Error = $injector.get('Error');
const ParseError = $injector.get('ParseError');
const PermissionSet = $injector.get('PermissionSet');
const User = $injector.get('User');
const UserGroup = $injector.get('UserGroup');
/**
* The result of parsing the current upload, if successful.
*
* @type {ParseResult}
*/
$scope.parseResult = null;
/**
* The failure associated with the current attempt to create connections
* through the API, if any.
*
* @type {Error}
*/
$scope.patchFailure = null;;
/**
* True if the file is fully uploaded and ready to be processed, or false
* otherwise.
*
* @type {Boolean}
*/
$scope.dataReady = false;
/**
* True if the file upload has been aborted mid-upload, or false otherwise.
*/
$scope.aborted = false;
/**
* True if fully-uploaded data is being processed, or false otherwise.
*/
$scope.processing = false;
/**
* The MIME type of the uploaded file, if any.
*
* @type {String}
*/
$scope.mimeType = null;
/**
* The name of the file that's currently being uploaded, or has yet to
* be imported, if any.
*
* @type {String}
*/
$scope.fileName = null;
/**
* The raw string contents of the uploaded file, if any.
*
* @type {String}
*/
$scope.fileData = null;
/**
* The file reader currently being used to upload the file, if any. If
* null, no file upload is currently in progress.
*
* @type {FileReader}
*/
$scope.fileReader = null;
/**
* The configuration options for this import, to be chosen by the user.
*
* @type {ConnectionImportConfig}
*/
$scope.importConfig = new ConnectionImportConfig();
/**
* Clear all file upload state.
*/
function resetUploadState() {
$scope.aborted = false;
$scope.dataReady = false;
$scope.processing = false;
$scope.fileData = null;
$scope.mimeType = null;
$scope.fileReader = null;
$scope.parseResult = null;
$scope.patchFailure = null;
$scope.fileName = null;
}
// Indicate that data is currently being loaded / processed if the the file
// has been provided but not yet fully uploaded, or if the the file is
// fully loaded and is currently being processed.
$scope.isLoading = () => (
($scope.fileName && !$scope.dataReady && !$scope.patchFailure)
|| $scope.processing);
/**
* Create all users and user groups mentioned in the import file that don't
* already exist in the current data source. Return an object describing the
* result of the creation requests.
*
* @param {ParseResult} parseResult
* The result of parsing the user-supplied import file.
*
* @return {Promise.<Object>}
* A promise resolving to an object containing the results of the calls
* to create the users and groups.
*/
function createUsersAndGroups(parseResult) {
const dataSource = $routeParams.dataSource;
return $q.all({
existingUsers : userService.getUsers(dataSource),
existingGroups : userGroupService.getUserGroups(dataSource)
}).then(({existingUsers, existingGroups}) => {
const userPatches = Object.keys(parseResult.users)
// Filter out any existing users
.filter(identifier => !existingUsers[identifier])
// A patch to create each new user
.map(username => new DirectoryPatch({
op: 'add',
path: '/',
value: new User({ username })
}));
const groupPatches = Object.keys(parseResult.groups)
// Filter out any existing groups
.filter(identifier => !existingGroups[identifier])
// A patch to create each new user group
.map(identifier => new DirectoryPatch({
op: 'add',
path: '/',
value: new UserGroup({ identifier })
}));
// Create all the users and groups
return $q.all({
userResponse: userService.patchUsers(dataSource, userPatches),
userGroupResponse: userGroupService.patchUserGroups(
dataSource, groupPatches)
});
});
}
/**
* Grant read permissions for each user and group in the supplied parse
* result to each connection in their connection list. Note that there will
* be a seperate request for each user and group.
*
* @param {ParseResult} parseResult
* The result of successfully parsing a user-supplied import file.
*
* @param {Object} response
* The response from the PATCH API request.
*
* @returns {Promise.<Object>}
* A promise that will resolve with the result of every permission
* granting request.
*/
function grantConnectionPermissions(parseResult, response) {
const dataSource = $routeParams.dataSource;
// All connection grant requests, one per user/group
const userRequests = {};
const groupRequests = {};
// Create a PermissionSet granting access to all connections at
// the provided indices within the provided parse result
const createPermissionSet = indices =>
new PermissionSet({ connectionPermissions: indices.reduce(
(permissions, index) => {
const connectionId = response.patches[index].identifier;
permissions[connectionId] = [
PermissionSet.ObjectPermissionType.READ];
return permissions;
}, {}) });
// Now that we've created all the users, grant access to each
_.forEach(parseResult.users, (connectionIndices, identifier) =>
// Grant the permissions - note the group flag is `false`
userRequests[identifier] = permissionService.patchPermissions(
dataSource, identifier,
// Create the permissions to these connections for this user
createPermissionSet(connectionIndices),
// Do not remove any permissions
new PermissionSet(),
// This call is not for a group
false));
// Now that we've created all the groups, grant access to each
_.forEach(parseResult.groups, (connectionIndices, identifier) =>
// Grant the permissions - note the group flag is `true`
groupRequests[identifier] = permissionService.patchPermissions(
dataSource, identifier,
// Create the permissions to these connections for this user
createPermissionSet(connectionIndices),
// Do not remove any permissions
new PermissionSet(),
// This call is for a group
true));
// Return the result from all the permission granting calls
return $q.all({ ...userRequests, ...groupRequests });
}
/**
* Process a successfully parsed import file, creating any specified
* connections, creating and granting permissions to any specified users
* and user groups. If successful, the user will be shown a success message.
* If not, any errors will be displayed and any already-created entities
* will be rolled back.
*
* @param {ParseResult} parseResult
* The result of parsing the user-supplied import file.
*/
function handleParseSuccess(parseResult) {
$scope.processing = false;
$scope.parseResult = parseResult;
// If errors were encounted during file parsing, abort further
// processing - the user will have a chance to fix the errors and try
// again
if (parseResult.hasErrors)
return;
const dataSource = $routeParams.dataSource;
// First, attempt to create the connections
connectionService.patchConnections(dataSource, parseResult.patches)
.then(connectionResponse =>
// If connection creation is successful, create users and groups
createUsersAndGroups(parseResult).then(() =>
// Grant any new permissions to users and groups. NOTE: Any
// existing permissions for updated connections will NOT be
// removed - only new permissions will be added.
grantConnectionPermissions(parseResult, connectionResponse)
.then(() => {
$scope.processing = false;
// Display a success message if everything worked
guacNotification.showStatus({
className : 'success',
title : 'IMPORT.DIALOG_HEADER_SUCCESS',
text : {
key: 'IMPORT.INFO_CONNECTIONS_IMPORTED_SUCCESS',
variables: { NUMBER: parseResult.connectionCount }
},
// Add a button to acknowledge and redirect to
// the connection listing page
actions : [{
name : 'IMPORT.ACTION_ACKNOWLEDGE',
callback : () => {
// Close the notification
guacNotification.showStatus(false);
// Redirect to connection list page
$location.url('/settings/' + dataSource + '/connections');
}
}]
});
}))
// If an error occurs while trying to create users or groups,
// display the error to the user.
.catch(handleError)
)
// If an error occurred when the call to create the connections was made,
// skip any further processing - the user will have a chance to fix the
// problems and try again
.catch(patchFailure => {
$scope.processing = false;
$scope.patchFailure = patchFailure;
});
}
/**
* Display the provided error to the user in a dismissable dialog.
*
* @argument {ParseError|Error} error
* The error to display.
*/
const handleError = error => {
// Any error indicates that processing of the file has failed, so clear
// all upload state to allow for a fresh retry
resetUploadState();
let text;
// If it's a import file parsing error
if (error instanceof ParseError)
text = {
// Use the translation key if available
key: error.key || error.message,
variables: error.variables
};
// If it's a generic REST error
else if (error instanceof Error)
text = error.translatableMessage;
// If it's an unknown type, just use the message directly
else
text = { key: error };
guacNotification.showStatus({
className : 'error',
title : 'IMPORT.DIALOG_HEADER_ERROR',
text,
// Add a button to hide the error
actions : [{
name : 'IMPORT.ACTION_ACKNOWLEDGE',
callback : () => guacNotification.showStatus(false)
}]
});
};
/**
* Process the uploaded import file, importing the connections, granting
* connection permissions, or displaying errors to the user if there are
* problems with the provided file.
*
* @param {String} mimeType
* The MIME type of the uploaded data file.
*
* @param {String} data
* The raw string contents of the import file.
*/
function processData(mimeType, data) {
// Data processing has begun
$scope.processing = true;
// The function that will process all the raw data and return a list of
// patches to be submitted to the API
let processDataCallback;
// Choose the appropriate parse function based on the mimetype
if (mimeType === JSON_MIME_TYPE)
processDataCallback = connectionParseService.parseJSON;
else if (mimeType === CSV_MIME_TYPE)
processDataCallback = connectionParseService.parseCSV;
else if (YAML_MIME_TYPES.indexOf(mimeType) >= 0)
processDataCallback = connectionParseService.parseYAML;
// The file type was validated before being uploaded - this should
// never happen
else
processDataCallback = () => {
throw new ParseError({
message: "Unexpected invalid file type: " + mimeType
});
};
// Make the call to process the data into a series of patches
processDataCallback($scope.importConfig, data)
// Send the data off to be imported if parsing is successful
.then(handleParseSuccess)
// Display any error found while parsing the file
.catch(handleError);
}
/**
* Process the uploaded import data. Only usuable if the upload is fully
* complete.
*/
$scope.import = () => processData($scope.mimeType, $scope.fileData);
/**
* Returns true if import should be disabled, or false if import should be
* allowed.
*
* @return {Boolean}
* True if import should be disabled, otherwise false.
*/
$scope.importDisabled = () =>
// Disable import if no data is ready
!$scope.dataReady ||
// Disable import if the file is currently being processed
$scope.processing;
/**
* Cancel any in-progress upload, or clear any uploaded-but-errored-out
* batch.
*/
$scope.cancel = function() {
// If the upload is in progress, stop it now; the FileReader will
// reset the upload state when it stops
if ($scope.fileReader) {
$scope.aborted = true;
$scope.fileReader.abort();
}
// Clear any upload state - there's no FileReader handler to do it
else
resetUploadState();
};
/**
* Returns true if cancellation should be disabled, or false if
* cancellation should be allowed.
*
* @return {Boolean}
* True if cancellation should be disabled, or false if cancellation
* should be allowed.
*/
$scope.cancelDisabled = () =>
// Disable cancellation if the import has already been cancelled
$scope.aborted ||
// Disable cancellation if the file is currently being processed
$scope.processing ||
// Disable cancellation if no data is ready or being uploaded
!($scope.fileReader || $scope.dataReady);
/**
* Handle a provided File upload, reading all data onto the scope for
* import processing, should the user request an import. Note that this
* function is used as a callback for directives that invoke it with a file
* list, but directive-level checking should ensure that there is only ever
* one file provided at a time.
*
* @argument {File[]} files
* The files to upload onto the scope for further processing. There
* should only ever be a single file in the array.
*/
$scope.handleFiles = files => {
// There should only ever be a single file in the array
const file = files[0];
// The name and MIME type of the file as provided by the browser
let fileName = file.name;
let mimeType = file.type;
// If no MIME type was provided by the browser at all, use REGEXes as a
// fallback to try to determine the file type. NOTE: Windows 10/11 are
// known to do this with YAML files.
if (!_.trim(mimeType).length) {
// If the file name matches what we'd expect for a CSV file, set the
// CSV MIME type and move on
if (CSV_FILENAME_REGEX.test(fileName))
mimeType = CSV_MIME_TYPE;
// If the file name matches what we'd expect for a JSON file, set
// the JSON MIME type and move on
else if (JSON_FILENAME_REGEX.test(fileName))
mimeType = JSON_MIME_TYPE;
// If the file name matches what we'd expect for a JSON file, set
// one of the allowed YAML MIME types and move on
else if (YAML_FILENAME_REGEX.test(fileName))
mimeType = YAML_MIME_TYPES[0];
else {
// If none of the REGEXes pass, there's nothing more to be tried
handleError(new ParseError({
message: "Unknown type for file: " + fileName,
key: 'IMPORT.ERROR_DETECTED_INVALID_TYPE'
}));
return;
}
}
// Check if the mimetype is one of the supported types,
// e.g. "application/json" or "text/csv"
else if (LEGAL_MIME_TYPES.indexOf(mimeType) < 0) {
// If the provided file is not one of the supported types,
// display an error and abort processing
handleError(new ParseError({
message: "Invalid file type: " + mimeType,
key: 'IMPORT.ERROR_INVALID_MIME_TYPE',
variables: { TYPE: mimeType }
}));
return;
}
// Save the name and type to the scope
$scope.fileName = fileName;
$scope.mimeType = mimeType;
// Initialize upload state
$scope.aborted = false;
$scope.dataReady = false;
$scope.processing = false;
$scope.uploadStarted = true;
// Save the file to the scope when ready
$scope.fileReader = new FileReader();
$scope.fileReader.onloadend = (e => {
// If the upload was explicitly aborted, clear any upload state and
// do not process the data
if ($scope.aborted)
resetUploadState();
else {
const fileData = e.target.result;
// Check if the file has a header of a known-bad type
if (_.some(ZIP_SIGNATURES,
signature => fileData.startsWith(signature))) {
// Throw an error and abort processing
handleError(new ParseError({
message: "Invalid file type detected",
key: 'IMPORT.ERROR_DETECTED_INVALID_TYPE'
}));
return;
}
// Save the uploaded data
$scope.fileData = fileData;
// Mark the data as ready
$scope.dataReady = true;
// Clear the file reader from the scope now that this file is
// fully uploaded
$scope.fileReader = null;
}
});
// Read all the data into memory
$scope.fileReader.readAsText(file);
};
}]);