diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 35ed46b231..60726ffab1 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -430,6 +430,7 @@ export const CLEAR_INTERVAL = () => `Clear interval`; export const GET_GEO_LOCATION = () => `Get Geolocation`; export const WATCH_GEO_LOCATION = () => `Watch Geolocation`; export const STOP_WATCH_GEO_LOCATION = () => `Stop watching Geolocation`; +export const POST_MESSAGE = () => `Post message to a target window`; //js actions export const JS_ACTION_COPY_SUCCESS = (actionName: string, pageName: string) => diff --git a/app/client/src/components/editorComponents/ActionCreator/Fields.tsx b/app/client/src/components/editorComponents/ActionCreator/Fields.tsx index 675ea4e455..6861d0383c 100644 --- a/app/client/src/components/editorComponents/ActionCreator/Fields.tsx +++ b/app/client/src/components/editorComponents/ActionCreator/Fields.tsx @@ -232,6 +232,7 @@ export const ActionType = { getGeolocation: "appsmith.geolocation.getCurrentPosition", watchGeolocation: "appsmith.geolocation.watchPosition", stopWatchGeolocation: "appsmith.geolocation.clearWatch", + postMessage: "postMessageToTargetWindow", }; type ActionType = typeof ActionType[keyof typeof ActionType]; @@ -355,6 +356,8 @@ export enum FieldType { DELAY_FIELD = "DELAY_FIELD", ID_FIELD = "ID_FIELD", CLEAR_INTERVAL_ID_FIELD = "CLEAR_INTERVAL_ID_FIELD", + MESSAGE_FIELD = "MESSAGE_FIELD", + TARGET_ORIGIN_FIELD = "TARGET_ORIGIN_FIELD", } type FieldConfig = { @@ -622,6 +625,24 @@ const fieldConfigs: FieldConfigs = { }, view: ViewTypes.TEXT_VIEW, }, + [FieldType.MESSAGE_FIELD]: { + getter: (value: string) => { + return textGetter(value, 0); + }, + setter: (value: string, currentValue: string) => { + return textSetter(value, currentValue, 0); + }, + view: ViewTypes.TEXT_VIEW, + }, + [FieldType.TARGET_ORIGIN_FIELD]: { + getter: (value: string) => { + return textGetter(value, 1); + }, + setter: (value: string, currentValue: string) => { + return textSetter(value, currentValue, 1); + }, + view: ViewTypes.TEXT_VIEW, + }, }; function renderField(props: { @@ -793,6 +814,8 @@ function renderField(props: { case FieldType.DELAY_FIELD: case FieldType.ID_FIELD: case FieldType.CLEAR_INTERVAL_ID_FIELD: + case FieldType.MESSAGE_FIELD: + case FieldType.TARGET_ORIGIN_FIELD: let fieldLabel = ""; if (fieldType === FieldType.ALERT_TEXT_FIELD) { fieldLabel = "Message"; @@ -818,6 +841,10 @@ function renderField(props: { fieldLabel = "Id"; } else if (fieldType === FieldType.CLEAR_INTERVAL_ID_FIELD) { fieldLabel = "Id"; + } else if (fieldType === FieldType.MESSAGE_FIELD) { + fieldLabel = "Message"; + } else if (fieldType === FieldType.TARGET_ORIGIN_FIELD) { + fieldLabel = "Target origin"; } viewElement = (view as (props: TextViewProps) => JSX.Element)({ label: fieldLabel, diff --git a/app/client/src/components/editorComponents/ActionCreator/index.tsx b/app/client/src/components/editorComponents/ActionCreator/index.tsx index d4054f1683..b96224d489 100644 --- a/app/client/src/components/editorComponents/ActionCreator/index.tsx +++ b/app/client/src/components/editorComponents/ActionCreator/index.tsx @@ -49,6 +49,7 @@ import { NAVIGATE_TO, NO_ACTION, OPEN_MODAL, + POST_MESSAGE, RESET_WIDGET, SET_INTERVAL, SHOW_MESSAGE, @@ -125,6 +126,10 @@ const baseOptions: { label: string; value: string }[] = [ label: createMessage(STOP_WATCH_GEO_LOCATION), value: ActionType.stopWatchGeolocation, }, + { + label: createMessage(POST_MESSAGE), + value: ActionType.postMessage, + }, ]; const getBaseOptions = (featureFlags: FeatureFlags) => { @@ -373,6 +378,18 @@ function getFieldFromValue( field: FieldType.CALLBACK_FUNCTION_FIELD, }); } + + if (value.indexOf("postMessageToTargetWindow") !== -1) { + fields.push( + { + field: FieldType.MESSAGE_FIELD, + }, + { + field: FieldType.TARGET_ORIGIN_FIELD, + }, + ); + } + return fields; } diff --git a/app/client/src/entities/DataTree/actionTriggers.ts b/app/client/src/entities/DataTree/actionTriggers.ts index e898131740..d951a76fb6 100644 --- a/app/client/src/entities/DataTree/actionTriggers.ts +++ b/app/client/src/entities/DataTree/actionTriggers.ts @@ -18,6 +18,7 @@ export enum ActionTriggerType { WATCH_CURRENT_LOCATION = "WATCH_CURRENT_LOCATION", STOP_WATCHING_CURRENT_LOCATION = "STOP_WATCHING_CURRENT_LOCATION", CONFIRMATION_MODAL = "CONFIRMATION_MODAL", + POST_MESSAGE = "POST_MESSAGE", } export const ActionTriggerFunctionNames: Record = { @@ -37,6 +38,7 @@ export const ActionTriggerFunctionNames: Record = { [ActionTriggerType.WATCH_CURRENT_LOCATION]: "watchLocation", [ActionTriggerType.STOP_WATCHING_CURRENT_LOCATION]: "stopWatch", [ActionTriggerType.CONFIRMATION_MODAL]: "ConfirmationModal", + [ActionTriggerType.POST_MESSAGE]: "postMessageToTargetWindow", }; export type RunPluginActionDescription = { @@ -166,6 +168,14 @@ export type ConfirmationModal = { payload?: Record; }; +export type PostMessageDescription = { + type: ActionTriggerType.POST_MESSAGE; + payload: { + message: unknown; + targetOrigin: string; + }; +}; + export type ActionDescription = | RunPluginActionDescription | ClearPluginActionDescription @@ -182,4 +192,5 @@ export type ActionDescription = | GetCurrentLocationDescription | WatchCurrentLocationDescription | StopWatchingCurrentLocationDescription - | ConfirmationModal; + | ConfirmationModal + | PostMessageDescription; diff --git a/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts b/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts index 9d3991d2d7..cb6d1cb3a3 100644 --- a/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts +++ b/app/client/src/sagas/ActionExecution/ActionExecutionSagas.ts @@ -10,8 +10,8 @@ import { import * as log from "loglevel"; import { all, call, put, takeEvery, takeLatest } from "redux-saga/effects"; import { - evaluateArgumentSaga, evaluateAndExecuteDynamicTrigger, + evaluateArgumentSaga, evaluateSnippetSaga, setAppVersionOnWorkerSaga, } from "sagas/EvaluationsSaga"; @@ -36,12 +36,12 @@ import { logActionExecutionError, TriggerFailureError, UncaughtPromiseError, + UserCancelledActionExecutionError, } from "sagas/ActionExecution/errorUtils"; import { clearIntervalSaga, setIntervalSaga, } from "sagas/ActionExecution/SetIntervalSaga"; -import { UserCancelledActionExecutionError } from "sagas/ActionExecution/errorUtils"; import { getCurrentLocationSaga, stopWatchCurrentLocation, @@ -49,6 +49,7 @@ import { } from "sagas/ActionExecution/GetCurrentLocationSaga"; import { requestModalConfirmationSaga } from "sagas/UtilSagas"; import { ModalType } from "reducers/uiReducers/modalActionReducer"; +import { postMessageSaga } from "./PostMessageSaga"; export type TriggerMeta = { source?: TriggerSource; @@ -142,6 +143,9 @@ export function* executeActionTriggers( throw new UserCancelledActionExecutionError(); } break; + case ActionTriggerType.POST_MESSAGE: + yield call(postMessageSaga, trigger.payload, triggerMeta); + break; default: log.error("Trigger type unknown", trigger); throw Error("Trigger type unknown"); diff --git a/app/client/src/sagas/ActionExecution/PostMessage.test.ts b/app/client/src/sagas/ActionExecution/PostMessage.test.ts new file mode 100644 index 0000000000..fd43fc62c4 --- /dev/null +++ b/app/client/src/sagas/ActionExecution/PostMessage.test.ts @@ -0,0 +1,60 @@ +import { postMessageSaga, executePostMessage } from "./PostMessageSaga"; +import { spawn } from "redux-saga/effects"; +import { runSaga } from "redux-saga"; + +describe("PostMessageSaga", () => { + describe("postMessageSaga function", () => { + const generator = postMessageSaga( + { + message: "hello world", + targetOrigin: "https://dev.appsmith.com", + }, + {}, + ); + + it("executes postMessageSaga with the payload and trigger meta", () => { + expect(generator.next().value).toStrictEqual( + spawn( + executePostMessage, + { + message: "hello world", + targetOrigin: "https://dev.appsmith.com", + }, + {}, + ), + ); + }); + + it("should be done on next iteration", () => { + expect(generator.next().done).toBeTruthy(); + }); + }); + + describe("executePostMessage function", () => { + it("calls window.parent with message and target origin", () => { + const dispatched: any[] = []; + + const postMessage = jest.spyOn(window.parent, "postMessage"); + + runSaga( + { + dispatch: (action) => dispatched.push(action), + }, + executePostMessage, + { + message: "hello world", + targetOrigin: "https://dev.appsmith.com", + }, + {}, + ); + + expect(postMessage).toHaveBeenCalledWith( + "hello world", + "https://dev.appsmith.com", + undefined, + ); + + expect(dispatched).toEqual([]); + }); + }); +}); diff --git a/app/client/src/sagas/ActionExecution/PostMessageSaga.ts b/app/client/src/sagas/ActionExecution/PostMessageSaga.ts new file mode 100644 index 0000000000..222a16fa10 --- /dev/null +++ b/app/client/src/sagas/ActionExecution/PostMessageSaga.ts @@ -0,0 +1,39 @@ +import { spawn } from "redux-saga/effects"; +import { PostMessageDescription } from "../../entities/DataTree/actionTriggers"; +import { + logActionExecutionError, + TriggerFailureError, +} from "sagas/ActionExecution/errorUtils"; +import { TriggerMeta } from "./ActionExecutionSagas"; +import { isEmpty } from "lodash"; + +export function* postMessageSaga( + payload: PostMessageDescription["payload"], + triggerMeta: TriggerMeta, +) { + yield spawn(executePostMessage, payload, triggerMeta); +} + +export function* executePostMessage( + payload: PostMessageDescription["payload"], + triggerMeta: TriggerMeta, +) { + const { message, targetOrigin } = payload; + try { + if (targetOrigin === "*") { + throw new TriggerFailureError( + "Please enter a valid url as targetOrigin. Failing to provide a specific target discloses the data you send to any interested malicious site.", + ); + } else if (isEmpty(targetOrigin)) { + throw new TriggerFailureError("Please enter a target origin URL."); + } else { + window.parent.postMessage(message, targetOrigin, undefined); + } + } catch (error) { + logActionExecutionError( + (error as Error).message, + triggerMeta.source, + triggerMeta.triggerPropertyName, + ); + } +} diff --git a/app/client/src/utils/autocomplete/EntityDefinitions.ts b/app/client/src/utils/autocomplete/EntityDefinitions.ts index c304a07eea..b28d1fb38f 100644 --- a/app/client/src/utils/autocomplete/EntityDefinitions.ts +++ b/app/client/src/utils/autocomplete/EntityDefinitions.ts @@ -670,6 +670,11 @@ export const GLOBAL_FUNCTIONS = { "!doc": "Stop executing a setInterval with id", "!type": "fn(id: string) -> void", }, + postMessageToTargetWindow: { + "!doc": + "Establish cross-origin communication between Window objects/page and iframes", + "!type": "fn(message: unknown, targetOrigin: string)", + }, }; export const getPropsForJSActionEntity = ({ diff --git a/app/client/src/workers/Actions.test.ts b/app/client/src/workers/Actions.test.ts index f2e8b02263..87c74120ea 100644 --- a/app/client/src/workers/Actions.test.ts +++ b/app/client/src/workers/Actions.test.ts @@ -433,4 +433,162 @@ describe("Add functions", () => { ]), ); }); + + describe("Post message to target window works", () => { + const targetOrigin = "https://dev.appsmith.com/"; + + it("Post message with first argument (message) as a string", () => { + const message = "Hello world!"; + + expect( + dataTreeWithFunctions.postMessageToTargetWindow(message, targetOrigin), + ).toBe(undefined); + + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + message: "Hello world!", + targetOrigin: "https://dev.appsmith.com/", + }, + type: "POST_MESSAGE", + }), + ]), + ); + }); + + it("Post message with first argument (message) as undefined", () => { + const message = undefined; + + expect( + dataTreeWithFunctions.postMessageToTargetWindow(message, targetOrigin), + ).toBe(undefined); + + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + message: undefined, + targetOrigin: "https://dev.appsmith.com/", + }, + type: "POST_MESSAGE", + }), + ]), + ); + }); + + it("Post message with first argument (message) as null", () => { + const message = null; + + expect( + dataTreeWithFunctions.postMessageToTargetWindow(message, targetOrigin), + ).toBe(undefined); + + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + message: null, + targetOrigin: "https://dev.appsmith.com/", + }, + type: "POST_MESSAGE", + }), + ]), + ); + }); + + it("Post message with first argument (message) as a number", () => { + const message = 1826; + + expect( + dataTreeWithFunctions.postMessageToTargetWindow(message, targetOrigin), + ).toBe(undefined); + + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + message: 1826, + targetOrigin: "https://dev.appsmith.com/", + }, + type: "POST_MESSAGE", + }), + ]), + ); + }); + + it("Post message with first argument (message) as a boolean", () => { + const message = true; + + expect( + dataTreeWithFunctions.postMessageToTargetWindow(message, targetOrigin), + ).toBe(undefined); + + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + message: true, + targetOrigin: "https://dev.appsmith.com/", + }, + type: "POST_MESSAGE", + }), + ]), + ); + }); + + it("Post message with first argument (message) as an array", () => { + const message = [1, 2, 3, [1, 2, 3, [1, 2, 3]]]; + + expect( + dataTreeWithFunctions.postMessageToTargetWindow(message, targetOrigin), + ).toBe(undefined); + + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + message: [1, 2, 3, [1, 2, 3, [1, 2, 3]]], + targetOrigin: "https://dev.appsmith.com/", + }, + type: "POST_MESSAGE", + }), + ]), + ); + }); + + it("Post message with first argument (message) as an object", () => { + const message = { + key: 1, + status: "active", + person: { + name: "timothee chalamet", + }, + randomArr: [1, 2, 3], + }; + + expect( + dataTreeWithFunctions.postMessageToTargetWindow(message, targetOrigin), + ).toBe(undefined); + + expect(self.TRIGGER_COLLECTOR).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + payload: { + message: { + key: 1, + status: "active", + person: { + name: "timothee chalamet", + }, + randomArr: [1, 2, 3], + }, + targetOrigin: "https://dev.appsmith.com/", + }, + type: "POST_MESSAGE", + }), + ]), + ); + }); + }); }); diff --git a/app/client/src/workers/Actions.ts b/app/client/src/workers/Actions.ts index 164a01aa07..fdce74cb5b 100644 --- a/app/client/src/workers/Actions.ts +++ b/app/client/src/workers/Actions.ts @@ -261,6 +261,16 @@ const DATA_TREE_FUNCTIONS: Record< }; }, }, + postMessageToTargetWindow: function(message: unknown, targetOrigin: string) { + return { + type: ActionTriggerType.POST_MESSAGE, + payload: { + message, + targetOrigin, + }, + executionType: ExecutionType.TRIGGER, + }; + }, }; export const enhanceDataTreeWithFunctions = (