Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Gleap.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,8 @@ class Gleap {
*/
static setApiUrl(apiUrl) {
GleapSession.getInstance().apiUrl = apiUrl;
// The SDK's own calls (with the session credential) stay out of the network logs.
GleapNetworkIntercepter.getInstance().setSdkApiUrl(apiUrl);
}

/**
Expand Down
7 changes: 4 additions & 3 deletions src/GleapAgentConversation.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GleapSession } from './Gleap';
import { bootstrapGleapFrame } from './GleapHelper';
import { frameTargetOrigin } from './GleapMessageSecurity';
import { GLEAP_DEFAULT_REGION, GLEAP_REGIONS } from './GleapRegions';

let agentConversationUrl = 'https://outboundmedia.gleap.io/agent-conversation';
Expand Down Expand Up @@ -141,23 +142,23 @@ export const registerGleapAgentComponents = () => {

this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-data', data: config }),
'*'
frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}

sendMessage(content) {
if (!this._iframe?.contentWindow || !content?.trim()) return;
this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-send-message', data: { content } }),
'*'
frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}

clearConversation() {
if (!this._iframe?.contentWindow) return;
this._iframe.contentWindow.postMessage(
JSON.stringify({ type: 'agent-conversation', name: 'agent-conv-clear', data: {} }),
'*'
frameTargetOrigin(this._iframe, this._agentConvUrl)
);
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/GleapAiChatbarManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import GleapAgentToolManager from './GleapAgentToolManager';
import GleapThemeManager from './GleapThemeManager';
import { applyGleapCSPNonce, bootstrapGleapFrame, runFunctionWhenDomIsReady } from './GleapHelper';
import { frameTargetOrigin } from './GleapMessageSecurity';
import { GLEAP_DEFAULT_REGION, GLEAP_REGIONS } from './GleapRegions';

export default class GleapAiChatbarManager {
Expand Down Expand Up @@ -160,7 +161,10 @@ export default class GleapAiChatbarManager {
this.pendingMessages.push(message);
return;
}
this.chatbarFrame.contentWindow.postMessage(JSON.stringify(message), '*');
this.chatbarFrame.contentWindow.postMessage(
JSON.stringify(message),
frameTargetOrigin(this.chatbarFrame, this.chatbarUrl)
);
} catch (e) {}
}

Expand Down
3 changes: 2 additions & 1 deletion src/GleapBannerManager.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Gleap, { GleapFrameManager } from './Gleap';
import { isOutboundActionBlocked } from './GleapCaptureSettings';
import { bootstrapGleapFrame } from './GleapHelper';
import { originMatchesUrl } from './GleapMessageSecurity';

const clipsOverflow = (style) => {
if (!style) {
Expand Down Expand Up @@ -51,7 +52,7 @@ export default class GleapBannerManager {
? this.bannerContainer.querySelector('.gleap-b-frame')
: null;
const sourceMatches = bannerFrame && event.source === bannerFrame.contentWindow;
const originMatches = this.bannerUrl?.includes(event.origin);
const originMatches = originMatchesUrl(event.origin, this.bannerUrl);
if (!sourceMatches && !originMatches) {
return;
}
Expand Down
27 changes: 26 additions & 1 deletion src/GleapBannerManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
*/
import GleapBannerManager, { bodyClipsInlineBanner } from './GleapBannerManager';

const mockUrlHandler = jest.fn();
jest.mock('./Gleap', () => ({
__esModule: true,
default: {},
GleapFrameManager: { getInstance: jest.fn(() => ({ urlHandler: jest.fn() })) },
GleapFrameManager: { getInstance: jest.fn(() => ({ urlHandler: mockUrlHandler })) },
}));

jest.mock('./GleapHelper', () => ({
Expand Down Expand Up @@ -90,3 +91,27 @@ describe('GleapBannerManager inline clip layout', () => {
expect(document.body.classList.contains('gleap-b-clip')).toBe(false);
});
});

describe('GleapBannerManager message origin', () => {
const manager = GleapBannerManager.getInstance();
const post = (origin) =>
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'BANNER', name: 'open-url', data: 'https://example.com' }),
origin,
})
);

beforeEach(() => mockUrlHandler.mockClear());

test('accepts the exact banner origin', () => {
post(new URL(manager.bannerUrl).origin);
expect(mockUrlHandler).toHaveBeenCalledWith('https://example.com', false);
});

test('ignores a look-alike origin that is a prefix of the banner URL', () => {
post('https://outboundmedia.gl');
post('https://outboundmedia.gleap');
expect(mockUrlHandler).not.toHaveBeenCalled();
});
});
20 changes: 13 additions & 7 deletions src/GleapCaptureApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,17 +243,23 @@ const entryTime = (entry) => {
return typeof value === 'string' || typeof value === 'number' ? new Date(value).getTime() : NaN;
};

const withinWindow = (entries, from, to) => {
// [from, to] wall times a recording was paused (to = 0: paused until the end).
const inPause = (time, pauses) =>
pauses.some((pause) => Array.isArray(pause) && time >= pause[0] && (!pause[1] || time < pause[1]));

const withinWindow = (entries, from, to, pauses) => {
if (!Array.isArray(entries)) {
return [];
}
if (!from) {
return entries.slice();
}
const paused = Array.isArray(pauses) ? pauses : [];
return entries.filter((entry) => {
const time = entryTime(entry);
// Entries without a usable date stay in.
return isNaN(time) || (time >= from && time <= to);
// Paused stretches stay private: Pause is for logging in or paying.
return isNaN(time) || (time >= from && time <= to && !inPause(time, paused));
});
};

Expand All @@ -269,8 +275,8 @@ const webReplaysEnabled = () => {
/**
* The log bundle for /logs (contract §9): the data a bug report carries, from the same collectors,
* limited to what `include` asks for. Never takes a screenshot. With a recording window only the
* entries of that window (and a short lead-in) are included.
* @param {{include?: object, windowStart?: number, windowEnd?: number, deviceId?: string}} options
* entries of that window (and a short lead-in) are included, without the stretches it was paused.
* @param {{include?: object, windowStart?: number, windowEnd?: number, pauses?: Array<[number, number]>, deviceId?: string}} options
*/
export const buildLogsBundle = (options = {}) => {
const include = normalizeInclude(options.include);
Expand All @@ -286,10 +292,10 @@ export const buildLogsBundle = (options = {}) => {
};

if (include.consoleLog) {
collect('consoleLog', () => withinWindow(GleapConsoleLogManager.getInstance().getLogs(), filterFrom, windowEnd));
collect('consoleLog', () => withinWindow(GleapConsoleLogManager.getInstance().getLogs(), filterFrom, windowEnd, options.pauses));
}
if (include.networkLogs) {
collect('networkLogs', () => withinWindow(GleapNetworkIntercepter.getInstance().getRequests(), filterFrom, windowEnd));
collect('networkLogs', () => withinWindow(GleapNetworkIntercepter.getInstance().getRequests(), filterFrom, windowEnd, options.pauses));
}
if (include.customData) {
collect('customData', () => GleapCustomDataManager.getInstance().getCustomData());
Expand All @@ -298,7 +304,7 @@ export const buildLogsBundle = (options = {}) => {
collect('metaData', () => GleapMetaDataManager.getInstance().getMetaData());
}
if (include.customEventLog) {
collect('customEventLog', () => withinWindow(GleapStreamedEvent.getInstance().getEventArray(), filterFrom, windowEnd));
collect('customEventLog', () => withinWindow(GleapStreamedEvent.getInstance().getEventArray(), filterFrom, windowEnd, options.pauses));
}
if (include.replays && webReplaysEnabled()) {
try {
Expand Down
32 changes: 32 additions & 0 deletions src/GleapCaptureLogs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ describe('log bundle', () => {
expect(bundle.windowEnd).toBe(iso(60000));
});

test('a paused stretch of the recording sends none of its entries', () => {
const bundle = buildLogsBundle({ windowStart: 50000, windowEnd: 60000, pauses: [[51500, 53000]] });
expect(bundle.consoleLog.map((entry) => entry.log)).toEqual(['during']);
expect(bundle.networkLogs).toEqual([]);
expect(bundle.customEventLog.map((entry) => entry.name)).toEqual(['pageView']);
});

test('encoded in slices to valid JSON: the replay packed, data that cannot be serialized left out', async () => {
const bundle = buildLogsBundle({ include: { replays: true } });
const cyclic = { plan: 'pro' };
Expand Down Expand Up @@ -207,6 +214,31 @@ describe('log requests from the websocket', () => {
expect(api.postCaptureLogs).toHaveBeenCalledTimes(1);
});

test("a screenshot's logs go only once the Messenger saved it: never for a cancel or a skip", async () => {
const image = { dataUrl: 'data:image/jpeg;base64,AA==', width: 1, height: 1, method: 'dom' };
const screenshot = (requestId) => {
const session = { requestId, kind: 'screenshot', options: { attachLogs: true }, phase: 'capturing', resumed: false };
manager.session = session;
manager.onScreenshotReady(session, image, { consoleLog: [{ log: requestId }] });
};

screenshot('cr-9');
await flush();
expect(api.postCaptureLogs).not.toHaveBeenCalled();
manager.onCaptureDone({ requestId: 'cr-9', sent: true });
await flush();
expect(api.postCaptureLogs).toHaveBeenCalledTimes(1);
expect(api.postCaptureLogs.mock.calls[0][0]).toBe('cr-9');

screenshot('cr-10');
manager.onCaptureCancel({ requestId: 'cr-10' });
manager.onCaptureDone({ requestId: 'cr-10', sent: true });
screenshot('cr-11');
manager.onCaptureDone({ requestId: 'cr-11', sent: false });
await flush();
expect(api.postCaptureLogs).toHaveBeenCalledTimes(1);
});

test('ignores other kinds, expired requests and requests that are already final', async () => {
manager.handleServerRequest({ id: 'cr-5', kind: 'screenshot' });
manager.handleServerRequest({ id: 'cr-6', kind: 'logs', expiresAt: iso(Date.now() - 1000) });
Expand Down
39 changes: 26 additions & 13 deletions src/GleapCaptureManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,12 @@ export default class GleapCaptureManager {
onCaptureDone(data) {
const session = this.session;
if (session && (!data.requestId || data.requestId === session.requestId)) {
// The screenshot's logs go only once the Messenger saved it (`sent: false`: skipped, failed or
// answered elsewhere; older Messengers don't say and still get them).
if (session.pendingLogs && data.sent !== false) {
this.postLogs(session.requestId, session.pendingLogs);
}
session.pendingLogs = null;
this.endSession(session, { restore: true });
}
this.setEditorOpen(false);
Expand Down Expand Up @@ -760,9 +766,9 @@ export default class GleapCaptureManager {
frameManager.setCaptureHidden(false, true);
}

if (logsBundle) {
this.postLogs(session.requestId, logsBundle);
}
// Held until the customer sends it (capture-done): a cancelled screenshot sends no logs, and a
// retake replaces them.
session.pendingLogs = logsBundle || null;
}

postLogs(requestId, bundle) {
Expand Down Expand Up @@ -992,6 +998,10 @@ export default class GleapCaptureManager {
this.liftVeil(session);
session.recording = null;
session.result = result;
// When it was paused: the logs leave those stretches out (Pause is for logging in or paying).
session.pauses = session.timeline
? session.timeline.pauses.map((pause) => [pause[0], pause[1] || result.endedAt])
: [];
session.timelineEntries = session.timeline ? session.timeline.stop(result.endedAt) : [];
const interactions = session.timeline ? session.timeline.interactions : 0;
session.timeline = null;
Expand All @@ -1015,6 +1025,7 @@ export default class GleapCaptureManager {
session.result = null;
session.uploadedUrl = null;
session.timelineEntries = null;
session.pauses = null;
session.phase = 'bar';
session.interrupted = false;
this.renderBar(session);
Expand All @@ -1032,18 +1043,17 @@ export default class GleapCaptureManager {
this.ui.setUploading(0);
this.sendState(session.requestId, 'uploading', { progress: 0 });

// Logs for the recording window go along (in parallel with the upload).
// Logs for the recording window (without its paused stretches), sent once the recording is saved.
let logsBundle = null;
if (this.attachesLogs(session)) {
try {
this.postLogs(
session.requestId,
buildLogsBundle({
include: session.options.include,
windowStart: result.startedAt,
windowEnd: result.endedAt,
deviceId: this.getDeviceId(),
})
);
logsBundle = buildLogsBundle({
include: session.options.include,
windowStart: result.startedAt,
windowEnd: result.endedAt,
pauses: session.pauses || [],
deviceId: this.getDeviceId(),
});
} catch (exp) {}
}

Expand Down Expand Up @@ -1103,6 +1113,9 @@ export default class GleapCaptureManager {
return;
}
if (isOk(response.status)) {
if (logsBundle) {
this.postLogs(session.requestId, logsBundle);
}
// What the card shows (and where its flight starts), read before the review closes.
const poster = this.ui ? this.ui.getPosterDataUrl() : null;
const from = this.ui ? this.ui.getPreviewRect() : null;
Expand Down
37 changes: 25 additions & 12 deletions src/GleapFrameManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import GleapAgentToolManager from './GleapAgentToolManager';
import { getCaptureCapabilities } from './GleapCaptureSettings';
import { forwardSurveyAnalyticsEvent } from './GleapSurveyAnalyticsForwarder';
import { bootstrapGleapFrame, loadFromGleapCache, runFunctionWhenDomIsReady, saveToGleapCache } from './GleapHelper';
import { frameTargetOrigin, isSafeNavigationUrl, isScriptUrl } from './GleapMessageSecurity';
import { widgetExpandMinWidth, widgetLoaderMarkup, widgetMaxHeight } from './UI';

export default class GleapFrameManager {
Expand Down Expand Up @@ -61,16 +62,16 @@ export default class GleapFrameManager {
// ...and the survey's surface colour + light/dark (survey-theme), null = the widget's colours.
surveySurface = null;
surveyDark = null;
// Default link handler: http(s), mailto, tel and relative links only — never
// `javascript:` (it would run in the host page). New tabs get no opener.
urlHandler = function (url, newTab) {
if (url && url.length > 0) {
if (newTab) {
const newWindow = window.open(url, '_blank');
if (newWindow) {
newWindow.focus();
}
} else {
window.location.href = url;
}
if (!isSafeNavigationUrl(url)) {
return;
}
if (newTab) {
window.open(url, '_blank', 'noopener,noreferrer');
} else {
window.location.href = url;
}
};

Expand Down Expand Up @@ -120,7 +121,16 @@ export default class GleapFrameManager {
}

setUrlHandler(handler) {
this.urlHandler = handler;
if (typeof handler !== 'function') {
return;
}
// A custom handler may open app deep links, but never script URLs.
this.urlHandler = (url, newTab) => {
if (isScriptUrl(url)) {
return;
}
return handler(url, newTab);
};
}

isSurvey() {
Expand Down Expand Up @@ -1023,7 +1033,10 @@ export default class GleapFrameManager {
try {
this.gleapFrame = document.querySelector('.gleap-frame');
if (this.comReady && this.gleapFrame && this.gleapFrame.contentWindow) {
this.gleapFrame.contentWindow.postMessage(JSON.stringify(data), '*');
this.gleapFrame.contentWindow.postMessage(
JSON.stringify(data),
frameTargetOrigin(this.gleapFrame, this.frameUrl)
);
} else {
if (queue) {
this.queue.push(data);
Expand Down Expand Up @@ -1420,7 +1433,7 @@ export default class GleapFrameManager {
name: 'collect-ticket-data',
data: ticketData,
}),
'*'
frameTargetOrigin(targetFrame, this.frameUrl)
);
}
} catch (e) {}
Expand Down
Loading
Loading