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`] = ` +
+
+
+ +
+
+
+
+
+`; + exports[`[component] InputForm Renders correctly to match the snapshot: Input: number 1`] = `
event.target.checked, [EpluginSettingType.editor]: (value: any) => value, + [EpluginSettingType.filepicker]: (value: any) => value, default: (event: any) => event.target.value, }; @@ -21,6 +22,8 @@ export const useForm = (fields) => { } }), {})); + const fieldRefs = useRef({}); + const enhanceFields = Object.entries(formFields).reduce((accum, [fieldKey, {currentValue: value, ...restFieldState}]) => ({ ...accum, [fieldKey]: { @@ -30,6 +33,8 @@ export const useForm = (fields) => { value, changed: !isEqual(restFieldState.initialValue, value), error: fields[fieldKey]?.validate?.(value), + setInputRef: (reference) => {fieldRefs.current[fieldKey] = reference}, + inputRef: fieldRefs.current[fieldKey], onChange: (event) => { const inputValue = getValueFromEvent(event, fields[fieldKey].type); const currentValue = fields[fieldKey]?.transformChangedInputValue?.(inputValue) ?? inputValue; diff --git a/public/components/common/form/index.test.tsx b/public/components/common/form/index.test.tsx index c0697402a9..6c5ba70ac9 100644 --- a/public/components/common/form/index.test.tsx +++ b/public/components/common/form/index.test.tsx @@ -4,50 +4,52 @@ import { InputForm } from './index'; import { useForm } from './hooks'; jest.mock('../../../../../../node_modules/@elastic/eui/lib/services/accessibility', () => ({ - htmlIdGenerator: () => () => 'generated-id', - useGeneratedHtmlId: () => () => 'generated-id', + htmlIdGenerator: () => () => 'generated-id', + useGeneratedHtmlId: () => () => 'generated-id', })); describe('[component] InputForm', () => { - const optionsEditor = { editor: { language: 'json' } }; - const optionsSelect = { select: [{ text: 'Label1', value: 'value1' }, { text: 'Label2', value: 'value2' }] }; - const optionsSwitch = { switch: { values: { enabled: { label: 'Enabled', value: true }, disabled: { label: 'Disabled', value: false } } } }; - it.each` + const optionsEditor = { editor: { language: 'json' } }; + const optionsFilepicker = { file: { type: 'image', extensions: ['.jpeg', '.jpg', '.png', '.svg'] } }; + const optionsSelect = { select: [{ text: 'Label1', value: 'value1' }, { text: 'Label2', value: 'value2' }] }; + const optionsSwitch = { switch: { values: { enabled: { label: 'Enabled', value: true }, disabled: { label: 'Disabled', value: false } } } }; + it.each` inputType | value | options ${'editor'} | ${'{}'} | ${optionsEditor} + ${'filepicker'} | ${'{}'} | ${optionsFilepicker} ${'number'} | ${4} | ${undefined} ${'select'} | ${'value1'} | ${optionsSelect} ${'switch'} | ${true} | ${optionsSwitch} ${'text'} | ${'test'} | ${undefined} `('Renders correctly to match the snapshot: Input: $inputType', ({ inputType, value, options }) => { - const wrapper = render( - { }} - options={options} - /> - ); - expect(wrapper.container).toMatchSnapshot(); - }); + const wrapper = render( + { }} + options={options} + /> + ); + expect(wrapper.container).toMatchSnapshot(); + }); - it.each` + it.each` inputType | initialValue | options | rest - ${'number'} | ${4} | ${{number: {min: 5}}} | ${{ validate: (value) => value > 3 ? undefined : 'Vaidation error: value is lower than 5'}} + ${'number'} | ${4} | ${{ number: { min: 5 } }} | ${{ validate: (value) => value > 3 ? undefined : 'Vaidation error: value is lower than 5' }} ${'text'} | ${''} | ${undefined} | ${{ validate: (value) => value.length ? undefined : 'Validation error: string can not be empty' }} ${'text'} | ${'test spaces'} | ${undefined} | ${{ validate: (value) => value.length ? undefined : 'Validation error: string can not contain spaces' }} `('Renders correctly to match the snapshot with validation errors. Input: $inputType', async ({ inputType, initialValue, options, rest }) => { - const TestComponent = () => { - const { fields: { [inputType]: field } } = useForm({ [inputType]: { initialValue, type: inputType, options, ...rest } }); - return ( - - ); - }; - const wrapper = render(); - expect(wrapper.container).toMatchSnapshot(); - }); + const TestComponent = () => { + const { fields: { [inputType]: field } } = useForm({ [inputType]: { initialValue, type: inputType, options, ...rest } }); + return ( + + ); + }; + const wrapper = render(); + expect(wrapper.container).toMatchSnapshot(); + }); }); diff --git a/public/components/common/form/index.tsx b/public/components/common/form/index.tsx index 62d8e15080..5067dc7b7a 100644 --- a/public/components/common/form/index.tsx +++ b/public/components/common/form/index.tsx @@ -2,11 +2,22 @@ import React from 'react'; import { InputFormEditor } from './input_editor'; import { InputFormNumber } from './input_number'; import { InputFormText } from './input_text'; -import { InputFormSwitch } from './input_switch'; import { InputFormSelect } from './input_select'; -import { EuiFormRow } from '@elastic/eui'; +import { InputFormSwitch } from './input_switch'; +import { InputFormFilePicker } from './input_filepicker'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; -export const InputForm = ({ type, value, onChange, error, label, preInput, postInput, ...rest}) => { +export const InputForm = ({ + type, + value, + onChange, + error, + label, + header, + footer, + preInput, + postInput, +...rest}) => { const ComponentInput = Input[type]; @@ -29,9 +40,15 @@ export const InputForm = ({ type, value, onChange, error, label, preInput, postI ? ( <> - {typeof preInput === 'function' ? preInput({value, error}) : preInput} - {input} - {typeof postInput === 'function' ? postInput({value, error}) : postInput} + {typeof header === 'function' ? header({value, error}) : header} + + {typeof preInput === 'function' ? preInput({value, error}) : preInput} + + {input} + + {typeof postInput === 'function' ? postInput({value, error}) : postInput} + + {typeof footer === 'function' ? footer({value, error}) : footer} ) : input; @@ -44,4 +61,5 @@ const Input = { number: InputFormNumber, select: InputFormSelect, text: InputFormText, + filepicker: InputFormFilePicker }; diff --git a/public/components/common/form/input_filepicker.tsx b/public/components/common/form/input_filepicker.tsx new file mode 100644 index 0000000000..5a46828a47 --- /dev/null +++ b/public/components/common/form/input_filepicker.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { EuiFilePicker } from '@elastic/eui'; +import { IInputFormType } from './types'; + +export const InputFormFilePicker = ({onChange, options, setInputRef, key, ...rest} : IInputFormType) => ( + onChange( + // File was added. + fileList?.[0] + // File was removed. We set the initial value, so the useForm hook will not detect any change. */ + || rest.initialValue)} + display='default' + fullWidth + aria-label='Upload a file' + accept={options.file.extensions.join(',')} + ref={setInputRef} + /> +); diff --git a/public/components/common/form/input_number.tsx b/public/components/common/form/input_number.tsx index acc21002f4..f4db8e2a0b 100644 --- a/public/components/common/form/input_number.tsx +++ b/public/components/common/form/input_number.tsx @@ -3,12 +3,13 @@ import { EuiFieldNumber } from '@elastic/eui'; import { IInputFormType } from './types'; export const InputFormNumber = ({ options, value, onChange }: IInputFormType) => { + const { integer, ...rest } = options?.number || {}; return ( ); }; diff --git a/public/components/common/form/types.ts b/public/components/common/form/types.ts index b1d8c51120..e064804790 100644 --- a/public/components/common/form/types.ts +++ b/public/components/common/form/types.ts @@ -6,6 +6,7 @@ export interface IInputFormType { onChange: (event: any) => void isInvalid?: boolean options: any + setInputRef: (reference: any) => void }; export interface IInputForm { diff --git a/public/components/settings/configuration/components/categories/components/category/category.tsx b/public/components/settings/configuration/components/categories/components/category/category.tsx index 19ad8d1ad2..4aaca2ab08 100644 --- a/public/components/settings/configuration/components/categories/components/category/category.tsx +++ b/public/components/settings/configuration/components/categories/components/category/category.tsx @@ -25,12 +25,19 @@ import { EuiButtonIcon, } from '@elastic/eui'; import { EuiIconTip } from '@elastic/eui'; -import { TPluginSettingWithKey } from '../../../../../../../../common/constants'; -import { getPluginSettingDescription } from '../../../../../../../../common/services/settings'; +import { EpluginSettingType, TPluginSettingWithKey, UI_LOGGER_LEVELS } from '../../../../../../../../common/constants'; import { webDocumentationLink } from '../../../../../../../../common/services/web_documentation'; import classNames from 'classnames'; import { InputForm } from '../../../../../../common/form'; - +import { useDispatch } from 'react-redux'; +import { getHttp } from '../../../../../../../kibana-services'; +import { getAssetURL } from '../../../../../../../utils/assets'; +import { UI_ERROR_SEVERITIES } from '../../../../../../../react-services/error-orchestrator/types'; +import { WzRequest } from '../../../../../../../react-services'; +import { updateAppConfig } from '../../../../../../../redux/actions/appConfigActions'; +import { getErrorOrchestrator } from '../../../../../../../react-services/common-services'; +import { WzButtonModalConfirm } from '../../../../../../common/buttons'; +import { toastRequiresReloadingBrowserTab, toastRequiresRestartingPluginPlatform, toastRequiresRunningHealthcheck, toastSuccessUpdateConfiguration } from '../show-toasts'; interface ICategoryProps { title: string @@ -38,12 +45,11 @@ interface ICategoryProps { documentationLink?: string items: TPluginSettingWithKey[] currentConfiguration: { [field: string]: any } - changedConfiguration: { [field: string]: any } - onChangeFieldForm: () => void } export const Category: React.FunctionComponent = ({ title, + currentConfiguration, description, documentationLink, items @@ -122,10 +128,23 @@ export const Category: React.FunctionComponent = ({ )} } - description={getPluginSettingDescription(item)} > + description={item.description} > ( + + + + ) + } + : {} + )} /> ) @@ -135,3 +154,61 @@ export const Category: React.FunctionComponent = ({ ) }; + +const InputFormFilePickerPreInput = ({image, field}: {image: string, field: any}) => { + const dispatch = useDispatch(); + + return ( + <> + + + Custom logo + + + { + try{ + const response = await WzRequest.genericReq('DELETE', `/utils/configuration/files/${field.key}`); + dispatch(updateAppConfig(response.data.data.updatedConfiguration)); + + // Show the toasts if necessary + const { requiresRunningHealthCheck, requiresReloadingBrowserTab, requiresRestartingPluginPlatform } = response.data.data; + requiresRunningHealthCheck && toastRequiresRunningHealthcheck(); + requiresReloadingBrowserTab&& toastRequiresReloadingBrowserTab(); + requiresRestartingPluginPlatform && toastRequiresRestartingPluginPlatform(); + toastSuccessUpdateConfiguration(); + }catch(error){ + const options = { + context: `${InputFormFilePickerPreInput.name}.confirmDeleteFile`, + level: UI_LOGGER_LEVELS.ERROR, + severity: UI_ERROR_SEVERITIES.BUSINESS, + store: true, + error: { + error: error, + message: error.message || error, + title: error.name || error, + }, + }; + getErrorOrchestrator().handleError(options); + } + }} + modalProps={{ buttonColor: 'danger' }} + iconType="trash" + color="danger" + aria-label="Delete file" + /> + + + + ); +}; diff --git a/public/components/settings/configuration/components/categories/components/show-toasts.tsx b/public/components/settings/configuration/components/categories/components/show-toasts.tsx new file mode 100644 index 0000000000..b53e56e092 --- /dev/null +++ b/public/components/settings/configuration/components/categories/components/show-toasts.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { PLUGIN_PLATFORM_NAME } from '../../../../../../../common/constants'; +import { getToasts } from '../../../../../../kibana-services'; + +export const toastRequiresReloadingBrowserTab = () => { + getToasts().add({ + color: 'success', + title: 'Reload the page to apply the changes', + text: + + window.location.reload()} size="s">Reload page + + + }); +}; + +export const toastRequiresRunningHealthcheck = () => { + const toast = getToasts().add({ + color: 'warning', + title: 'Run a health check to apply the changes.', + toastLifeTimeMs: 5000, + text: + + + { + getToasts().remove(toast); + window.location.href = '#/health-check'; + }} size="s">Execute health check + + + }); +}; + +export const toastRequiresRestartingPluginPlatform = () => { + getToasts().add({ + color: 'warning', + title: `Restart ${PLUGIN_PLATFORM_NAME} to apply the changes`, + }); +}; + +export const toastSuccessUpdateConfiguration = () => { + getToasts().add({ + color: 'success', + title: 'The configuration has been successfully updated', + }); +}; \ No newline at end of file diff --git a/public/components/settings/configuration/configuration.tsx b/public/components/settings/configuration/configuration.tsx index b8ccba3f2b..5fceb25b35 100644 --- a/public/components/settings/configuration/configuration.tsx +++ b/public/components/settings/configuration/configuration.tsx @@ -29,15 +29,17 @@ import { import store from '../../../redux/store' import { updateSelectedSettingsSection } from '../../../redux/actions/appStateActions'; import { withUserAuthorizationPrompt, withErrorBoundary, withReduxProvider } from '../../common/hocs'; -import { PLUGIN_PLATFORM_NAME, PLUGIN_SETTINGS, PLUGIN_SETTINGS_CATEGORIES, UI_LOGGER_LEVELS, WAZUH_ROLE_ADMINISTRATOR_NAME } from '../../../../common/constants'; +import { EpluginSettingType, PLUGIN_SETTINGS, PLUGIN_SETTINGS_CATEGORIES, UI_LOGGER_LEVELS, WAZUH_ROLE_ADMINISTRATOR_NAME } from '../../../../common/constants'; import { compose } from 'redux'; -import { getSettingsDefaultList, groupSettingsByCategory, getCategorySettingByTitle } from '../../../../common/services/settings'; +import { getPluginSettingDescription, getSettingsDefaultList, groupSettingsByCategory, getCategorySettingByTitle } from '../../../../common/services/settings'; import { Category } from './components/categories/components'; import { WzRequest } from '../../../react-services'; import { UIErrorLog, UIErrorSeverity, UILogLevel, UI_ERROR_SEVERITIES } from '../../../react-services/error-orchestrator/types'; import { getErrorOrchestrator } from '../../../react-services/common-services'; import { getToasts } from '../../../kibana-services'; import { updateAppConfig } from '../../../redux/actions/appConfigActions'; +import path from 'path'; +import { toastRequiresReloadingBrowserTab, toastRequiresRestartingPluginPlatform, toastRequiresRunningHealthcheck, toastSuccessUpdateConfiguration } from './components/categories/components/show-toasts'; export type ISetting = { key: string @@ -103,7 +105,7 @@ const WzConfigurationSettingsProvider = (props) => { type: PLUGIN_SETTINGS[fieldKey].type, options: PLUGIN_SETTINGS[fieldKey]?.options, title: PLUGIN_SETTINGS[fieldKey]?.title, - description: PLUGIN_SETTINGS[fieldKey]?.description + description: getPluginSettingDescription(PLUGIN_SETTINGS[fieldKey]), })); // https://github.com/elastic/eui/blob/aa4cfd7b7c34c2d724405a3ecffde7fe6cf3b50f/src/components/search_bar/query/query.ts#L138-L163 @@ -120,23 +122,48 @@ const WzConfigurationSettingsProvider = (props) => { setLoading(true); try { const settingsToUpdate = Object.entries(changed).reduce((accum, [pluginSettingKey, currentValue]) => { - if(PLUGIN_SETTINGS[pluginSettingKey].isConfigurableFromFile){ + if(PLUGIN_SETTINGS[pluginSettingKey].isConfigurableFromFile && PLUGIN_SETTINGS[pluginSettingKey].type === EpluginSettingType.filepicker){ + accum.fileUpload = { + ...accum.fileUpload, + [pluginSettingKey]: { + file: currentValue, + extension: path.extname(currentValue.name) + } + } + }else if(PLUGIN_SETTINGS[pluginSettingKey].isConfigurableFromFile){ accum.saveOnConfigurationFile = { ...accum.saveOnConfigurationFile, [pluginSettingKey]: currentValue } }; return accum; - }, {saveOnConfigurationFile: {}}); + }, {saveOnConfigurationFile: {}, fileUpload: {}}); const requests = []; + // Update the settings that doesn't upload a file if(Object.keys(settingsToUpdate.saveOnConfigurationFile).length){ requests.push(WzRequest.genericReq( 'PUT', '/utils/configuration', settingsToUpdate.saveOnConfigurationFile )); }; + + // Update the settings that uploads a file + if(Object.keys(settingsToUpdate.fileUpload).length){ + requests.push(...Object.entries(settingsToUpdate.fileUpload) + .map(([pluginSettingKey, {file, extension}]) => { + // Create the form data + const formData = new FormData(); + formData.append('file', file); + return WzRequest.genericReq( + 'PUT', `/utils/configuration/files/${pluginSettingKey}`, + formData, + {overwriteHeaders: {'content-type': 'multipart/form-data'}} + ) + })); + }; + const responses = await Promise.all(requests); // Show the toasts if necessary @@ -154,8 +181,26 @@ const WzConfigurationSettingsProvider = (props) => { },{}) })); + // Remove the selected files on the file picker inputs + if(Object.keys(settingsToUpdate.fileUpload).length){ + Object.keys(settingsToUpdate.fileUpload).forEach(settingKey => { + try{ + fields[settingKey].inputRef.removeFiles( + // This method uses some methods of a DOM event. + // Because we want to remove the files when the configuration is saved, + // there is no event, so we create a object that contains the + // methods used to remove the files. Of this way, we can skip the errors + // due to missing methods. + // This workaround is based in @elastic/eui v29.3.2 + // https://github.com/elastic/eui/blob/v29.3.2/src/components/form/file_picker/file_picker.tsx#L107-L108 + {stopPropagation: () => {}, preventDefault: () => {}} + ); + }catch(error){ }; + }); + }; + // Show the success toast - successToast(); + toastSuccessUpdateConfiguration(); // Reset the form changed configuration doChanges(); @@ -178,6 +223,31 @@ const WzConfigurationSettingsProvider = (props) => { }; }; + const onCancel = () => { + const updatedSettingsUseFilePicker = Object.entries(changed).reduce((accum, [pluginSettingKey]) => { + if(PLUGIN_SETTINGS[pluginSettingKey].isConfigurableFromFile && PLUGIN_SETTINGS[pluginSettingKey].type === EpluginSettingType.filepicker){ + accum.push(pluginSettingKey); + }; + return accum; + }, []); + + updatedSettingsUseFilePicker.forEach(settingKey => { + try{ + fields[settingKey].inputRef.removeFiles( + // This method uses some methods of a DOM event. + // Because we want to remove the files when the configuration is saved, + // there is no event, so we create a object that contains the + // methods used to remove the files. Of this way, we can skip the errors + // due to missing methods. + // This workaround is based in @elastic/eui v29.3.2 + // https://github.com/elastic/eui/blob/v29.3.2/src/components/form/file_picker/file_picker.tsx#L107-L108 + {stopPropagation: () => {}, preventDefault: () => {}} + ); + }catch(error){ }; + }); + undoChanges(); + }; + return ( @@ -203,6 +273,7 @@ const WzConfigurationSettingsProvider = (props) => { description={description} documentationLink={documentationLink} items={settings} + currentConfiguration={currentConfiguration} /> ) } @@ -213,7 +284,7 @@ const WzConfigurationSettingsProvider = (props) => { )} @@ -226,46 +297,3 @@ export const WzConfigurationSettings = compose ( withReduxProvider, withUserAuthorizationPrompt(null, [WAZUH_ROLE_ADMINISTRATOR_NAME]) )(WzConfigurationSettingsProvider); - -const toastRequiresReloadingBrowserTab = () => { - getToasts().add({ - color: 'success', - title: 'This setting requires you to reload the page to take effect.', - text: - - window.location.reload()} size="s">Reload page - - - }); -}; - -const toastRequiresRunningHealthcheck = () => { - const toast = getToasts().add({ - color: 'warning', - title: 'You must execute the health check for the changes to take effect', - toastLifeTimeMs: 5000, - text: - - - { - getToasts().remove(toast); - window.location.href = '#/health-check'; - }} size="s">Execute health check - - - }); -}; - -const toastRequiresRestartingPluginPlatform = () => { - getToasts().add({ - color: 'warning', - title: `You must restart ${PLUGIN_PLATFORM_NAME} for the changes to take effect`, - }); -}; - -const successToast = () => { - getToasts().add({ - color: 'success', - title: 'The configuration has been successfully updated', - }); -}; diff --git a/public/components/wz-menu/wz-menu.js b/public/components/wz-menu/wz-menu.js index f1922c7057..17072a7684 100644 --- a/public/components/wz-menu/wz-menu.js +++ b/public/components/wz-menu/wz-menu.js @@ -894,7 +894,7 @@ export const WzMenu = withWindowSize(class WzMenu extends Component { )} - {/*this.state.hover === 'overview' */this.state.isOverviewPopoverOpen && ( + {this.state.isOverviewPopoverOpen && ( this.setState({ menuOpened: false })} > @@ -912,10 +912,15 @@ export const WzMenu = withWindowSize(class WzMenu extends Component { responsive={false} style={{ paddingTop: 2 }} > - - + + Menu logo - + {this.state.menuOpened && ( )} diff --git a/public/components/wz-menu/wz-menu.scss b/public/components/wz-menu/wz-menu.scss index cd61ac66d1..652de05081 100644 --- a/public/components/wz-menu/wz-menu.scss +++ b/public/components/wz-menu/wz-menu.scss @@ -75,9 +75,18 @@ wz-menu { max-width: 700px; } +.navBarLogo-wrapper { + margin-right: 0px; + max-width: 100px; + flex-direction: column; + justify-content: center; +} + .navBarLogo { - width: 100px; - } + width: fit-content; + max-width: 100%; + max-height: 32px; +} .wz-menu-popover{ position: fixed; diff --git a/public/controllers/management/components/upload-files.js b/public/controllers/management/components/upload-files.js index 0632aa6492..085716c1ae 100644 --- a/public/controllers/management/components/upload-files.js +++ b/public/controllers/management/components/upload-files.js @@ -31,6 +31,7 @@ import { import { getToasts } from '../../../kibana-services'; import { WzButtonPermissions } from '../../../components/common/permissions/button'; import { resourceDictionary, ResourcesConstants } from './management/common/resources-handler'; +import { formatBytes } from '../../../../common/services/file-size'; export class UploadFiles extends Component { @@ -232,7 +233,7 @@ export class UploadFiles extends Component { ))} @@ -241,11 +242,6 @@ export class UploadFiles extends Component { ); } - /** - * Format Bytes size to largest unit - */ - formatBytes(a, b = 2) { if (0 === a) return "0 Bytes"; const c = 0 > b ? 0 : b, d = Math.floor(Math.log(a) / Math.log(1024)); return parseFloat((a / Math.pow(1024, d)).toFixed(c)) + " " + ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"][d] } - /** * Renders the errors when trying to upload files */ diff --git a/public/react-services/wz-request.ts b/public/react-services/wz-request.ts index 39819ddd21..bd63bef16b 100644 --- a/public/react-services/wz-request.ts +++ b/public/react-services/wz-request.ts @@ -32,13 +32,15 @@ export class WzRequest { method, path, payload: any = null, - extraOptions: { shouldRetry?: boolean, checkCurrentApiIsUp?: boolean } = { + extraOptions: { shouldRetry?: boolean, checkCurrentApiIsUp?: boolean, overwriteHeaders?: any } = { shouldRetry: true, - checkCurrentApiIsUp: true + checkCurrentApiIsUp: true, + overwriteHeaders: {} } ) { const shouldRetry = typeof extraOptions.shouldRetry === 'boolean' ? extraOptions.shouldRetry : true; - const checkCurrentApiIsUp = typeof extraOptions.checkCurrentApiIsUp === 'boolean' ? extraOptions.checkCurrentApiIsUp : true; + const checkCurrentApiIsUp = typeof extraOptions.checkCurrentApiIsUp === 'boolean' ? extraOptions.checkCurrentApiIsUp : true; + const overwriteHeaders = typeof extraOptions.overwriteHeaders === 'object' ? extraOptions.overwriteHeaders : {}; try { if (!method || !path) { throw new Error('Missing parameters'); @@ -50,7 +52,7 @@ export class WzRequest { const url = getHttp().basePath.prepend(path); const options = { method: method, - headers: { ...PLUGIN_PLATFORM_REQUEST_HEADERS, 'content-type': 'application/json' }, + headers: { ...PLUGIN_PLATFORM_REQUEST_HEADERS, 'content-type': 'application/json', ...overwriteHeaders }, url: url, data: payload, timeout: timeout, diff --git a/server/controllers/wazuh-utils/wazuh-utils.ts b/server/controllers/wazuh-utils/wazuh-utils.ts index ecf380f4b2..05ea874af8 100644 --- a/server/controllers/wazuh-utils/wazuh-utils.ts +++ b/server/controllers/wazuh-utils/wazuh-utils.ts @@ -20,9 +20,15 @@ import { WAZUH_ROLE_ADMINISTRATOR_ID, WAZUH_DATA_LOGS_RAW_PATH, PLUGIN_SETTINGS import { ManageHosts } from '../../lib/manage-hosts'; import { KibanaRequest, RequestHandlerContext, KibanaResponseFactory } from 'src/core/server'; import { getCookieValueByName } from '../../lib/cookie'; +import fs from 'fs'; +import path from 'path'; +import { createDirectoryIfNotExists } from '../../lib/filesystem'; +import glob from 'glob'; +import { getFileExtensionFromBuffer } from '../../../common/services/file-extension'; const updateConfigurationFile = new UpdateConfigurationFile(); +// TODO: these controllers have no logs. We should include them. export class WazuhUtilsCtrl { /** * Constructor @@ -85,7 +91,7 @@ export class WazuhUtilsCtrl { return response.ok({ body: { - data: { requiresRunningHealthCheck, requiresReloadingBrowserTab, requiresRestartingPluginPlatform, updatedConfiguration: request.body } + data: { requiresRunningHealthCheck, requiresReloadingBrowserTab, requiresRestartingPluginPlatform, updatedConfiguration: pluginSettingsConfigurableFile } } }); }, @@ -94,7 +100,7 @@ export class WazuhUtilsCtrl { /** * Returns Wazuh app logs - * @param {Object} context + * @param {Object} context * @param {Object} request * @param {Object} response * @returns {Array} app logs or ErrorResponse @@ -121,6 +127,99 @@ export class WazuhUtilsCtrl { } } + /** + * Upload a file + * @param {Object} context + * @param {Object} request + * @param {Object} response + * @returns {Object} Configuration File or ErrorResponse + */ + uploadFile = this.routeDecoratorProtectedAdministratorRoleValidToken( + async (context: RequestHandlerContext, request: KibanaRequest, response: KibanaResponseFactory) => { + const { key } = request.params; + const { file: bufferFile } = request.body; + const pluginSetting = PLUGIN_SETTINGS[key]; + + // Check file extension + const fileExtension = getFileExtensionFromBuffer(bufferFile); + + // Check if the extension is valid for the setting. + if(!pluginSetting.options.file.extensions.includes(`.${fileExtension}`)){ + return response.badRequest({ + body: `File extension is not valid for setting [${key}] setting. Allowed file extensions: ${pluginSetting.options.file.extensions.join(', ')}` + }); + }; + + const fileNamePath = `${key}.${fileExtension}`; + + // Create target directory + const targetDirectory = path.join(__dirname, '../../..', pluginSetting.options.file.store.relativePathFileSystem); + createDirectoryIfNotExists(targetDirectory); + // Get the files related to the setting and remove them + const files = glob.sync(path.join(targetDirectory, `${key}.*`)); + files.forEach(fs.unlinkSync); + + // Store the file in the target directory. + fs.writeFileSync(path.join(targetDirectory, fileNamePath), bufferFile); + + // Update the setting in the configuration cache + const pluginSettingValue = pluginSetting.options.file.store.resolveStaticURL(fileNamePath); + await updateConfigurationFile.updateConfiguration({[key]: pluginSettingValue}); + + return response.ok({ + body: { + data: { + requiresRunningHealthCheck: Boolean(pluginSetting.requiresRunningHealthCheck), + requiresReloadingBrowserTab: Boolean(pluginSetting.requiresReloadingBrowserTab), + requiresRestartingPluginPlatform: Boolean(pluginSetting.requiresRestartingPluginPlatform), + updatedConfiguration: { + [key]: pluginSettingValue + } + } + } + }); + }, + 3022 + ) + + /** + * Upload a file + * @param {Object} context + * @param {Object} request + * @param {Object} response + * @returns {Object} Configuration File or ErrorResponse + */ + deleteFile = this.routeDecoratorProtectedAdministratorRoleValidToken( + async (context: RequestHandlerContext, request: KibanaRequest, response: KibanaResponseFactory) => { + const { key } = request.params; + const pluginSetting = PLUGIN_SETTINGS[key]; + + // Get the files related to the setting and remove them + const targetDirectory = path.join(__dirname, '../../..', pluginSetting.options.file.store.relativePathFileSystem); + const files = glob.sync(path.join(targetDirectory,`${key}.*`)); + files.forEach(fs.unlinkSync); + + // Update the setting in the configuration cache + const pluginSettingValue = pluginSetting.defaultValue; + await updateConfigurationFile.updateConfiguration({[key]: pluginSettingValue}); + + return response.ok({ + body: { + message: 'All files were removed and the configuration file was updated.', + data: { + requiresRunningHealthCheck: Boolean(pluginSetting.requiresRunningHealthCheck), + requiresReloadingBrowserTab: Boolean(pluginSetting.requiresReloadingBrowserTab), + requiresRestartingPluginPlatform: Boolean(pluginSetting.requiresRestartingPluginPlatform), + updatedConfiguration: { + [key]: pluginSettingValue + } + } + } + }); + }, + 3023 + ) + private routeDecoratorProtectedAdministratorRoleValidToken(routeHandler, errorCode: number){ return async (context, request, response) => { try{ diff --git a/server/lib/filesystem.ts b/server/lib/filesystem.ts index d4aeab1bd5..68fe41c878 100644 --- a/server/lib/filesystem.ts +++ b/server/lib/filesystem.ts @@ -4,7 +4,7 @@ import { WAZUH_DATA_ABSOLUTE_PATH } from '../../common/constants'; export const createDirectoryIfNotExists = (directory: string): void => { if (!fs.existsSync(directory)) { - fs.mkdirSync(directory); + fs.mkdirSync(directory, { recursive: true }); } }; @@ -19,7 +19,7 @@ export const createDataDirectoryIfNotExists = (directory?: string) => { ? path.join(WAZUH_DATA_ABSOLUTE_PATH, directory) : WAZUH_DATA_ABSOLUTE_PATH; if (!fs.existsSync(absoluteRoute)) { - fs.mkdirSync(absoluteRoute); + fs.mkdirSync(absoluteRoute, { recursive: true }); } }; diff --git a/server/routes/wazuh-utils/fixtures/fixture_file.txt b/server/routes/wazuh-utils/fixtures/fixture_file.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/routes/wazuh-utils/fixtures/fixture_image_big.png b/server/routes/wazuh-utils/fixtures/fixture_image_big.png new file mode 100644 index 0000000000..9c574f937d Binary files /dev/null and b/server/routes/wazuh-utils/fixtures/fixture_image_big.png differ diff --git a/server/routes/wazuh-utils/fixtures/fixture_image_small.jpg b/server/routes/wazuh-utils/fixtures/fixture_image_small.jpg new file mode 100644 index 0000000000..19c02a553b Binary files /dev/null and b/server/routes/wazuh-utils/fixtures/fixture_image_small.jpg differ diff --git a/server/routes/wazuh-utils/fixtures/fixture_image_small.png b/server/routes/wazuh-utils/fixtures/fixture_image_small.png new file mode 100644 index 0000000000..ba9bc4dae9 Binary files /dev/null and b/server/routes/wazuh-utils/fixtures/fixture_image_small.png differ diff --git a/server/routes/wazuh-utils/fixtures/fixture_image_small.svg b/server/routes/wazuh-utils/fixtures/fixture_image_small.svg new file mode 100644 index 0000000000..f6d72ed8ce --- /dev/null +++ b/server/routes/wazuh-utils/fixtures/fixture_image_small.svg @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/server/routes/wazuh-utils/wazuh-utils.test.ts b/server/routes/wazuh-utils/wazuh-utils.test.ts index f5dfc3cbca..ce0ed7d0a1 100644 --- a/server/routes/wazuh-utils/wazuh-utils.test.ts +++ b/server/routes/wazuh-utils/wazuh-utils.test.ts @@ -13,13 +13,13 @@ import { WAZUH_DATA_ABSOLUTE_PATH, WAZUH_DATA_CONFIG_APP_PATH, WAZUH_DATA_CONFIG_DIRECTORY_PATH, - WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH, - WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, WAZUH_DATA_LOGS_DIRECTORY_PATH, WAZUH_DATA_LOGS_RAW_PATH, } from "../../../common/constants"; import { execSync } from 'child_process'; import fs from 'fs'; +import path from 'path'; +import glob from 'glob'; import moment from 'moment'; import { of } from 'rxjs'; @@ -187,7 +187,7 @@ hosts: }, { testTitle: 'Bad request, unknown setting', - settings: { 'unknown.setting': 'test-alerts-groupA-*','logs.level': 'debug' }, + settings: { 'unknown.setting': 'test-alerts-groupA-*', 'logs.level': 'debug' }, responseStatusCode: 400, responseBodyMessage: '[request body.unknown.setting]: definition for this key is missing' }, @@ -197,17 +197,17 @@ hosts: responseStatusCode: 400, responseBodyMessage: '[request body.cron.statistics.apis.0]: expected value of type [string] but got [number]' } - ])(`$testTitle: $settings. PUT /utils/configuration - $responseStatusCode`, async ({responseBodyMessage, responseStatusCode, settings}) => { + ])(`$testTitle: $settings. PUT /utils/configuration - $responseStatusCode`, async ({ responseBodyMessage, responseStatusCode, settings }) => { const response = await supertest(innerServer.listener) .put('/utils/configuration') .send(settings) .expect(responseStatusCode); - responseStatusCode === 200 && expect(response.body.data.updatedConfiguration).toEqual(settings); - responseStatusCode === 200 && expect(response.body.data.requiresRunningHealthCheck).toBeDefined(); - responseStatusCode === 200 && expect(response.body.data.requiresReloadingBrowserTab).toBeDefined(); - responseStatusCode === 200 && expect(response.body.data.requiresRestartingPluginPlatform).toBeDefined(); - responseBodyMessage && expect(response.body.message).toMatch(responseBodyMessage); + responseStatusCode === 200 && expect(response.body.data.updatedConfiguration).toEqual(settings); + responseStatusCode === 200 && expect(response.body.data.requiresRunningHealthCheck).toBeDefined(); + responseStatusCode === 200 && expect(response.body.data.requiresReloadingBrowserTab).toBeDefined(); + responseStatusCode === 200 && expect(response.body.data.requiresRestartingPluginPlatform).toBeDefined(); + responseBodyMessage && expect(response.body.message).toMatch(responseBodyMessage); }); it.each` @@ -422,18 +422,166 @@ hosts: ${'wazuh.monitoring.shards'} | ${-1} | ${400} | ${"[request body.wazuh.monitoring.shards]: Value should be greater or equal than 1."} ${'wazuh.monitoring.shards'} | ${1.2} | ${400} | ${"[request body.wazuh.monitoring.shards]: Number should be an integer."} ${'wazuh.monitoring.shards'} | ${'custom'} | ${400} | ${"[request body.wazuh.monitoring.shards]: expected value of type [number] but got [string]"} - `(`$setting: $value - PUT /utils/configuration - $responseStatusCode`, async ({responseBodyMessage, responseStatusCode, setting, value}) => { - const body = {[setting]: value}; + `(`$setting: $value - PUT /utils/configuration - $responseStatusCode`, async ({ responseBodyMessage, responseStatusCode, setting, value }) => { + const body = { [setting]: value }; const response = await supertest(innerServer.listener) .put('/utils/configuration') .send(body) .expect(responseStatusCode); - responseStatusCode === 200 && expect(response.body.data.updatedConfiguration).toEqual(body); - responseStatusCode === 200 && expect(response.body.data.requiresRunningHealthCheck).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRunningHealthCheck)); - responseStatusCode === 200 && expect(response.body.data.requiresReloadingBrowserTab).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresReloadingBrowserTab)); - responseStatusCode === 200 && expect(response.body.data.requiresRestartingPluginPlatform).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRestartingPluginPlatform)); - responseBodyMessage && expect(response.body.message).toMatch(responseBodyMessage); + responseStatusCode === 200 && expect(response.body.data.updatedConfiguration).toEqual(body); + responseStatusCode === 200 && expect(response.body.data.requiresRunningHealthCheck).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRunningHealthCheck)); + responseStatusCode === 200 && expect(response.body.data.requiresReloadingBrowserTab).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresReloadingBrowserTab)); + responseStatusCode === 200 && expect(response.body.data.requiresRestartingPluginPlatform).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRestartingPluginPlatform)); + responseBodyMessage && expect(response.body.message).toMatch(responseBodyMessage); + }); +}); + +describe('[endpoint] PUT /utils/configuration/files/{key} - Upload file', () => { + + const PUBLIC_CUSTOM_ASSETS_PATH = path.join(__dirname, '../../../', 'public/assets/custom'); + + beforeAll(() => { + // Remove /public/assets/custom directory. + execSync(`rm -rf ${PUBLIC_CUSTOM_ASSETS_PATH}`); + + // Create the configuration file with custom content + const fileContent = `--- +pattern: test-alerts-* + +hosts: + - default: + url: https://localhost + port: 55000 + username: wazuh-wui + password: wazuh-wui + run_as: false +`; + + fs.writeFileSync(WAZUH_DATA_CONFIG_APP_PATH, fileContent, 'utf8'); + }); + + afterAll(() => { + // Remove /public/assets/custom directory. + execSync(`rm -rf ${PUBLIC_CUSTOM_ASSETS_PATH}`); + + // Remove the configuration file + fs.unlinkSync(WAZUH_DATA_CONFIG_APP_PATH); + }); + + it.each` + setting | filename | responseStatusCode | responseBodyMessage + ${'customization.logo.unknown'} | ${'fixture_image_small.jpg'} | ${400} | ${'[request params.key]: types that failed validation:\n- [request params.key.0]: expected value to equal [customization.logo.app]\n- [request params.key.1]: expected value to equal [customization.logo.healthcheck]\n- [request params.key.2]: expected value to equal [customization.logo.reports]\n- [request params.key.3]: expected value to equal [customization.logo.sidebar]'} + ${'customization.logo.app'} | ${'fixture_image_small.jpg'} | ${200} | ${null} + ${'customization.logo.app'} | ${'fixture_image_small.png'} | ${200} | ${null} + ${'customization.logo.app'} | ${'fixture_image_small.svg'} | ${200} | ${null} + ${'customization.logo.app'} | ${'fixture_image_big.png'} | ${413} | ${'Payload content length greater than maximum allowed: 1048576'} + ${'customization.logo.healthcheck'} | ${'fixture_image_small.jpg'} | ${200} | ${null} + ${'customization.logo.healthcheck'} | ${'fixture_image_small.png'} | ${200} | ${null} + ${'customization.logo.healthcheck'} | ${'fixture_image_small.svg'} | ${200} | ${null} + ${'customization.logo.healthcheck'} | ${'fixture_image_big.png'} | ${413} | ${'Payload content length greater than maximum allowed: 1048576'} + ${'customization.logo.reports'} | ${'fixture_image_small.jpg'} | ${200} | ${null} + ${'customization.logo.reports'} | ${'fixture_image_small.png'} | ${200} | ${null} + ${'customization.logo.reports'} | ${'fixture_image_big.png'} | ${413} | ${'Payload content length greater than maximum allowed: 1048576'} + ${'customization.logo.reports'} | ${'fixture_image_small.svg'} | ${400} | ${"File extension is not valid for setting [customization.logo.reports] setting. Allowed file extensions: .jpeg, .jpg, .png"} + ${'customization.logo.sidebar'} | ${'fixture_image_small.jpg'} | ${200} | ${null} + ${'customization.logo.sidebar'} | ${'fixture_image_small.png'} | ${200} | ${null} + ${'customization.logo.sidebar'} | ${'fixture_image_small.svg'} | ${200} | ${null} + ${'customization.logo.sidebar'} | ${'fixture_image_big.png'} | ${413} | ${'Payload content length greater than maximum allowed: 1048576'} + `(`$setting: $filename - PUT /utils/configuration/files/{key} - $responseStatusCode`, async ({ responseBodyMessage, responseStatusCode, setting, filename }) => { + const filePath = path.join(__dirname, 'fixtures', filename); + const extension = path.extname(filename); + + const response = await supertest(innerServer.listener) + .put(`/utils/configuration/files/${setting}`) + .attach('file', filePath) + .expect(responseStatusCode); + + responseStatusCode === 200 && expect(response.body.data.updatedConfiguration[setting]).toBeDefined(); + responseStatusCode === 200 && expect(response.body.data.updatedConfiguration[setting]).toMatch(`${setting}${extension}`); + responseStatusCode === 200 && expect(response.body.data.requiresRunningHealthCheck).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRunningHealthCheck)); + responseStatusCode === 200 && expect(response.body.data.requiresReloadingBrowserTab).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresReloadingBrowserTab)); + responseStatusCode === 200 && expect(response.body.data.requiresRestartingPluginPlatform).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRestartingPluginPlatform)); + responseBodyMessage && expect(response.body.message).toMatch(responseBodyMessage); + + // Check the file was created in the expected path of the file system. + if (response?.body?.data?.updatedConfiguration?.[setting]) { + const targetFilepath = path.join(__dirname, '../../../', PLUGIN_SETTINGS[setting].options.file.store.relativePathFileSystem, `${PLUGIN_SETTINGS[setting].options.file.store.filename}${extension}`); + const files = glob.sync(path.join(targetFilepath)); + expect(files[0]).toBeDefined(); + }; + }); +}); + +describe('[endpoint] DELETE /utils/configuration/files/{key} - Delete file', () => { + + const PUBLIC_CUSTOM_ASSETS_PATH = path.join(__dirname, '../../../', 'public/assets/custom'); + + beforeAll(() => { + // Remove /public/assets/custom directory. + execSync(`rm -rf ${PUBLIC_CUSTOM_ASSETS_PATH}`); + + // Create the configuration file with custom content + const fileContent = `--- +pattern: test-alerts-* + +hosts: + - default: + url: https://localhost + port: 55000 + username: wazuh-wui + password: wazuh-wui + run_as: false +`; + + fs.writeFileSync(WAZUH_DATA_CONFIG_APP_PATH, fileContent, 'utf8'); + + createDirectoryIfNotExists(PUBLIC_CUSTOM_ASSETS_PATH); + }); + + afterAll(() => { + // Remove /public/assets/custom directory. + execSync(`rm -rf ${PUBLIC_CUSTOM_ASSETS_PATH}`); + + // Remove the configuration file + fs.unlinkSync(WAZUH_DATA_CONFIG_APP_PATH); + }); + + it.each` + setting | expectedValue | responseStatusCode | responseBodyMessage + ${'customization.logo.unknown'} | ${''} | ${400} | ${'[request params.key]: types that failed validation:\n- [request params.key.0]: expected value to equal [customization.logo.app]\n- [request params.key.1]: expected value to equal [customization.logo.healthcheck]\n- [request params.key.2]: expected value to equal [customization.logo.reports]\n- [request params.key.3]: expected value to equal [customization.logo.sidebar]'} + ${'customization.logo.app'} | ${''} | ${200} | ${'All files were removed and the configuration file was updated.'} + ${'customization.logo.healthcheck'} | ${''} | ${200} | ${'All files were removed and the configuration file was updated.'} + ${'customization.logo.reports'} | ${''} | ${200} | ${'All files were removed and the configuration file was updated.'} + ${'customization.logo.sidebar'} | ${''} | ${200} | ${'All files were removed and the configuration file was updated.'} + `(`$setting - PUT /utils/configuration - $responseStatusCode`, async ({ responseBodyMessage, responseStatusCode, setting, expectedValue }) => { + + // If the setting is defined in the plugin + if (PLUGIN_SETTINGS[setting]) { + // Create the directory where the asset was stored. + createDirectoryIfNotExists(path.join(__dirname, '../../../', PLUGIN_SETTINGS[setting].options.file.store.relativePathFileSystem)); + + // Create a empty file + fs.writeFileSync(path.join(__dirname, '../../../', PLUGIN_SETTINGS[setting].options.file.store.relativePathFileSystem, `${PLUGIN_SETTINGS[setting].options.file.store.filename}.jpg`), '', 'utf8'); + }; + + const response = await supertest(innerServer.listener) + .delete(`/utils/configuration/files/${setting}`) + .expect(responseStatusCode); + + responseStatusCode === 200 && expect(response.body.data.updatedConfiguration[setting]).toBeDefined(); + responseStatusCode === 200 && expect(response.body.data.updatedConfiguration[setting]).toMatch(expectedValue); + responseStatusCode === 200 && expect(response.body.data.requiresRunningHealthCheck).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRunningHealthCheck)); + responseStatusCode === 200 && expect(response.body.data.requiresReloadingBrowserTab).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresReloadingBrowserTab)); + responseStatusCode === 200 && expect(response.body.data.requiresRestartingPluginPlatform).toBe(Boolean(PLUGIN_SETTINGS[setting].requiresRestartingPluginPlatform)); + responseBodyMessage && expect(response.body.message).toMatch(responseBodyMessage); + + // Check the file was deleted from the expected path of the file system. + if (response?.body?.data?.updatedConfiguration?.[setting]) { + const targetFilepath = path.join(__dirname, '../../../', PLUGIN_SETTINGS[setting].options.file.store.relativePathFileSystem, `${PLUGIN_SETTINGS[setting].options.file.store.filename}.*`); + const files = glob.sync(path.join(targetFilepath)); + expect(files).toHaveLength(0); + }; }); }); diff --git a/server/routes/wazuh-utils/wazuh-utils.ts b/server/routes/wazuh-utils/wazuh-utils.ts index 1e4032fd0d..26ec791f90 100644 --- a/server/routes/wazuh-utils/wazuh-utils.ts +++ b/server/routes/wazuh-utils/wazuh-utils.ts @@ -12,7 +12,7 @@ import { WazuhUtilsCtrl } from '../../controllers'; import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; -import { PLUGIN_SETTINGS } from '../../../common/constants'; +import { CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES, EpluginSettingType, PLUGIN_SETTINGS } from '../../../common/constants'; export function WazuhUtilsRoutes(router: IRouter) { const ctrl = new WazuhUtilsCtrl(); @@ -51,6 +51,48 @@ export function WazuhUtilsRoutes(router: IRouter) { async (context, request, response) => ctrl.updateConfigurationFile(context, request, response) ); + const pluginSettingsTypeFilepicker = Object.entries(PLUGIN_SETTINGS) + .filter(([_, {type, isConfigurableFromFile}]) => type === EpluginSettingType.filepicker && isConfigurableFromFile); + + const schemaPluginSettingsTypeFilepicker = schema.oneOf(pluginSettingsTypeFilepicker.map(([pluginSettingKey]) => schema.literal(pluginSettingKey))); + + // Upload an asset + router.put( + { + path: '/utils/configuration/files/{key}', + validate: { + params: schema.object({ + // key parameter should be a plugin setting of `filepicker` type + key: schemaPluginSettingsTypeFilepicker + }), + body: schema.object({ + // file: buffer + file: schema.buffer(), + }) + }, + options: { + body: { + maxBytes: CUSTOMIZATION_ENDPOINT_PAYLOAD_UPLOAD_CUSTOM_FILE_MAXIMUM_BYTES, + }, + } + }, + async (context, request, response) => ctrl.uploadFile(context, request, response) + ); + + // Remove an asset + router.delete( + { + path: '/utils/configuration/files/{key}', + validate: { + params: schema.object({ + // key parameter should be a plugin setting of `filepicker` type + key: schemaPluginSettingsTypeFilepicker + }) + } + }, + async (context, request, response) => ctrl.deleteFile(context, request, response) + ); + // Returns Wazuh app logs router.get( {