diff --git a/src/all/background_page/model/entity/avatar/update/avatarUpdateEntity.js b/src/all/background_page/model/entity/avatar/update/avatarUpdateEntity.js index afda8e74..57fe8aad 100644 --- a/src/all/background_page/model/entity/avatar/update/avatarUpdateEntity.js +++ b/src/all/background_page/model/entity/avatar/update/avatarUpdateEntity.js @@ -13,7 +13,7 @@ */ import Entity from "passbolt-styleguide/src/shared/models/entity/abstract/entity"; import EntitySchema from "passbolt-styleguide/src/shared/models/entity/abstract/entitySchema"; -import b64ToBlob from "../../../../utils/format/base64"; +import Base64Utils from "../../../../utils/format/base64"; const ENTITY_NAME = 'AvatarUpdate'; @@ -47,7 +47,7 @@ class AvatarUpdateEntity extends Entity { const filename = avatarBase64UpdateDto.filename; const fileBase64 = avatarBase64UpdateDto.fileBase64; const mimeType = avatarBase64UpdateDto.mimeType; - const file = b64ToBlob(fileBase64, mimeType); + const file = Base64Utils.base64ToBlob(fileBase64, mimeType); const avatarUpdateDto = {file: file, filename: filename, mimeType: mimeType}; return new AvatarUpdateEntity(avatarUpdateDto); } diff --git a/src/all/background_page/utils/format/base64.js b/src/all/background_page/utils/format/base64.js index a9a4e107..bf6e59f3 100644 --- a/src/all/background_page/utils/format/base64.js +++ b/src/all/background_page/utils/format/base64.js @@ -1,33 +1,61 @@ /** - * Transforms a base 64 encoded file content into a file object. - * Useful when we need to transmit a file from the content code to the add-on code. - * @param string b64Data - * @param string contentType - * @param integer sliceSize - * @returns {*} + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.8.0 */ -function b64ToBlob(b64Data, contentType, sliceSize) { - contentType = contentType || ''; - sliceSize = sliceSize || 512; - const byteCharacters = atob(b64Data); - const byteArrays = []; +/** + * The class that deals with Passbolt to convert base64. + */ +class Base64Utils { + /** + * Transforms a base 64 encoded file content into a file object. + * Useful when we need to transmit a file from the content code to the add-on code. + * @param {string} b64Data The base64 data + * @param {string} contentType The content type + * @param {number} sliceSize The slice size + * @returns {*} + */ + static base64ToBlob(b64Data, contentType = "", sliceSize = 512) { + const byteCharacters = atob(b64Data); + const byteArrays = []; - for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { - const slice = byteCharacters.slice(offset, offset + sliceSize); + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); - const byteNumbers = new Array(slice.length); - for (let i = 0; i < slice.length; i++) { - byteNumbers[i] = slice.charCodeAt(i); - } + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } - const byteArray = new Uint8Array(byteNumbers); + const byteArray = new Uint8Array(byteNumbers); - byteArrays.push(byteArray); + byteArrays.push(byteArray); + } + + return new Blob(byteArrays, {type: contentType}); } - const blob = new Blob(byteArrays, {type: contentType}); - return blob; + /** + * Transforms a file object into a base 64 encoded file content. + * @param {Blob} blob + * @returns {Promise} + */ + static blobToBase64(blob) { + return new Promise(resolve => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + } } -export default b64ToBlob; +export default Base64Utils; diff --git a/src/all/background_page/utils/format/formDataUtils.js b/src/all/background_page/utils/format/formDataUtils.js new file mode 100644 index 00000000..887b214c --- /dev/null +++ b/src/all/background_page/utils/format/formDataUtils.js @@ -0,0 +1,93 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.8.0 + */ + +import Base64Utils from "./base64"; + +/** + * The class that deals with Passbolt to convert formData. + */ +class FormDataUtils { + /** + * Transform a form data to an array of object + * @param {FormData} formData The form data + * @return {Promise>} + */ + static async formDataToArray(formData) { + const formDataSerialized = []; + for (const [key, value] of formData.entries()) { + const formDataObject = { + key: key + }; + // BLOB in FormData is transformed into a File + if (value instanceof File) { + formDataObject.value = await Base64Utils.blobToBase64(value); + formDataObject.name = value.name; + formDataObject.type = FormDataUtils.TYPE_FILE; + } else { + formDataObject.value = value; + formDataObject.type = FormDataUtils.TYPE_SCALAR; + } + formDataSerialized.push(formDataObject); + } + return formDataSerialized; + } + + /** + * Transform an array of object to a form data + * @param {Array} array + * @return {FormData} + */ + static arrayToFormData(array) { + const formData = new FormData(); + array.forEach(data => { + if (data.type === FormDataUtils.TYPE_SCALAR) { + formData.append(data.key, data.value); + } else { + const base64UrlSplit = data.value.split(','); + const blobBase64 = base64UrlSplit[1]; + const mimeType = base64UrlSplit[0].split(':')[1].split(';')[0]; + const blob = Base64Utils.base64ToBlob(blobBase64, mimeType); + formData.append(data.key, blob, data.name); + } + }); + return formData; + } + + /** + * Get the type scalar + * @return {string} + */ + static get TYPE_SCALAR() { + return "SCALAR"; + } + + /** + * Get the type file + * @return {string} + */ + static get TYPE_FILE() { + return "FILE"; + } + + /** + * Get the type blob + * @return {string} + * @constructor + */ + static get TYPE_BLOB() { + return "FILE"; + } +} + +export default FormDataUtils; diff --git a/src/all/background_page/utils/format/formDataUtils.test.data.js b/src/all/background_page/utils/format/formDataUtils.test.data.js new file mode 100644 index 00000000..9ab21db5 --- /dev/null +++ b/src/all/background_page/utils/format/formDataUtils.test.data.js @@ -0,0 +1,49 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.8.0 + */ + +export const formDataString = () => { + const formDataBody = new FormData(); + formDataBody.append("prop1", "value 1"); + formDataBody.append("prop1", "value 2"); + return formDataBody; +}; + +export const formDataFile = () => { + const formDataBody = new FormData(); + const file1 = new File(['test'], "file 1", {type: 'image/png'}); + const file2 = new File(['test'], "file 2", {type: 'image/png'}); + formDataBody.append("file", file1, "file 1"); + formDataBody.append("file", file2, "file 2"); + return formDataBody; +}; + +export const formDataBlob = () => { + const formDataBody = new FormData(); + const blob1 = new Blob(['test'], {type: 'text/plain'}); + const blob2 = new Blob(['test'], {type: 'text/plain'}); + formDataBody.append("blob", blob1, "blob 1"); + formDataBody.append("blob", blob2, "blob 2"); + return formDataBody; +}; + +export const formDataMixed = () => { + const formDataBody = new FormData(); + formDataBody.append("prop1", "value 1"); + const file = new File(['test'], "file 1", {type: 'image/png'}); + formDataBody.append("file", file, "file 1"); + const blob = new Blob(['test'], {type: 'text/plain'}); + formDataBody.append("blob", blob, "blob 1"); + return formDataBody; +}; + diff --git a/src/all/background_page/utils/format/formDataUtils.test.js b/src/all/background_page/utils/format/formDataUtils.test.js new file mode 100644 index 00000000..26725364 --- /dev/null +++ b/src/all/background_page/utils/format/formDataUtils.test.js @@ -0,0 +1,105 @@ +/** + * Passbolt ~ Open source password manager for teams + * Copyright (c) Passbolt SA (https://www.passbolt.com) + * + * Licensed under GNU Affero General Public License version 3 of the or any later version. + * For full copyright and license information, please see the LICENSE.txt + * Redistributions of files must retain the above copyright notice. + * + * @copyright Copyright (c) Passbolt SA (https://www.passbolt.com) + * @license https://opensource.org/licenses/AGPL-3.0 AGPL License + * @link https://www.passbolt.com Passbolt(tm) + * @since 4.8.0 + */ +import {formDataMixed, formDataString} from "./formDataUtils.test.data"; +import FormDataUtils from "./formDataUtils"; +import {formDataBlob, formDataFile} from "./formDataUtils.test.data"; + +describe("FormDataUtils", () => { + beforeEach(() => { + jest.resetModules(); + }); + + describe("FormDataUtils::formDataToArray", () => { + it("Should create an array of scalar object", async() => { + expect.assertions(1); + // data mocked + const formData = formDataString(); + // process + const arrayObject = await FormDataUtils.formDataToArray(formData); + // expectations + const expectedArray = [ + {key: "prop1", value: "value 1", type: FormDataUtils.TYPE_SCALAR}, + {key: "prop1", value: "value 2", type: FormDataUtils.TYPE_SCALAR} + ]; + expect(arrayObject).toStrictEqual(expectedArray); + }); + + it("Should create an array of file object", async() => { + expect.assertions(1); + // data mocked + const formData = formDataFile(); + // process + const arrayObject = await FormDataUtils.formDataToArray(formData); + // expectations + const expectedArray = [ + {key: "file", value: "data:image/png;base64,dGVzdA==", name: "file 1", type: FormDataUtils.TYPE_FILE}, + {key: "file", value: "data:image/png;base64,dGVzdA==", name: "file 2", type: FormDataUtils.TYPE_FILE} + ]; + expect(arrayObject).toStrictEqual(expectedArray); + }); + + it("Should create an array of blob object", async() => { + expect.assertions(1); + // data mocked + const formData = formDataBlob(); + // process + const arrayObject = await FormDataUtils.formDataToArray(formData); + // expectations + const expectedArray = [ + {key: "blob", value: "data:text/plain;base64,dGVzdA==", name: "blob 1", type: FormDataUtils.TYPE_BLOB}, + {key: "blob", value: "data:text/plain;base64,dGVzdA==", name: "blob 2", type: FormDataUtils.TYPE_BLOB} + ]; + expect(arrayObject).toStrictEqual(expectedArray); + }); + + it("Should create an array of mixed object", async() => { + expect.assertions(1); + // data mocked + const formData = formDataMixed(); + // process + const arrayObject = await FormDataUtils.formDataToArray(formData); + // expectations + const expectedArray = [ + {key: "prop1", value: "value 1", type: FormDataUtils.TYPE_SCALAR}, + {key: "file", value: "data:image/png;base64,dGVzdA==", name: "file 1", type: FormDataUtils.TYPE_FILE}, + {key: "blob", value: "data:text/plain;base64,dGVzdA==", name: "blob 1", type: FormDataUtils.TYPE_BLOB} + ]; + expect(arrayObject).toStrictEqual(expectedArray); + }); + }); + + describe("FormDataUtils::arrayToFormData", () => { + it("should form the same formData string from the origin", async() => { + expect.assertions(1); + // data mocked + const formData = formDataString(); + // process + const arrayObject = await FormDataUtils.formDataToArray(formData); + const formDataReceived = FormDataUtils.arrayToFormData(arrayObject); + // expectations + expect(formData).toStrictEqual(formDataReceived); + }); + + it("should form the same formData mixed from the origin", async() => { + expect.assertions(1); + // data mocked + const formData = formDataMixed(); + // process + const arrayObject = await FormDataUtils.formDataToArray(formData); + const formDataReceived = FormDataUtils.arrayToFormData(arrayObject); + // expectations + expect(formData).toStrictEqual(formDataReceived); + }); + }); +}); diff --git a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js index 246f9c90..1ce1c40f 100644 --- a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js +++ b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.js @@ -13,12 +13,15 @@ */ import Validator from "validator"; +import FormDataUtils from "../../../../all/background_page/utils/format/formDataUtils"; export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN = "fetch-offscreen"; export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_RESPONSE_HANDLER = "service-worker-fetch-offscreen-response-handler"; export const SEND_MESSAGE_TARGET_FETCH_OFFSCREEN_POLLING_HANDLER = "service-worker-fetch-offscreen-polling-handler"; export const FETCH_OFFSCREEN_RESPONSE_TYPE_SUCCESS = "success"; export const FETCH_OFFSCREEN_RESPONSE_TYPE_ERROR = "error"; +export const FETCH_OFFSCREEN_DATA_TYPE_JSON = "JSON"; +export const FETCH_OFFSCREEN_DATA_TYPE_FORM_DATA = "FORM_DATA"; const POLLING_COUNTER_UPDATE_LOCK = "POLLING_COUNTER_UPDATE_LOCK"; const POLLING_PERIOD = 25_000; @@ -54,7 +57,8 @@ export default class FetchOffscreenService { return; } const {id, resource, options} = message?.data || {}; - + // Update the body to fit the data type to send (JSON or FORM DATA) + options.body = options.body.dataType === FETCH_OFFSCREEN_DATA_TYPE_JSON ? options.body.data : FormDataUtils.arrayToFormData(options.body.data); await FetchOffscreenService.increaseAwaitingRequests(); try { const response = await fetch(resource, options); diff --git a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js index 2e744aff..5ff1cd37 100644 --- a/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js +++ b/src/chrome-mv3/offscreens/service/network/fetchOffscreenService.test.data.js @@ -12,7 +12,7 @@ * @since 4.7.0 */ import {fetchOptionsHeaders} from "../../../serviceWorker/service/network/requestFetchOffscreenService.test.data"; -import {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} from "./fetchOffscreenService"; +import {FETCH_OFFSCREEN_DATA_TYPE_JSON, SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} from "./fetchOffscreenService"; export const defaultFetchMessage = message => ({ target: SEND_MESSAGE_TARGET_FETCH_OFFSCREEN, @@ -23,8 +23,11 @@ export const defaultFetchMessage = message => ({ credentials: "include", headers: fetchOptionsHeaders(), body: { - prop1: "value 1", - prop2: "value 2" + data: { + prop1: "value 1", + prop2: "value 2" + }, + dataType: FETCH_OFFSCREEN_DATA_TYPE_JSON } } }, diff --git a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js index 358e981d..25d76ea0 100644 --- a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js +++ b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.js @@ -11,6 +11,11 @@ * @link https://www.passbolt.com Passbolt(tm) * @since 4.7.0 */ +import FormDataUtils from "../../../../all/background_page/utils/format/formDataUtils"; +import { + FETCH_OFFSCREEN_DATA_TYPE_FORM_DATA, + FETCH_OFFSCREEN_DATA_TYPE_JSON +} from "../../../offscreens/service/network/fetchOffscreenService"; const {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} = require("../../../offscreens/service/network/fetchOffscreenService"); @@ -92,7 +97,7 @@ export class RequestFetchOffscreenService { RequestFetchOffscreenService.createIfNotExistOffscreenDocument); const offscreenFetchId = crypto.randomUUID(); - const offscreenFetchData = RequestFetchOffscreenService.buildOffscreenData(offscreenFetchId, resource, options); + const offscreenFetchData = await RequestFetchOffscreenService.buildOffscreenData(offscreenFetchId, resource, options); return new Promise((resolve, reject) => { // Stack the response listener callbacks. @@ -130,19 +135,21 @@ export class RequestFetchOffscreenService { * @param {object} fetchOptions The fetch options, similar to the native fetch option parameter. * @returns {object} */ - static buildOffscreenData(id, resource, fetchOptions = {}) { + static async buildOffscreenData(id, resource, fetchOptions = {}) { const options = JSON.parse(JSON.stringify(fetchOptions)); // Format FormData fetch options to allow its serialization. if (fetchOptions?.body instanceof FormData) { - const formDataValues = []; - for (const key of fetchOptions.body.keys()) { - formDataValues.push(`${encodeURIComponent(key)}=${encodeURIComponent(fetchOptions.body.get(key))}`); - } - options.body = formDataValues.join('&'); - // Ensure the request content type reflect the content of its body. - options.headers = options.headers ?? {}; - options.headers['Content-type'] = 'application/x-www-form-urlencoded'; + const formDataSerialized = await FormDataUtils.formDataToArray(fetchOptions.body); + options.body = { + data: formDataSerialized, + dataType: FETCH_OFFSCREEN_DATA_TYPE_FORM_DATA + }; + } else { + options.body = { + data: fetchOptions.body, + dataType: FETCH_OFFSCREEN_DATA_TYPE_JSON + }; } return {id, resource, options}; diff --git a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js index e878b496..e8eb1ed3 100644 --- a/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js +++ b/src/chrome-mv3/serviceWorker/service/network/requestFetchOffscreenService.test.js @@ -19,8 +19,12 @@ import { IS_FETCH_OFFSCREEN_PREFERRED_STORAGE_KEY, RequestFetchOffscreenService } from "./requestFetchOffscreenService"; -import {SEND_MESSAGE_TARGET_FETCH_OFFSCREEN} from "../../../offscreens/service/network/fetchOffscreenService"; +import { + FETCH_OFFSCREEN_DATA_TYPE_FORM_DATA, FETCH_OFFSCREEN_DATA_TYPE_JSON, + SEND_MESSAGE_TARGET_FETCH_OFFSCREEN +} from "../../../offscreens/service/network/fetchOffscreenService"; import {fetchOptionsWithBodyFormData, fetchOptionWithBodyData} from "./requestFetchOffscreenService.test.data"; +import FormDataUtils from "../../../../all/background_page/utils/format/formDataUtils"; beforeEach(() => { enableFetchMocks(); @@ -108,19 +112,24 @@ describe("RequestFetchOffscreenService", () => { const id = crypto.randomUUID(); const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; const options = fetchOptionWithBodyData(); - const offscreenData = RequestFetchOffscreenService.buildOffscreenData(id, resource, options); + const offscreenData = await RequestFetchOffscreenService.buildOffscreenData(id, resource, options); + options.body = { + data: options.body, + dataType: FETCH_OFFSCREEN_DATA_TYPE_JSON + } // Ensure body remains a form data after serialization. expect(offscreenData).toEqual({id, resource, options}); }); it("should ensure given fetch options body will not be altered", async() => { - expect.assertions(1); + expect.assertions(2); const id = crypto.randomUUID(); const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; const fetchOptions = fetchOptionsWithBodyFormData(); - RequestFetchOffscreenService.buildOffscreenData(id, resource, fetchOptions); + const offscreenData = await RequestFetchOffscreenService.buildOffscreenData(id, resource, fetchOptions); // Ensure body remains a form data after serialization. - expect(fetchOptions.body).toBeInstanceOf(FormData); + expect(offscreenData.options.body.data).toBeInstanceOf(Array); + expect(offscreenData.options.body.dataType).toStrictEqual(FETCH_OFFSCREEN_DATA_TYPE_FORM_DATA); }); it("should transform FormData body into serialized encoded url parameters", async() => { @@ -129,7 +138,7 @@ describe("RequestFetchOffscreenService", () => { const resource = "https://test.passbolt.com/passbolt-unit-test/test.json"; const options = fetchOptionsWithBodyFormData(); - const offscreenData = RequestFetchOffscreenService.buildOffscreenData(id, resource, options); + const offscreenData = await RequestFetchOffscreenService.buildOffscreenData(id, resource, options); // eslint-disable-next-line object-shorthand const expectedOffscreenMessageData = { id, @@ -138,9 +147,11 @@ describe("RequestFetchOffscreenService", () => { ...options, headers: { ...options.headers, - "Content-type": "application/x-www-form-urlencoded" // ensure the content type reflect the body type parameter. }, - body: "prop1=value%201&prop1=value%201" // ensure the body is serialized as url encoded parameter + body: { + data: [{key: "prop1", value: "value 1", type: FormDataUtils.TYPE_SCALAR}, {key: "prop1", value: "value 2", type: FormDataUtils.TYPE_SCALAR}], + dataType: FETCH_OFFSCREEN_DATA_TYPE_FORM_DATA + } // ensure the body is serialized as url encoded parameter } }; expect(offscreenData).toEqual(expectedOffscreenMessageData); @@ -172,9 +183,14 @@ describe("RequestFetchOffscreenService", () => { ...message.data.options, headers: { ...message.data.options.headers, - "Content-type": "application/x-www-form-urlencoded" // ensure the content type reflect the body type parameter. }, - body: "prop1=value%201&prop1=value%201" // ensure the body is serialized as url encoded parameter + body: { + data: [ + {key: "prop1", value: "value 1", type: FormDataUtils.TYPE_SCALAR}, + {key: "prop1", value: "value 2", type: FormDataUtils.TYPE_SCALAR} + ], + dataType: FETCH_OFFSCREEN_DATA_TYPE_FORM_DATA + }, // ensure the body is serialized as url encoded parameter } }, };