diff options
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts')
-rw-r--r-- | src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts | 612 |
1 files changed, 612 insertions, 0 deletions
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts new file mode 100644 index 000000000..22371a50f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -0,0 +1,612 @@ +import { + AbstractControl, + AsyncValidatorFn, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; + +import _ from 'lodash'; +import { Observable, of as observableOf, timer as observableTimer } from 'rxjs'; +import { map, switchMapTo, take } from 'rxjs/operators'; + +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { FormatterService } from '~/app/shared/services/formatter.service'; + +export function isEmptyInputValue(value: any): boolean { + return value == null || value.length === 0; +} + +export type existsServiceFn = (value: any) => Observable<boolean>; + +export class CdValidators { + /** + * Validator that performs email validation. In contrast to the Angular + * email validator an empty email will not be handled as invalid. + */ + static email(control: AbstractControl): ValidationErrors | null { + // Exit immediately if value is empty. + if (isEmptyInputValue(control.value)) { + return null; + } + return Validators.email(control); + } + + /** + * Validator function in order to validate IP addresses. + * @param {number} version determines the protocol version. It needs to be set to 4 for IPv4 and + * to 6 for IPv6 validation. For any other number (it's also the default case) it will return a + * function to validate the input string against IPv4 OR IPv6. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static ip(version: number = 0): ValidatorFn { + // prettier-ignore + const ipv4Rgx = + /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i; + const ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i; + + if (version === 4) { + return Validators.pattern(ipv4Rgx); + } else if (version === 6) { + return Validators.pattern(ipv6Rgx); + } else { + return Validators.pattern(new RegExp(ipv4Rgx.source + '|' + ipv6Rgx.source)); + } + } + + /** + * Validator function in order to validate numbers. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static number(allowsNegative: boolean = true): ValidatorFn { + if (allowsNegative) { + return Validators.pattern(/^-?[0-9]+$/i); + } else { + return Validators.pattern(/^[0-9]+$/i); + } + } + + /** + * Validator function in order to validate decimal numbers. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static decimalNumber(allowsNegative: boolean = true): ValidatorFn { + if (allowsNegative) { + return Validators.pattern(/^-?[0-9]+(.[0-9]+)?$/i); + } else { + return Validators.pattern(/^[0-9]+(.[0-9]+)?$/i); + } + } + + /** + * Validator that performs SSL certificate validation. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static sslCert(): ValidatorFn { + return Validators.pattern( + /^-----BEGIN CERTIFICATE-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END CERTIFICATE-----[\n\r\f]*$/ + ); + } + + /** + * Validator that performs SSL private key validation. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static sslPrivKey(): ValidatorFn { + return Validators.pattern( + /^-----BEGIN RSA PRIVATE KEY-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END RSA PRIVATE KEY-----[\n\r\f]*$/ + ); + } + + /** + * Validator that performs SSL certificate validation of pem format. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static pemCert(): ValidatorFn { + return Validators.pattern(/^-----BEGIN .+-----$.+^-----END .+-----$/ms); + } + + /** + * Validator that requires controls to fulfill the specified condition if + * the specified prerequisites matches. If the prerequisites are fulfilled, + * then the given function is executed and if it succeeds, the 'required' + * validation error will be returned, otherwise null. + * @param {Object} prerequisites An object containing the prerequisites. + * To do additional checks rather than checking for equality you can + * use the extended prerequisite syntax: + * 'field_name': { 'op': '<OPERATOR>', arg1: '<OPERATOR_ARGUMENT>' } + * The following operators are supported: + * * empty + * * !empty + * * equal + * * !equal + * * minLength + * ### Example + * ```typescript + * { + * 'generate_key': true, + * 'username': 'Max Mustermann' + * } + * ``` + * ### Example - Extended prerequisites + * ```typescript + * { + * 'generate_key': { 'op': 'equal', 'arg1': true }, + * 'username': { 'op': 'minLength', 'arg1': 5 } + * } + * ``` + * Only if all prerequisites are fulfilled, then the validation of the + * control will be triggered. + * @param {Function | undefined} condition The function to be executed when all + * prerequisites are fulfilled. If not set, then the {@link isEmptyInputValue} + * function will be used by default. The control's value is used as function + * argument. The function must return true to set the validation error. + * @return {ValidatorFn} Returns the validator function. + */ + static requiredIf(prerequisites: object, condition?: Function | undefined): ValidatorFn { + let isWatched = false; + + return (control: AbstractControl): ValidationErrors | null => { + if (!isWatched && control.parent) { + Object.keys(prerequisites).forEach((key) => { + control.parent.get(key).valueChanges.subscribe(() => { + control.updateValueAndValidity({ emitEvent: false }); + }); + }); + + isWatched = true; + } + + // Check if all prerequisites met. + if ( + !Object.keys(prerequisites).every((key) => { + if (!control.parent) { + return false; + } + const value = control.parent.get(key).value; + const prerequisite = prerequisites[key]; + if (_.isObjectLike(prerequisite)) { + let result = false; + switch (prerequisite['op']) { + case 'empty': + result = _.isEmpty(value); + break; + case '!empty': + result = !_.isEmpty(value); + break; + case 'equal': + result = value === prerequisite['arg1']; + break; + case '!equal': + result = value !== prerequisite['arg1']; + break; + case 'minLength': + if (_.isString(value)) { + result = value.length >= prerequisite['arg1']; + } + break; + } + return result; + } + return value === prerequisite; + }) + ) { + return null; + } + const success = _.isFunction(condition) + ? condition.call(condition, control.value) + : isEmptyInputValue(control.value); + return success ? { required: true } : null; + }; + } + + /** + * Compose multiple validators into a single function that returns the union of + * the individual error maps for the provided control when the given prerequisites + * are fulfilled. + * + * @param {Object} prerequisites An object containing the prerequisites as + * key/value pairs. + * ### Example + * ```typescript + * { + * 'generate_key': true, + * 'username': 'Max Mustermann' + * } + * ``` + * @param {ValidatorFn[]} validators List of validators that should be taken + * into action when the prerequisites are met. + * @return {ValidatorFn} Returns the validator function. + */ + static composeIf(prerequisites: object, validators: ValidatorFn[]): ValidatorFn { + let isWatched = false; + return (control: AbstractControl): ValidationErrors | null => { + if (!isWatched && control.parent) { + Object.keys(prerequisites).forEach((key) => { + control.parent.get(key).valueChanges.subscribe(() => { + control.updateValueAndValidity({ emitEvent: false }); + }); + }); + isWatched = true; + } + // Check if all prerequisites are met. + if ( + !Object.keys(prerequisites).every((key) => { + return control.parent && control.parent.get(key).value === prerequisites[key]; + }) + ) { + return null; + } + return Validators.compose(validators)(control); + }; + } + + /** + * Custom validation by passing a name for the error and a function as error condition. + * + * @param {string} error + * @param {Function} condition - a truthy return value will trigger the error + * @returns {ValidatorFn} + */ + static custom(error: string, condition: Function): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const value = condition.call(this, control.value); + if (value) { + return { [error]: value }; + } + return null; + }; + } + + /** + * Validate form control if condition is true with validators. + * + * @param {AbstractControl} formControl + * @param {Function} condition + * @param {ValidatorFn[]} conditionalValidators List of validators that should only be tested + * when the condition is met + * @param {ValidatorFn[]} permanentValidators List of validators that should always be tested + * @param {AbstractControl[]} watchControls List of controls that the condition depend on. + * Every time one of this controls value is updated, the validation will be triggered + */ + static validateIf( + formControl: AbstractControl, + condition: Function, + conditionalValidators: ValidatorFn[], + permanentValidators: ValidatorFn[] = [], + watchControls: AbstractControl[] = [] + ) { + conditionalValidators = conditionalValidators.concat(permanentValidators); + + formControl.setValidators((control: AbstractControl): { + [key: string]: any; + } => { + const value = condition.call(this); + if (value) { + return Validators.compose(conditionalValidators)(control); + } + if (permanentValidators.length > 0) { + return Validators.compose(permanentValidators)(control); + } + return null; + }); + + watchControls.forEach((control: AbstractControl) => { + control.valueChanges.subscribe(() => { + formControl.updateValueAndValidity({ emitEvent: false }); + }); + }); + } + + /** + * Validator that requires that both specified controls have the same value. + * Error will be added to the `path2` control. + * @param {string} path1 A dot-delimited string that define the path to the control. + * @param {string} path2 A dot-delimited string that define the path to the control. + * @return {ValidatorFn} Returns a validator function that always returns `null`. + * If the validation fails an error map with the `match` property will be set + * on the `path2` control. + */ + static match(path1: string, path2: string): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const ctrl1 = control.get(path1); + const ctrl2 = control.get(path2); + if (!ctrl1 || !ctrl2) { + return null; + } + if (ctrl1.value !== ctrl2.value) { + ctrl2.setErrors({ match: true }); + } else { + const hasError = ctrl2.hasError('match'); + if (hasError) { + // Remove the 'match' error. If no more errors exists, then set + // the error value to 'null', otherwise the field is still marked + // as invalid. + const errors = ctrl2.errors; + _.unset(errors, 'match'); + ctrl2.setErrors(_.isEmpty(_.keys(errors)) ? null : errors); + } + } + return null; + }; + } + + /** + * Asynchronous validator that requires the control's value to be unique. + * The validation is only executed after the specified delay. Every + * keystroke during this delay will restart the timer. + * @param serviceFn {existsServiceFn} The service function that is + * called to check whether the given value exists. It must return + * boolean 'true' if the given value exists, otherwise 'false'. + * @param serviceFnThis {any} The object to be used as the 'this' object + * when calling the serviceFn function. Defaults to null. + * @param {number|Date} dueTime The delay time to wait before the + * serviceFn call is executed. This is useful to prevent calls on + * every keystroke. Defaults to 500. + * @return {AsyncValidatorFn} Returns an asynchronous validator function + * that returns an error map with the `notUnique` property if the + * validation check succeeds, otherwise `null`. + */ + static unique( + serviceFn: existsServiceFn, + serviceFnThis: any = null, + usernameFn?: Function, + uidField = false + ): AsyncValidatorFn { + let uName: string; + return (control: AbstractControl): Observable<ValidationErrors | null> => { + // Exit immediately if user has not interacted with the control yet + // or the control value is empty. + if (control.pristine || isEmptyInputValue(control.value)) { + return observableOf(null); + } + uName = control.value; + if (_.isFunction(usernameFn) && usernameFn() !== null && usernameFn() !== '') { + if (uidField) { + uName = `${control.value}$${usernameFn()}`; + } else { + uName = `${usernameFn()}$${control.value}`; + } + } + + return observableTimer().pipe( + switchMapTo(serviceFn.call(serviceFnThis, uName)), + map((resp: boolean) => { + if (!resp) { + return null; + } else { + return { notUnique: true }; + } + }), + take(1) + ); + }; + } + + /** + * Validator function for UUIDs. + * @param required - Defines if it is mandatory to fill in the UUID + * @return Validator function that returns an error object containing `invalidUuid` if the + * validation failed, `null` otherwise. + */ + static uuid(required = false): ValidatorFn { + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return (control: AbstractControl): { [key: string]: any } | null => { + if (control.pristine && control.untouched) { + return null; + } else if (!required && !control.value) { + return null; + } else if (uuidRe.test(control.value)) { + return null; + } + return { invalidUuid: 'This is not a valid UUID' }; + }; + } + + /** + * A simple minimum validator vor cd-binary inputs. + * + * To use the validation message pass I18n into the function as it cannot + * be called in a static one. + */ + static binaryMin(bytes: number): ValidatorFn { + return (control: AbstractControl): { [key: string]: () => string } | null => { + const formatterService = new FormatterService(); + const currentBytes = new FormatterService().toBytes(control.value); + if (bytes <= currentBytes) { + return null; + } + const value = new DimlessBinaryPipe(formatterService).transform(bytes); + return { + binaryMin: () => $localize`Size has to be at least ${value} or more` + }; + }; + } + + /** + * A simple maximum validator vor cd-binary inputs. + * + * To use the validation message pass I18n into the function as it cannot + * be called in a static one. + */ + static binaryMax(bytes: number): ValidatorFn { + return (control: AbstractControl): { [key: string]: () => string } | null => { + const formatterService = new FormatterService(); + const currentBytes = formatterService.toBytes(control.value); + if (bytes >= currentBytes) { + return null; + } + const value = new DimlessBinaryPipe(formatterService).transform(bytes); + return { + binaryMax: () => $localize`Size has to be at most ${value} or less` + }; + }; + } + + /** + * Asynchronous validator that checks if the password meets the password + * policy. + * @param userServiceThis The object to be used as the 'this' object + * when calling the 'validatePassword' method of the 'UserService'. + * @param usernameFn Function to get the username that should be + * taken into account. + * @param callback Callback function that is called after the validation + * has been done. + * @return {AsyncValidatorFn} Returns an asynchronous validator function + * that returns an error map with the `passwordPolicy` property if the + * validation check fails, otherwise `null`. + */ + static passwordPolicy( + userServiceThis: any, + usernameFn?: Function, + callback?: (valid: boolean, credits?: number, valuation?: string) => void + ): AsyncValidatorFn { + return (control: AbstractControl): Observable<ValidationErrors | null> => { + if (control.pristine || control.value === '') { + if (_.isFunction(callback)) { + callback(true, 0); + } + return observableOf(null); + } + let username; + if (_.isFunction(usernameFn)) { + username = usernameFn(); + } + return observableTimer(500).pipe( + switchMapTo(_.invoke(userServiceThis, 'validatePassword', control.value, username)), + map((resp: { valid: boolean; credits: number; valuation: string }) => { + if (_.isFunction(callback)) { + callback(resp.valid, resp.credits, resp.valuation); + } + if (resp.valid) { + return null; + } else { + return { passwordPolicy: true }; + } + }), + take(1) + ); + }; + } + + /** + * Validate the bucket name. In general, bucket names should follow domain + * name constraints: + * - Bucket names must be unique. + * - Bucket names cannot be formatted as IP address. + * - Bucket names can be between 3 and 63 characters long. + * - Bucket names must not contain uppercase characters or underscores. + * - Bucket names must start with a lowercase letter or number. + * - Bucket names must be a series of one or more labels. Adjacent + * labels are separated by a single period (.). Bucket names can + * contain lowercase letters, numbers, and hyphens. Each label must + * start and end with a lowercase letter or a number. + */ + static bucketName(): AsyncValidatorFn { + return (control: AbstractControl): Observable<ValidationErrors | null> => { + if (control.pristine || !control.value) { + return observableOf({ required: true }); + } + const constraints = []; + let errorName: string; + // - Bucket names cannot be formatted as IP address. + constraints.push(() => { + const ipv4Rgx = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i; + const ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i; + const name = control.value; + let notIP = true; + if (ipv4Rgx.test(name) || ipv6Rgx.test(name)) { + errorName = 'ipAddress'; + notIP = false; + } + return notIP; + }); + // - Bucket names can be between 3 and 63 characters long. + constraints.push((name: string) => { + if (!_.inRange(name.length, 3, 64)) { + errorName = 'shouldBeInRange'; + return false; + } + // Bucket names can only contain lowercase letters, numbers, periods and hyphens. + if (!/^[0-9a-z.-]+$/.test(control.value)) { + errorName = 'bucketNameInvalid'; + return false; + } + return true; + }); + // - Bucket names must not contain uppercase characters or underscores. + // - Bucket names must start with a lowercase letter or number. + // - Bucket names must be a series of one or more labels. Adjacent + // labels are separated by a single period (.). Bucket names can + // contain lowercase letters, numbers, and hyphens. Each label must + // start and end with a lowercase letter or a number. + constraints.push((name: string) => { + const labels = _.split(name, '.'); + return _.every(labels, (label) => { + // Bucket names must not contain uppercase characters or underscores. + if (label !== _.toLower(label) || label.includes('_')) { + errorName = 'containsUpperCase'; + return false; + } + // Bucket labels can contain lowercase letters, numbers, and hyphens. + if (!/^[0-9a-z-]+$/.test(label)) { + errorName = 'onlyLowerCaseAndNumbers'; + return false; + } + // Each label must start and end with a lowercase letter or a number. + return _.every([0, label.length - 1], (index) => { + errorName = 'lowerCaseOrNumber'; + return /[a-z]/.test(label[index]) || _.isInteger(_.parseInt(label[index])); + }); + }); + }); + if (!_.every(constraints, (func: Function) => func(control.value))) { + return observableOf( + (() => { + switch (errorName) { + case 'onlyLowerCaseAndNumbers': + return { onlyLowerCaseAndNumbers: true }; + case 'shouldBeInRange': + return { shouldBeInRange: true }; + case 'ipAddress': + return { ipAddress: true }; + case 'containsUpperCase': + return { containsUpperCase: true }; + case 'lowerCaseOrNumber': + return { lowerCaseOrNumber: true }; + default: + return { bucketNameInvalid: true }; + } + })() + ); + } + + return observableOf(null); + }; + } + + static bucketExistence( + requiredExistenceResult: boolean, + rgwBucketService: RgwBucketService + ): AsyncValidatorFn { + return (control: AbstractControl): Observable<ValidationErrors | null> => { + if (control.pristine || !control.value) { + return observableOf({ required: true }); + } + return rgwBucketService + .exists(control.value) + .pipe( + map((existenceResult: boolean) => + existenceResult === requiredExistenceResult ? null : { bucketNameNotAllowed: true } + ) + ); + }; + } +} |