diff --git a/.gitignore b/.gitignore
index 4afc00fde2..5ca1c192ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,6 @@ cypress/.idea/
cypress/cypress.env.json
cypress/report/
cypress/cookies.json
+
+# Customization plugin assets
+public/assets/custom/*
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 519a2134e8..145cf5a3a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@ All notable changes to the Wazuh app project will be documented in this file.
- Added agent synchronization status in the agent module. [#3874](https://github.com/wazuh/wazuh-kibana-app/pull/3874)
- Redesign the SCA table from agent's dashboard [#4512](https://github.com/wazuh/wazuh-kibana-app/pull/4512)
- Enhanced the plugin setting description displayed in the UI and the configuration file. [#4501](https://github.com/wazuh/wazuh-kibana-app/pull/4501)
-- Added validation to the plugin settings in the form of `Settings/Configuration` and the endpoint to update the plugin configuration [#4501](https://github.com/wazuh/wazuh-kibana-app/pull/4503)
+- Added validation to the plugin settings in the form of `Settings/Configuration` and the endpoint to update the plugin configuration [#4503](https://github.com/wazuh/wazuh-kibana-app/pull/4503)
### Changed
@@ -20,6 +20,7 @@ All notable changes to the Wazuh app project will be documented in this file.
- Made Agents Overview icons load independently [#4363](https://github.com/wazuh/wazuh-kibana-app/pull/4363)
- Improved the message displayed when there is a versions mismatch between the Wazuh API and the Wazuh APP [#4529](https://github.com/wazuh/wazuh-kibana-app/pull/4529)
- Changed the endpoint that updates the plugin configuration to support multiple settings. [#4501](https://github.com/wazuh/wazuh-kibana-app/pull/4501)
+- Allowed to upload an image for the `customization.logo.*` settings in `Settings/Configuration` [#4504](https://github.com/wazuh/wazuh-kibana-app/pull/4504)
### Fixed
diff --git a/common/constants.ts b/common/constants.ts
index a06dbf34bb..ce420326b9 100644
--- a/common/constants.ts
+++ b/common/constants.ts
@@ -342,6 +342,11 @@ export const DOCUMENTATION_WEB_BASE_URL = "https://documentation.wazuh.com";
// Default Elasticsearch user name context
export const ELASTIC_NAME = 'elastic';
+
+// Customization
+export const CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES = 1048576;
+
+
// Plugin settings
export enum SettingCategory {
GENERAL,
@@ -357,6 +362,35 @@ type TPluginSettingOptionsSelect = {
select: { text: string, value: any }[]
};
+type TPluginSettingOptionsEditor = {
+ editor: {
+ language: string
+ }
+};
+
+type TPluginSettingOptionsFile = {
+ file: {
+ type: 'image'
+ extensions?: string[]
+ size?: {
+ maxBytes?: number
+ minBytes?: number
+ }
+ recommended?: {
+ dimensions?: {
+ width: number,
+ height: number,
+ unit: string
+ }
+ }
+ store?: {
+ relativePathFileSystem: string
+ filename: string
+ resolveStaticURL: (filename: string) => string
+ }
+ }
+};
+
type TPluginSettingOptionsNumber = {
number: {
min?: number
@@ -365,12 +399,6 @@ type TPluginSettingOptionsNumber = {
}
};
-type TPluginSettingOptionsEditor = {
- editor: {
- language: string
- }
-};
-
type TPluginSettingOptionsSwitch = {
switch: {
values: {
@@ -387,6 +415,7 @@ export enum EpluginSettingType {
number = 'number',
editor = 'editor',
select = 'select',
+ filepicker = 'filepicker'
};
export type TPluginSetting = {
@@ -413,14 +442,14 @@ export type TPluginSetting = {
// Modify the setting requires restarting the plugin platform to take effect.
requiresRestartingPluginPlatform?: boolean
// Define options related to the `type`.
- options?: TPluginSettingOptionsNumber | TPluginSettingOptionsEditor | TPluginSettingOptionsSelect | TPluginSettingOptionsSwitch
+ options?: TPluginSettingOptionsNumber | TPluginSettingOptionsEditor | TPluginSettingOptionsFile | TPluginSettingOptionsSelect | TPluginSettingOptionsSwitch
// Transform the input value. The result is saved in the form global state of Settings/Configuration
uiFormTransformChangedInputValue?: (value: any) => any
// Transform the configuration value or default as initial value for the input in Settings/Configuration
uiFormTransformConfigurationValueToInputValue?: (value: any) => any
// Transform the input value changed in the form of Settings/Configuration and returned in the `changed` property of the hook useForm
uiFormTransformInputValueToConfigurationValue?: (value: any) => any
- // Validate the value in the form of Settings/Configuration. It returns a string if there is some validation error.
+ // Validate the value in the form of Settings/Configuration. It returns a string if there is some validation error.
validate?: (value: any) => string | undefined
// Validate function creator to validate the setting in the backend. It uses `schema` of the `@kbn/config-schema` package.
validateBackend?: (schema: any) => (value: unknown) => string | undefined
@@ -898,39 +927,150 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = {
title: "App main logo",
description: `This logo is used in the app main menu, at the top left corner.`,
category: SettingCategory.CUSTOMIZATION,
- type: EpluginSettingType.text,
+ type: EpluginSettingType.filepicker,
defaultValue: "",
isConfigurableFromFile: true,
isConfigurableFromUI: true,
+ options: {
+ file: {
+ type: 'image',
+ extensions: ['.jpeg', '.jpg', '.png', '.svg'],
+ size: {
+ maxBytes: CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES,
+ },
+ recommended: {
+ dimensions: {
+ width: 300,
+ height: 70,
+ unit: 'px'
+ }
+ },
+ store: {
+ relativePathFileSystem: 'public/assets/custom/images',
+ filename: 'customization.logo.app',
+ resolveStaticURL: (filename: string) => `custom/images/${filename}?v=${Date.now()}`
+ // ?v=${Date.now()} is used to force the browser to reload the image when a new file is uploaded
+ }
+ }
+ },
+ validate: function(value){
+ return SettingsValidator.compose(
+ SettingsValidator.filePickerFileSize({...this.options.file.size, meaningfulUnit: true}),
+ SettingsValidator.filePickerSupportedExtensions(this.options.file.extensions)
+ )(value)
+ },
},
"customization.logo.healthcheck": {
title: "Healthcheck logo",
description: `This logo is displayed during the Healthcheck routine of the app.`,
category: SettingCategory.CUSTOMIZATION,
- type: EpluginSettingType.text,
+ type: EpluginSettingType.filepicker,
defaultValue: "",
isConfigurableFromFile: true,
isConfigurableFromUI: true,
+ options: {
+ file: {
+ type: 'image',
+ extensions: ['.jpeg', '.jpg', '.png', '.svg'],
+ size: {
+ maxBytes: CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES,
+ },
+ recommended: {
+ dimensions: {
+ width: 300,
+ height: 70,
+ unit: 'px'
+ }
+ },
+ store: {
+ relativePathFileSystem: 'public/assets/custom/images',
+ filename: 'customization.logo.healthcheck',
+ resolveStaticURL: (filename: string) => `custom/images/${filename}?v=${Date.now()}`
+ // ?v=${Date.now()} is used to force the browser to reload the image when a new file is uploaded
+ }
+ }
+ },
+ validate: function(value){
+ return SettingsValidator.compose(
+ SettingsValidator.filePickerFileSize({...this.options.file.size, meaningfulUnit: true}),
+ SettingsValidator.filePickerSupportedExtensions(this.options.file.extensions)
+ )(value)
+ },
},
"customization.logo.reports": {
title: "PDF reports logo",
description: `This logo is used in the PDF reports generated by the app. It's placed at the top left corner of every page of the PDF.`,
category: SettingCategory.CUSTOMIZATION,
- type: EpluginSettingType.text,
+ type: EpluginSettingType.filepicker,
defaultValue: "",
defaultValueIfNotSet: REPORTS_LOGO_IMAGE_ASSETS_RELATIVE_PATH,
isConfigurableFromFile: true,
isConfigurableFromUI: true,
+ options: {
+ file: {
+ type: 'image',
+ extensions: ['.jpeg', '.jpg', '.png'],
+ size: {
+ maxBytes: CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES,
+ },
+ recommended: {
+ dimensions: {
+ width: 190,
+ height: 40,
+ unit: 'px'
+ }
+ },
+ store: {
+ relativePathFileSystem: 'public/assets/custom/images',
+ filename: 'customization.logo.reports',
+ resolveStaticURL: (filename: string) => `custom/images/${filename}`
+ }
+ }
+ },
+ validate: function(value){
+ return SettingsValidator.compose(
+ SettingsValidator.filePickerFileSize({...this.options.file.size, meaningfulUnit: true}),
+ SettingsValidator.filePickerSupportedExtensions(this.options.file.extensions)
+ )(value)
+ },
},
"customization.logo.sidebar": {
title: "Navigation drawer logo",
description: `This is the logo for the app to display in the platform's navigation drawer, this is, the main sidebar collapsible menu.`,
category: SettingCategory.CUSTOMIZATION,
- type: EpluginSettingType.text,
+ type: EpluginSettingType.filepicker,
defaultValue: "",
isConfigurableFromFile: true,
isConfigurableFromUI: true,
requiresReloadingBrowserTab: true,
+ options: {
+ file: {
+ type: 'image',
+ extensions: ['.jpeg', '.jpg', '.png', '.svg'],
+ size: {
+ maxBytes: CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES,
+ },
+ recommended: {
+ dimensions: {
+ width: 80,
+ height: 80,
+ unit: 'px'
+ }
+ },
+ store: {
+ relativePathFileSystem: 'public/assets/custom/images',
+ filename: 'customization.logo.sidebar',
+ resolveStaticURL: (filename: string) => `custom/images/${filename}?v=${Date.now()}`
+ // ?v=${Date.now()} is used to force the browser to reload the image when a new file is uploaded
+ }
+ }
+ },
+ validate: function(value){
+ return SettingsValidator.compose(
+ SettingsValidator.filePickerFileSize({...this.options.file.size, meaningfulUnit: true}),
+ SettingsValidator.filePickerSupportedExtensions(this.options.file.extensions)
+ )(value)
+ },
},
"disabled_roles": {
title: "Disable roles",
@@ -1361,7 +1501,7 @@ export const PLUGIN_SETTINGS: { [key: string]: TPluginSetting } = {
SettingsValidator.isString,
SettingsValidator.isNotEmptyString,
SettingsValidator.hasNoSpaces,
- SettingsValidator.noLiteralString('.', '..'),
+ SettingsValidator.noLiteralString('.', '..'),
SettingsValidator.noStartsWithString('-', '_', '+', '.'),
SettingsValidator.hasNotInvalidCharacters('\\', '/', '?', '"', '<', '>', '|', ',', '#')
)),
diff --git a/common/plugin-settings.test.ts b/common/plugin-settings.test.ts
index 112a0325f1..d67e514eab 100644
--- a/common/plugin-settings.test.ts
+++ b/common/plugin-settings.test.ts
@@ -93,6 +93,30 @@ describe('[settings] Input validation', () => {
${'cron.statistics.interval'} | ${true} | ${"Interval is not valid."}
${'cron.statistics.status'} | ${true} | ${undefined}
${'cron.statistics.status'} | ${0} | ${'It should be a boolean. Allowed values: true or false.'}
+ ${'customization.logo.app'} | ${{size: 124000, name: 'image.jpg'}} | ${undefined}
+ ${'customization.logo.app'} | ${{size: 124000, name: 'image.jpeg'}} | ${undefined}
+ ${'customization.logo.app'} | ${{size: 124000, name: 'image.png'}} | ${undefined}
+ ${'customization.logo.app'} | ${{size: 124000, name: 'image.svg'}} | ${undefined}
+ ${'customization.logo.app'} | ${{size: 124000, name: 'image.txt'}} | ${'File extension is invalid. Allowed file extensions: .jpeg, .jpg, .png, .svg.'}
+ ${'customization.logo.app'} | ${{size: 1240000, name: 'image.txt'}} | ${'File size should be lower or equal than 1 MB.'}
+ ${'customization.logo.healthcheck'} | ${{size: 124000, name: 'image.jpg'}} | ${undefined}
+ ${'customization.logo.healthcheck'} | ${{size: 124000, name: 'image.jpeg'}} | ${undefined}
+ ${'customization.logo.healthcheck'} | ${{size: 124000, name: 'image.png'}} | ${undefined}
+ ${'customization.logo.healthcheck'} | ${{size: 124000, name: 'image.svg'}} | ${undefined}
+ ${'customization.logo.healthcheck'} | ${{size: 124000, name: 'image.txt'}} | ${'File extension is invalid. Allowed file extensions: .jpeg, .jpg, .png, .svg.'}
+ ${'customization.logo.healthcheck'} | ${{size: 1240000, name: 'image.txt'}} | ${'File size should be lower or equal than 1 MB.'}
+ ${'customization.logo.reports'} | ${{size: 124000, name: 'image.jpg'}} | ${undefined}
+ ${'customization.logo.reports'} | ${{size: 124000, name: 'image.jpeg'}} | ${undefined}
+ ${'customization.logo.reports'} | ${{size: 124000, name: 'image.png'}} | ${undefined}
+ ${'customization.logo.reports'} | ${{size: 124000, name: 'image.svg'}} | ${'File extension is invalid. Allowed file extensions: .jpeg, .jpg, .png.'}
+ ${'customization.logo.reports'} | ${{size: 124000, name: 'image.txt'}} | ${'File extension is invalid. Allowed file extensions: .jpeg, .jpg, .png.'}
+ ${'customization.logo.reports'} | ${{size: 1240000, name: 'image.txt'}} | ${'File size should be lower or equal than 1 MB.'}
+ ${'customization.logo.sidebar'} | ${{size: 124000, name: 'image.jpg'}} | ${undefined}
+ ${'customization.logo.sidebar'} | ${{size: 124000, name: 'image.jpeg'}} | ${undefined}
+ ${'customization.logo.sidebar'} | ${{size: 124000, name: 'image.png'}} | ${undefined}
+ ${'customization.logo.sidebar'} | ${{size: 124000, name: 'image.svg'}} | ${undefined}
+ ${'customization.logo.sidebar'} | ${{size: 124000, name: 'image.txt'}} | ${'File extension is invalid. Allowed file extensions: .jpeg, .jpg, .png, .svg.'}
+ ${'customization.logo.sidebar'} | ${{size: 1240000, name: 'image.txt'}} | ${'File size should be lower or equal than 1 MB.'}
${'disabled_roles'} | ${['test']} | ${undefined}
${'disabled_roles'} | ${['']} | ${'Value can not be empty.'}
${'disabled_roles'} | ${['test space']} | ${"No whitespaces allowed."}
diff --git a/common/services/file-extension.test.ts b/common/services/file-extension.test.ts
new file mode 100644
index 0000000000..73a5e47e99
--- /dev/null
+++ b/common/services/file-extension.test.ts
@@ -0,0 +1,17 @@
+import { getFileExtensionFromBuffer } from "./file-extension";
+import fs from 'fs';
+import path from 'path';
+
+describe('getFileExtensionFromBuffer', () => {
+ it.each`
+ filepath | extension
+ ${'../../server/routes/wazuh-utils/fixtures/fixture_image_small.jpg'} | ${'jpg'}
+ ${'../../server/routes/wazuh-utils/fixtures/fixture_image_small.png'} | ${'png'}
+ ${'../../server/routes/wazuh-utils/fixtures/fixture_image_big.png'} | ${'png'}
+ ${'../../server/routes/wazuh-utils/fixtures/fixture_image_small.svg'} | ${'svg'}
+ ${'../../server/routes/wazuh-utils/fixtures/fixture_file.txt'} | ${'unknown'}
+ `(`filepath: $filepath expects to get extension: $extension`, ({ extension, filepath }) => {
+ const bufferFile = fs.readFileSync(path.join(__dirname, filepath));
+ expect(getFileExtensionFromBuffer(bufferFile)).toBe(extension);
+ });
+});
diff --git a/common/services/file-extension.ts b/common/services/file-extension.ts
new file mode 100644
index 0000000000..5f4be36b0a
--- /dev/null
+++ b/common/services/file-extension.ts
@@ -0,0 +1,23 @@
+/**
+ * Get the file extension from a file buffer. Calculates the image format by reading the first 4 bytes of the image (header)
+ * Supported types: jpeg, jpg, png, svg
+ * Additionally, this function allows checking gif images.
+ * @param buffer file buffer
+ * @returns the file extension. Example: jpg, png, svg. it Returns unknown if it can not find the extension.
+*/
+export function getFileExtensionFromBuffer(buffer: Buffer): string {
+ const imageFormat = buffer.toString('hex').substring(0, 4);
+ switch (imageFormat) {
+ case '4749':
+ return 'gif';
+ case 'ffd8':
+ return 'jpg'; // Also jpeg
+ case '8950':
+ return 'png';
+ case '3c73':
+ case '3c3f':
+ return 'svg';
+ default:
+ return 'unknown';
+ }
+};
diff --git a/common/services/file-size.test.ts b/common/services/file-size.test.ts
new file mode 100644
index 0000000000..269c215e6c
--- /dev/null
+++ b/common/services/file-size.test.ts
@@ -0,0 +1,20 @@
+import {formatBytes } from "./file-size";
+
+describe('formatBytes', () => {
+ it.each`
+ bytes | decimals | expected
+ ${1024} | ${2} | ${'1 KB'}
+ ${1023} | ${2} | ${'1023 Bytes'}
+ ${1500} | ${2} | ${'1.46 KB'}
+ ${1500} | ${1} | ${'1.5 KB'}
+ ${1500} | ${3} | ${'1.465 KB'}
+ ${1048576} | ${2} | ${'1 MB'}
+ ${1048577} | ${2} | ${'1 MB'}
+ ${1475487} | ${2} | ${'1.41 MB'}
+ ${1475487} | ${1} | ${'1.4 MB'}
+ ${1475487} | ${3} | ${'1.407 MB'}
+ ${1073741824} | ${2} | ${'1 GB'}
+ `(`bytes: $bytes | decimals: $decimals | expected: $expected`, ({ bytes, decimals, expected }) => {
+ expect(formatBytes(bytes, decimals)).toBe(expected);
+ });
+});
diff --git a/common/services/file-size.ts b/common/services/file-size.ts
new file mode 100644
index 0000000000..9ee7af30c2
--- /dev/null
+++ b/common/services/file-size.ts
@@ -0,0 +1,17 @@
+/**
+ * Format the number the bytes to the higher unit.
+ * @param bytes Bytes
+ * @param decimals Number of decimals
+ * @returns Formatted value with the unit
+ */
+export function formatBytes(bytes: number, decimals: number = 2): string {
+ if (!+bytes) return '0 Bytes';
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
+};
\ No newline at end of file
diff --git a/common/services/settings-validator.ts b/common/services/settings-validator.ts
index 1d447238e7..8b11ea7ef8 100644
--- a/common/services/settings-validator.ts
+++ b/common/services/settings-validator.ts
@@ -1,203 +1,232 @@
-export class SettingsValidator{
- /**
- * Create a function that is a composition of the input validations
- * @param functions SettingsValidator functions to compose
- * @returns composed validation
- */
- static compose(...functions) {
- return function composedValidation(value) {
- for (const fn of functions) {
- const result = fn(value);
- if (typeof result === 'string' && result.length > 0) {
- return result;
- };
- };
- };
- };
-
- /**
- * Check the value is a string
- * @param value
- * @returns
- */
- static isString(value: unknown): string | undefined {
- return typeof value === 'string' ? undefined : "Value is not a string.";
- };
-
- /**
- * Check the string has no spaces
- * @param value
- * @returns
- */
- static hasNoSpaces(value: string): string | undefined {
- return /^\S*$/.test(value) ? undefined : "No whitespaces allowed.";
- };
-
- /**
- * Check the string has no empty
- * @param value
- * @returns
- */
- static isNotEmptyString(value: string): string | undefined {
- if (typeof value === 'string') {
- if (value.length === 0) {
- return "Value can not be empty."
- } else {
- return undefined;
- }
- };
- };
-
- /**
- * Check the number of string lines is limited
- * @param options
- * @returns
- */
- static multipleLinesString(options: { min?: number, max?: number } = {}){
- return function (value: number){
- const lines = value.split(/\r\n|\r|\n/).length;
- if (typeof options.min !== 'undefined' && lines < options.min) {
- return `The string should have more or ${options.min} line/s.`;
- };
- if (typeof options.max !== 'undefined' && lines > options.max) {
- return `The string should have less or ${options.max} line/s.`;
- };
- }
- };
-
- /**
- * Creates a function that checks the string does not contain some characters
- * @param invalidCharacters
- * @returns
- */
- static hasNotInvalidCharacters(...invalidCharacters: string[]){
- return function (value: string): string | undefined {
- return invalidCharacters.some(invalidCharacter => value.includes(invalidCharacter))
- ? `It can't contain invalid characters: ${invalidCharacters.join(', ')}.`
- : undefined;
- };
- };
-
- /**
- * Creates a function that checks the string does not start with a substring
- * @param invalidStartingCharacters
- * @returns
- */
- static noStartsWithString(...invalidStartingCharacters: string[]) {
- return function (value: string): string | undefined {
- return invalidStartingCharacters.some(invalidStartingCharacter => value.startsWith(invalidStartingCharacter))
- ? `It can't start with: ${invalidStartingCharacters.join(', ')}.`
- : undefined;
- };
- };
-
- /**
- * Creates a function that checks the string is not equals to some values
- * @param invalidLiterals
- * @returns
- */
- static noLiteralString(...invalidLiterals: string[]){
- return function (value: string): string | undefined {
- return invalidLiterals.some(invalidLiteral => value === invalidLiteral)
- ? `It can't be: ${invalidLiterals.join(', ')}.`
- : undefined;
- };
- };
-
- /**
- * Check the value is a boolean
- * @param value
- * @returns
- */
- static isBoolean(value: string): string | undefined {
- return typeof value === 'boolean'
- ? undefined
- : "It should be a boolean. Allowed values: true or false.";
- };
-
- /**
- * Check the value is a number between some optional limits
- * @param options
- * @returns
- */
- static number(options: { min?: number, max?: number, integer?: boolean } = {}) {
- return function (value: number){
- if (options.integer
- && (
- (typeof value === 'string' ? ['.', ','].some(character => value.includes(character)) : false)
- || !Number.isInteger(Number(value))
- )
- ) {
- return 'Number should be an integer.'
- };
-
- const valueNumber = typeof value === 'string' ? Number(value) : value;
-
- if (typeof options.min !== 'undefined' && valueNumber < options.min) {
- return `Value should be greater or equal than ${options.min}.`;
- };
- if (typeof options.max !== 'undefined' && valueNumber > options.max) {
- return `Value should be lower or equal than ${options.max}.`;
- };
- };
- };
-
- /**
- * Creates a function that checks if the value is a json
- * @param validateParsed Optional parameter to validate the parsed object
- * @returns
- */
- static json(validateParsed: (object: any) => string | undefined) {
- return function (value: string){
- let jsonObject;
- // Try to parse the string as JSON
- try {
- jsonObject = JSON.parse(value);
- } catch (error) {
- return "Value can't be parsed. There is some error.";
- };
-
- return validateParsed ? validateParsed(jsonObject) : undefined;
- };
- };
-
- /**
- * Creates a function that checks is the value is an array and optionally validates each element
- * @param validationElement Optional function to validate each element of the array
- * @returns
- */
- static array(validationElement: (json: any) => string | undefined) {
- return function(value: unknown[]) {
- // Check the JSON is an array
- if (!Array.isArray(value)) {
- return 'Value is not a valid list.';
- };
-
- return validationElement
- ? value.reduce((accum, elementValue) => {
- if (accum) {
- return accum;
- };
-
- const resultValidationElement = validationElement(elementValue);
- if (resultValidationElement) {
- return resultValidationElement;
- };
-
- return accum;
- }, undefined)
- : undefined;
- };
- };
-
- /**
- * Creates a function that checks if the value is equal to list of values
- * @param literals Array of values to compare
- * @returns
- */
- static literal(literals: unknown[]){
- return function(value: any): string | undefined{
- return literals.includes(value) ? undefined : `Invalid value. Allowed values: ${literals.map(String).join(', ')}.`;
- };
- };
+import path from 'path';
+import { formatBytes } from './file-size';
+
+export class SettingsValidator {
+ /**
+ * Create a function that is a composition of the input validations
+ * @param functions SettingsValidator functions to compose
+ * @returns composed validation
+ */
+ static compose(...functions) {
+ return function composedValidation(value) {
+ for (const fn of functions) {
+ const result = fn(value);
+ if (typeof result === 'string' && result.length > 0) {
+ return result;
+ };
+ };
+ };
+ };
+
+ /**
+ * Check the value is a string
+ * @param value
+ * @returns
+ */
+ static isString(value: unknown): string | undefined {
+ return typeof value === 'string' ? undefined : "Value is not a string.";
+ };
+
+ /**
+ * Check the string has no spaces
+ * @param value
+ * @returns
+ */
+ static hasNoSpaces(value: string): string | undefined {
+ return /^\S*$/.test(value) ? undefined : "No whitespaces allowed.";
+ };
+
+ /**
+ * Check the string has no empty
+ * @param value
+ * @returns
+ */
+ static isNotEmptyString(value: string): string | undefined {
+ if (typeof value === 'string') {
+ if (value.length === 0) {
+ return "Value can not be empty."
+ } else {
+ return undefined;
+ }
+ };
+ };
+
+ /**
+ * Check the number of string lines is limited
+ * @param options
+ * @returns
+ */
+ static multipleLinesString(options: { min?: number, max?: number } = {}) {
+ return function (value: number) {
+ const lines = value.split(/\r\n|\r|\n/).length;
+ if (typeof options.min !== 'undefined' && lines < options.min) {
+ return `The string should have more or ${options.min} line/s.`;
+ };
+ if (typeof options.max !== 'undefined' && lines > options.max) {
+ return `The string should have less or ${options.max} line/s.`;
+ };
+ }
+ };
+
+ /**
+ * Creates a function that checks the string does not contain some characters
+ * @param invalidCharacters
+ * @returns
+ */
+ static hasNotInvalidCharacters(...invalidCharacters: string[]) {
+ return function (value: string): string | undefined {
+ return invalidCharacters.some(invalidCharacter => value.includes(invalidCharacter))
+ ? `It can't contain invalid characters: ${invalidCharacters.join(', ')}.`
+ : undefined;
+ };
+ };
+
+ /**
+ * Creates a function that checks the string does not start with a substring
+ * @param invalidStartingCharacters
+ * @returns
+ */
+ static noStartsWithString(...invalidStartingCharacters: string[]) {
+ return function (value: string): string | undefined {
+ return invalidStartingCharacters.some(invalidStartingCharacter => value.startsWith(invalidStartingCharacter))
+ ? `It can't start with: ${invalidStartingCharacters.join(', ')}.`
+ : undefined;
+ };
+ };
+
+ /**
+ * Creates a function that checks the string is not equals to some values
+ * @param invalidLiterals
+ * @returns
+ */
+ static noLiteralString(...invalidLiterals: string[]) {
+ return function (value: string): string | undefined {
+ return invalidLiterals.some(invalidLiteral => value === invalidLiteral)
+ ? `It can't be: ${invalidLiterals.join(', ')}.`
+ : undefined;
+ };
+ };
+
+ /**
+ * Check the value is a boolean
+ * @param value
+ * @returns
+ */
+ static isBoolean(value: string): string | undefined {
+ return typeof value === 'boolean'
+ ? undefined
+ : "It should be a boolean. Allowed values: true or false.";
+ };
+
+ /**
+ * Check the value is a number between some optional limits
+ * @param options
+ * @returns
+ */
+ static number(options: { min?: number, max?: number, integer?: boolean } = {}) {
+ return function (value: number) {
+ if (options.integer
+ && (
+ (typeof value === 'string' ? ['.', ','].some(character => value.includes(character)) : false)
+ || !Number.isInteger(Number(value))
+ )
+ ) {
+ return 'Number should be an integer.'
+ };
+
+ const valueNumber = typeof value === 'string' ? Number(value) : value;
+
+ if (typeof options.min !== 'undefined' && valueNumber < options.min) {
+ return `Value should be greater or equal than ${options.min}.`;
+ };
+ if (typeof options.max !== 'undefined' && valueNumber > options.max) {
+ return `Value should be lower or equal than ${options.max}.`;
+ };
+ };
+ };
+
+ /**
+ * Creates a function that checks if the value is a json
+ * @param validateParsed Optional parameter to validate the parsed object
+ * @returns
+ */
+ static json(validateParsed: (object: any) => string | undefined) {
+ return function (value: string) {
+ let jsonObject;
+ // Try to parse the string as JSON
+ try {
+ jsonObject = JSON.parse(value);
+ } catch (error) {
+ return "Value can't be parsed. There is some error.";
+ };
+
+ return validateParsed ? validateParsed(jsonObject) : undefined;
+ };
+ };
+
+ /**
+ * Creates a function that checks is the value is an array and optionally validates each element
+ * @param validationElement Optional function to validate each element of the array
+ * @returns
+ */
+ static array(validationElement: (json: any) => string | undefined) {
+ return function (value: unknown[]) {
+ // Check the JSON is an array
+ if (!Array.isArray(value)) {
+ return 'Value is not a valid list.';
+ };
+
+ return validationElement
+ ? value.reduce((accum, elementValue) => {
+ if (accum) {
+ return accum;
+ };
+
+ const resultValidationElement = validationElement(elementValue);
+ if (resultValidationElement) {
+ return resultValidationElement;
+ };
+
+ return accum;
+ }, undefined)
+ : undefined;
+ };
+ };
+
+ /**
+ * Creates a function that checks if the value is equal to list of values
+ * @param literals Array of values to compare
+ * @returns
+ */
+ static literal(literals: unknown[]) {
+ return function (value: any): string | undefined {
+ return literals.includes(value) ? undefined : `Invalid value. Allowed values: ${literals.map(String).join(', ')}.`;
+ };
+ };
+
+ // FilePicker
+ static filePickerSupportedExtensions = (extensions: string[]) => (options: { name: string }) => {
+ if (typeof options === 'undefined' || typeof options.name === 'undefined') {
+ return;
+ }
+ if (!extensions.includes(path.extname(options.name))) {
+ return `File extension is invalid. Allowed file extensions: ${extensions.join(', ')}.`;
+ };
+ };
+
+ /**
+ * filePickerFileSize
+ * @param options
+ */
+ static filePickerFileSize = (options: { maxBytes?: number, minBytes?: number, meaningfulUnit?: boolean }) => (value: { size: number }) => {
+ if (typeof value === 'undefined' || typeof value.size === 'undefined') {
+ return;
+ };
+ if (typeof options.minBytes !== 'undefined' && value.size <= options.minBytes) {
+ return `File size should be greater or equal than ${options.meaningfulUnit ? formatBytes(options.minBytes) : `${options.minBytes} bytes`}.`;
+ };
+ if (typeof options.maxBytes !== 'undefined' && value.size >= options.maxBytes) {
+ return `File size should be lower or equal than ${options.meaningfulUnit ? formatBytes(options.maxBytes) : `${options.maxBytes} bytes`}.`;
+ };
+ };
};
diff --git a/common/services/settings.ts b/common/services/settings.ts
index 4e88c6c5fe..02dd107d86 100644
--- a/common/services/settings.ts
+++ b/common/services/settings.ts
@@ -5,6 +5,7 @@ import {
TPluginSettingKey,
TPluginSettingWithKey
} from '../constants';
+import { formatBytes } from './file-size';
/**
* Look for a configuration category setting by its name
@@ -108,6 +109,13 @@ export function groupSettingsByCategory(settings: TPluginSettingWithKey[]){
...(options?.switch ? [`Allowed values: ${['enabled', 'disabled'].map(s => formatLabelValuePair(options.switch.values[s].label, options.switch.values[s].value)).join(', ')}.`] : []),
...(options?.number && 'min' in options.number ? [`Minimum value: ${options.number.min}.`] : []),
...(options?.number && 'max' in options.number ? [`Maximum value: ${options.number.max}.`] : []),
+ // File extensions
+ ...(options?.file?.extensions ? [`Supported extensions: ${options.file.extensions.join(', ')}.`] : []),
+ // File recommended dimensions
+ ...(options?.file?.recommended?.dimensions ? [`Recommended dimensions: ${options.file.recommended.dimensions.width}x${options.file.recommended.dimensions.height}${options.file.recommended.dimensions.unit || ''}.`] : []),
+ // File size
+ ...((options?.file?.size && typeof options.file.size.minBytes !== 'undefined') ? [`Minimum file size: ${formatBytes(options.file.size.minBytes)}.`] : []),
+ ...((options?.file?.size && typeof options.file.size.maxBytes !== 'undefined') ? [`Maximum file size: ${formatBytes(options.file.size.maxBytes)}.`] : []),
].join(' ');
};
diff --git a/public/components/common/form/__snapshots__/index.test.tsx.snap b/public/components/common/form/__snapshots__/index.test.tsx.snap
index d4cf163362..1423f32943 100644
--- a/public/components/common/form/__snapshots__/index.test.tsx.snap
+++ b/public/components/common/form/__snapshots__/index.test.tsx.snap
@@ -21,17 +21,25 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali
class="euiFormRow__fieldWrapper"
>
-
+
+
+
+
+
@@ -60,16 +68,24 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali
class="euiFormRow__fieldWrapper"
>
-
+
+
+
+
+
-
+
+
+
+
+
@@ -122,7 +146,6 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali
`;
-
exports[`[component] InputForm Renders correctly to match the snapshot: Input: editor 1`] = `
`;
+exports[`[component] InputForm Renders correctly to match the snapshot: Input: filepicker 1`] = `
+
+
+
+
+
+
+
+ Select or drag the file
+
+
+
+
+
+`;
+
exports[`[component] InputForm Renders correctly to match the snapshot: Input: number 1`] = `