From bc51482632ace29a9c174a31cd34ec0c12a88d47 Mon Sep 17 00:00:00 2001 From: Antonio <34042064+Desvelao@users.noreply.github.com> Date: Fri, 28 Oct 2022 12:00:16 +0200 Subject: [PATCH] Upload file for `customization.logo.*` settings (#4504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): centralize the plugin settings Create the plugin setting schema Define the current plugin settings Remove refactored code * feat(settings): add setting services and replaced the references to constants * feat(settings): refactor the content of the default configuration file Use dynamically the definition of the plugin settings * feat(inputs): create new inputs components Add new hooks to manage when a input value or form has changed Add new inputs components * feat(configuration): refactor the form of Settings/Configuration Refactor Header, BottomBar, Configuration components Remove deprecated files * feat(settings): support updating multiple setting at the same time Changed the endpoint that updating the plugin setting to support multiple settings at the same time Refactor the getConfiguration service. Split the logic to: - Read the file and transform to JSON - Obfuscate the password key of the host configuration * feat: add validation to the plugin settings Create services to validate Add the validation to the plugin settings * feat(validation): add validation to the `PUT /utils/configuration` endpoint * feat(validation): add validation to the configuration form in `Settings/Configuration` * feat(validatio): remove no used import * clean: remove not used code * feat(settings): upload file for `customization.logo` settigs Add endpoints - `PUT /utils/configuration/files/{key}` - `DELETE /utils/configuration/files/{key}` Add plugin setting type: filepicker Add filepicker input form Display the customized image in `Settings/Configuration` Add button to remove the customized image * feat(settings): Add validation for extensions of files for `customization.logo.*` settings * fix: fixed category name in `Settings/Configuration` * fix(settings): Fix accessing to `validate` of undefined error * fix(settings): fixed error due to missing service * fix(settings): fixed problems setting a custom logo. Add logic to do request to the expected endpoint Add function to transform the filepicker input form * Made upload file mkdir recursive * Fixed configuration image preview ratios * Fixed logo aspect ratio in wz-menu * Fix file input Remove button error * fix(settings): refactor the form and inputs of `Settings/Configuration` to control the global state of the form * fix: add value transformation for the form inputs and output of fields changed * fix: Fixed some settings validation * fix(settings): fixed validation of literals * fix(settings): removed unused import * feat(settings): clean file picker inputs when there is a selected file and saving the configuration * fix(settings): get plugin setting description to display in Settings/Configuration * fix(settings): renamed properties related to transform the value of the input * feat(settings): add description to the plugin setting definition properties * fix(settings): fix getConfiguration service when the configuration file has no `hosts` entry * fix(settings): Fixed error when do changes of the `useForm` hook an rename methods of this * tests(settings): add test related to the plugin settings and its configuration from the UI * feat(settings): rename plugin setting options of type select to match its type * feat(settings): add plugin settings services and enhance the description of the plugin settings in default configuration file and UI * tests(input-form): update tests of InputForm component * test(configuration-file): add tests of the default configuration file * feat(settings): remove `extensions.mitre` plugin setting * test(settings): add test to validate the plugin setting when updating it through PUT /utils/configuration fix some plugin settings validation * feat(settings): add documentation to some setting services and test some of them * fix: fixed documentation of setting service * doc(settings): move the documentation of the plugin setting properties * fix(settings): rename some plugin setting properties because of request changes - Rename plugin setting properties: - `default` to `defaultValue` - `defaultHidden` to `defaultValueIfNotSet` - `configurableFile` to `isConfigurableFromFile` - configurableUI` to `isConfigurableFromUI` - `requireHealthCheck` to `requiresRunningHealthCheck` - `requireReload` to `requiresReloadingBrowserTab` - `requireRestart` to `requiresRestartingPluginPlatform` - Fix tests * tests: fix tests of InputForm component * fix: response properties when saving the configuration * fix(settings): fix validation plugin settings value in the UI * feat(settings): add validation of selected file size Frontend: - Validate the selected file size in the file picker Backend: - Validate the body payload. This is not the same than the file. So the file size should be lower than the total allowed * fix(settings): fix validation of numbers * fix(settings): fix validation of numbers * fix(settings): fix an issue when removing a file from a file picker that the form displayed there was some change. * fix(settings): fix error when deleting a custom image * feat(settings): format bytes to meaningful unit. Create service to format the bytes to meaningful unit. Add test to the service Replace similar method used in the frontend Add the validation to the settings related to files * test(settings): Add tests related to validation for the `useForm` hook and the `InputForm` component * test(settings): add test to upload and delete customization files Display configuration toast when removing a customization file if it is required * fix(settings): fix displaying toast to run the healthcheck when saving the configuration * feat(settings): add file size to the settings description * fix(settings): remove the selected files in the file pickers when clicks on the `Cancel changes` button * Added category sorting + description + docs link * Added settings sorting within their category * Fixed constant types definition * Checks if localCompare exists validation * fix(settings): fixed plugin setting description doesn't display the minimum number value when it is falsy (0) * fix(settings): fix setting type of `wazuh.monitoring.replicas` and limit the valid number for the number input * feat(settins): add plugin settings category description * fix(settings): fix a problem comparing the initial and current value for the plugin settings of the `number` type * fix(settings): fix wrong conflict resolution * fix(settings): fix typo in setting description * feat(settings): enhance the validation of plugin settings related to indices or index patterns taking in account the supported characters * feat(settings): add validation of setting values in the inputs * fix(tests): format tables of the tests * test(settings): add tests related to the `customization.logo.*` in the form inputs * Fix small typo * fix(settings): fix response when uploading custom files for `customization.logo.*` setting and fix URL in test * feat(upload-file): get the file extension from file buffer. - Add service to get the file extesion from file buffer - Add tests - Removed unnecessary `extension` field when uploading a file using `PUT /utils/configuration/files/{key}` * fix(settings): fix a typo in a toast related to modify the plugin settings from UI * Changed Custom Branding documentation link * Merge centralize plugin settings PR * Fix white-labeling documentation url * Code format * Delete unused imports * fix(settings): fix a problem with the useForm hook * fix(settings): refactor the settings validation function to a class and rename the file * feat(settings): add check for integer numbers and adapt the affected settings * Fix semi-colon error * changelog: add entries to changelog * fix(settings): fix some request changes - unused imports - change some toast texts * fix(settings): fix an unsupported attribute for the number inputs * fix(settings): fix a problem when removing the `customization.logo.reports` from Settings/Configuration * fix(settings): remove unused import * Change validation error for inputs that don't allow whitespaces Signed-off-by: Alex Ruiz Becerra * Update file-extension.ts Add `3c73` SVG file signature * feat(settings): change the layout of the filepicker input plus logo and button to remove the configuration * test: update snapshots * test: fix tests and update snapshots * changelog: add PR entry * changelog:fix PR entries * fix(settings): fix a problem with SVG images that are not displayed when these do not have `width` and `height` * fix: remove comment * Fix logo image cache on upload * Added a comment describing image versioning Signed-off-by: Alex Ruiz Becerra Co-authored-by: Federico Rodriguez Co-authored-by: Álex --- .gitignore | 3 + CHANGELOG.md | 3 +- common/constants.ts | 166 ++++++- common/plugin-settings.test.ts | 24 + common/services/file-extension.test.ts | 17 + common/services/file-extension.ts | 23 + common/services/file-size.test.ts | 20 + common/services/file-size.ts | 17 + common/services/settings-validator.ts | 433 ++++++++++-------- common/services/settings.ts | 8 + .../form/__snapshots__/index.test.tsx.snap | 133 ++++-- public/components/common/form/hooks.tsx | 9 +- public/components/common/form/index.test.tsx | 6 +- public/components/common/form/index.tsx | 30 +- .../common/form/input_filepicker.tsx | 20 + .../components/common/form/input_number.tsx | 3 +- public/components/common/form/types.ts | 1 + .../components/category/category.tsx | 89 +++- .../categories/components/show-toasts.tsx | 51 +++ .../settings/configuration/configuration.tsx | 128 ++++-- public/components/wz-menu/wz-menu.js | 13 +- public/components/wz-menu/wz-menu.scss | 13 +- .../management/components/upload-files.js | 8 +- public/react-services/wz-request.ts | 10 +- server/controllers/wazuh-utils/wazuh-utils.ts | 103 ++++- server/lib/filesystem.ts | 4 +- .../wazuh-utils/fixtures/fixture_file.txt | 0 .../fixtures/fixture_image_big.png | Bin 0 -> 1412434 bytes .../fixtures/fixture_image_small.jpg | Bin 0 -> 9024 bytes .../fixtures/fixture_image_small.png | Bin 0 -> 4366 bytes .../fixtures/fixture_image_small.svg | 14 + server/routes/wazuh-utils/wazuh-utils.test.ts | 188 +++++++- server/routes/wazuh-utils/wazuh-utils.ts | 44 +- 33 files changed, 1222 insertions(+), 359 deletions(-) create mode 100644 common/services/file-extension.test.ts create mode 100644 common/services/file-extension.ts create mode 100644 common/services/file-size.test.ts create mode 100644 common/services/file-size.ts create mode 100644 public/components/common/form/input_filepicker.tsx create mode 100644 public/components/settings/configuration/components/categories/components/show-toasts.tsx create mode 100644 server/routes/wazuh-utils/fixtures/fixture_file.txt create mode 100644 server/routes/wazuh-utils/fixtures/fixture_image_big.png create mode 100644 server/routes/wazuh-utils/fixtures/fixture_image_small.jpg create mode 100644 server/routes/wazuh-utils/fixtures/fixture_image_small.png create mode 100644 server/routes/wazuh-utils/fixtures/fixture_image_small.svg 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 f26498f027..2dcedf93b5 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 2572405d33..c86b990ff4 100644 --- a/public/components/common/form/__snapshots__/index.test.tsx.snap +++ b/public/components/common/form/__snapshots__/index.test.tsx.snap @@ -4,7 +4,7 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali
Test @@ -21,17 +21,25 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali class="euiFormRow__fieldWrapper" >
- +
+
+ +
+
@@ -43,7 +51,7 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali
Test @@ -60,22 +68,30 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali class="euiFormRow__fieldWrapper" >
- +
+
+ +
+
Validation error: string can not be empty
@@ -88,7 +104,7 @@ exports[`[component] InputForm Renders correctly to match the snapshot with vali
Test @@ -105,16 +121,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`] = `
@@ -150,7 +173,7 @@ exports[`[component] InputForm Renders correctly to match the snapshot: Input: e