diff --git a/src/webview/js/InboundEventLogger.js b/src/webview/js/InboundEventLogger.js new file mode 100644 index 00000000000..26aa4770e61 --- /dev/null +++ b/src/webview/js/InboundEventLogger.js @@ -0,0 +1,159 @@ +/* @flow strict-local */ +import type { + WebViewUpdateEvent as WebViewInboundEvent, + WebViewUpdateEventContent as WebViewInboundEventContent, + WebViewUpdateEventFetching as WebViewInboundEventFetching, + WebViewUpdateEventTyping as WebViewInboundEventTyping, + WebViewUpdateEventReady as WebViewInboundEventReady, + WebViewUpdateEventMessagesRead as WebViewInboundEventMessagesRead, +} from '../webViewHandleUpdates'; +import type { JSONable } from '../../utils/jsonable'; +import sendMessage from './sendMessage'; +import { ensureUnreachable } from '../../types'; + +// TODO: Make exact (see note in jsonable.js). +type Scrub = { [key: $Keys]: JSONable }; + +type ScrubbedInboundEvent = + | Scrub + | Scrub + | Scrub + | Scrub + | Scrub; + +type ScrubbedInboundEventItem = {| + timestamp: number, + type: 'inbound', + scrubbedEvent: ScrubbedInboundEvent, +|}; + +/** + * Grab interesting but not privacy-sensitive message-loading state. + * + * Takes the "content" from an inbound WebView event, an HTML string, + * and returns the opening div#message-loading tag, so we know whether + * it's visible. + */ +const placeholdersDivTagFromContent = (content: string): string | null => { + const match = new RegExp('
').exec(content); + return match !== null ? match[0] : null; +}; + +export default class InboundEventLogger { + _captureStartTime: number | void; + _captureEndTime: number | void; + _isCapturing: boolean; + _capturedInboundEventItems: ScrubbedInboundEventItem[]; + + /** + * Minimally transform an inbound event to remove sensitive data. + */ + static scrubInboundEvent(event: WebViewInboundEvent): ScrubbedInboundEvent { + // Don't spread the event (e.g., `...event`); instead, rebuild it. + // That way, a new property, when added, won't automatically be + // sent to Sentry un-scrubbed. + switch (event.type) { + case 'content': { + return { + type: event.type, + scrollMessageId: event.scrollMessageId, + auth: 'redacted', + content: placeholdersDivTagFromContent(event.content), + updateStrategy: event.updateStrategy, + }; + } + case 'fetching': { + return { + type: event.type, + showMessagePlaceholders: event.showMessagePlaceholders, + fetchingOlder: event.fetchingOlder, + fetchingNewer: event.fetchingNewer, + }; + } + case 'typing': { + return { + type: event.type, + // Empty if no one is typing; otherwise, has avatar URLs. + content: event.content !== '', + }; + } + case 'ready': { + return { + type: event.type, + }; + } + case 'read': { + return { + type: event.type, + messageIds: event.messageIds, + }; + } + default: { + ensureUnreachable(event); + return { + type: event.type, + }; + } + } + } + + constructor() { + this._isCapturing = false; + this._capturedInboundEventItems = []; + } + + startCapturing() { + if (this._isCapturing) { + throw new Error('InboundEventLogger: Tried to call startCapturing while already capturing.'); + } else if (this._capturedInboundEventItems.length > 0 || this._captureEndTime !== undefined) { + throw new Error('InboundEventLogger: Tried to call startCapturing before resetting.'); + } + this._isCapturing = true; + this._captureStartTime = Date.now(); + } + + stopCapturing() { + if (!this._isCapturing) { + throw new Error('InboundEventLogger: Tried to call stopCapturing while not capturing.'); + } + this._isCapturing = false; + this._captureEndTime = Date.now(); + } + + send() { + if (this._isCapturing) { + throw new Error('InboundEventLogger: Tried to send captured events while still capturing.'); + } + sendMessage({ + type: 'warn', + details: { + startTime: this._captureStartTime ?? null, + endTime: this._captureEndTime ?? null, + inboundEventItems: this._capturedInboundEventItems, + }, + }); + } + + reset() { + this._captureStartTime = undefined; + this._captureEndTime = undefined; + this._capturedInboundEventItems = []; + this._isCapturing = false; + } + + maybeCaptureInboundEvent(event: WebViewInboundEvent) { + if (this._isCapturing) { + const item = { + type: 'inbound', + timestamp: Date.now(), + // Scrubbing up front, rather than just before sending, means + // it might be a waste of work -- we may never send. But it's + // not a *ton* of work, and it's currently the case that + // scrubbed events are much lighter than unscrubbed ones + // (unscrubbed events can have very long `content` strings). + scrubbedEvent: InboundEventLogger.scrubInboundEvent(event), + }; + this._capturedInboundEventItems.push(item); + } + } +} diff --git a/src/webview/js/generatedEs3.js b/src/webview/js/generatedEs3.js index 41845ad60b9..76c20821fd3 100644 --- a/src/webview/js/generatedEs3.js +++ b/src/webview/js/generatedEs3.js @@ -14,91 +14,6 @@ export default ` var compiledWebviewJs = (function (exports) { 'use strict'; - function _defineProperty(obj, key, value) { - if (key in obj) { - Object.defineProperty(obj, key, { - value: value, - enumerable: true, - configurable: true, - writable: true - }); - } else { - obj[key] = value; - } - - return obj; - } - - function ownKeys(object, enumerableOnly) { - var keys = Object.keys(object); - - if (Object.getOwnPropertySymbols) { - var symbols = Object.getOwnPropertySymbols(object); - if (enumerableOnly) symbols = symbols.filter(function (sym) { - return Object.getOwnPropertyDescriptor(object, sym).enumerable; - }); - keys.push.apply(keys, symbols); - } - - return keys; - } - - function _objectSpread2(target) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i] != null ? arguments[i] : {}; - - if (i % 2) { - ownKeys(Object(source), true).forEach(function (key) { - _defineProperty(target, key, source[key]); - }); - } else if (Object.getOwnPropertyDescriptors) { - Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); - } else { - ownKeys(Object(source)).forEach(function (key) { - Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); - }); - } - } - - return target; - } - - function _objectWithoutPropertiesLoose(source, excluded) { - if (source == null) return {}; - var target = {}; - var sourceKeys = Object.keys(source); - var key, i; - - for (i = 0; i < sourceKeys.length; i++) { - key = sourceKeys[i]; - if (excluded.indexOf(key) >= 0) continue; - target[key] = source[key]; - } - - return target; - } - - function _objectWithoutProperties(source, excluded) { - if (source == null) return {}; - - var target = _objectWithoutPropertiesLoose(source, excluded); - - var key, i; - - if (Object.getOwnPropertySymbols) { - var sourceSymbolKeys = Object.getOwnPropertySymbols(source); - - for (i = 0; i < sourceSymbolKeys.length; i++) { - key = sourceSymbolKeys[i]; - if (excluded.indexOf(key) >= 0) continue; - if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; - target[key] = source[key]; - } - } - - return target; - } - var inlineApiRoutes = ['^/user_uploads/', '^/thumbnail$', '^/avatar/'].map(function (r) { return new RegExp(r); }); @@ -131,6 +46,169 @@ var compiledWebviewJs = (function (exports) { }); }; + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + return Constructor; + } + + var sendMessage = (function (msg) { + window.ReactNativeWebView.postMessage(JSON.stringify(msg)); + }); + + var placeholdersDivTagFromContent = function placeholdersDivTagFromContent(content) { + var match = new RegExp('
').exec(content); + return match !== null ? match[0] : null; + }; + + var InboundEventLogger = function () { + _createClass(InboundEventLogger, null, [{ + key: "scrubInboundEvent", + value: function scrubInboundEvent(event) { + switch (event.type) { + case 'content': + { + return { + type: event.type, + scrollMessageId: event.scrollMessageId, + auth: 'redacted', + content: placeholdersDivTagFromContent(event.content), + updateStrategy: event.updateStrategy + }; + } + + case 'fetching': + { + return { + type: event.type, + showMessagePlaceholders: event.showMessagePlaceholders, + fetchingOlder: event.fetchingOlder, + fetchingNewer: event.fetchingNewer + }; + } + + case 'typing': + { + return { + type: event.type, + content: event.content !== '' + }; + } + + case 'ready': + { + return { + type: event.type + }; + } + + case 'read': + { + return { + type: event.type, + messageIds: event.messageIds + }; + } + + default: + { + return { + type: event.type + }; + } + } + } + }]); + + function InboundEventLogger() { + _classCallCheck(this, InboundEventLogger); + + this._isCapturing = false; + this._capturedInboundEventItems = []; + } + + _createClass(InboundEventLogger, [{ + key: "startCapturing", + value: function startCapturing() { + if (this._isCapturing) { + throw new Error('InboundEventLogger: Tried to call startCapturing while already capturing.'); + } else if (this._capturedInboundEventItems.length > 0 || this._captureEndTime !== undefined) { + throw new Error('InboundEventLogger: Tried to call startCapturing before resetting.'); + } + + this._isCapturing = true; + this._captureStartTime = Date.now(); + } + }, { + key: "stopCapturing", + value: function stopCapturing() { + if (!this._isCapturing) { + throw new Error('InboundEventLogger: Tried to call stopCapturing while not capturing.'); + } + + this._isCapturing = false; + this._captureEndTime = Date.now(); + } + }, { + key: "send", + value: function send() { + var _this$_captureStartTi, _this$_captureEndTime; + + if (this._isCapturing) { + throw new Error('InboundEventLogger: Tried to send captured events while still capturing.'); + } + + sendMessage({ + type: 'warn', + details: { + startTime: (_this$_captureStartTi = this._captureStartTime) !== null && _this$_captureStartTi !== void 0 ? _this$_captureStartTi : null, + endTime: (_this$_captureEndTime = this._captureEndTime) !== null && _this$_captureEndTime !== void 0 ? _this$_captureEndTime : null, + inboundEventItems: this._capturedInboundEventItems + } + }); + } + }, { + key: "reset", + value: function reset() { + this._captureStartTime = undefined; + this._captureEndTime = undefined; + this._capturedInboundEventItems = []; + this._isCapturing = false; + } + }, { + key: "maybeCaptureInboundEvent", + value: function maybeCaptureInboundEvent(event) { + if (this._isCapturing) { + var item = { + type: 'inbound', + timestamp: Date.now(), + scrubbedEvent: InboundEventLogger.scrubInboundEvent(event) + }; + + this._capturedInboundEventItems.push(item); + } + } + }]); + + return InboundEventLogger; + }(); + if (!Array.from) { Array.from = function from(arr) { return Array.prototype.slice.call(arr); @@ -175,10 +253,6 @@ var compiledWebviewJs = (function (exports) { return element.innerHTML; }; - var sendMessage = function sendMessage(msg) { - window.ReactNativeWebView.postMessage(JSON.stringify(msg)); - }; - window.onerror = function (message, source, line, column, error) { if (window.enableWebViewErrorDisplay) { var elementJsError = document.getElementById('js-error-detailed'); @@ -212,72 +286,18 @@ var compiledWebviewJs = (function (exports) { return true; }; - var isTrackingLongLoad = true; - var eventsDuringLongLoad = []; - - var logLongLoad = function logLongLoad() { - if (eventsDuringLongLoad === null) { - throw new Error(); - } - - var loggableEvents = eventsDuringLongLoad.map(function (eventWithTimestamp) { - var placeholdersDivTagFromContent = function placeholdersDivTagFromContent(content) { - var match = new RegExp('
').exec(content); - return match !== null ? match[0] : null; - }; - - var content = eventWithTimestamp.content, - auth = eventWithTimestamp.auth, - rest = _objectWithoutProperties(eventWithTimestamp, ["content", "auth"]); - - switch (eventWithTimestamp.type) { - case 'content': - { - return _objectSpread2(_objectSpread2({}, rest), {}, { - auth: 'redacted', - content: placeholdersDivTagFromContent(eventWithTimestamp.content) - }); - } - - case 'read': - case 'ready': - case 'fetching': - return rest; - - case 'typing': - { - return _objectSpread2(_objectSpread2({}, rest), {}, { - content: placeholdersDivTagFromContent(eventWithTimestamp.content) - }); - } - - default: - return { - type: eventWithTimestamp.type, - timestamp: eventWithTimestamp.timestamp - }; - } - }); - sendMessage({ - type: 'warn', - details: { - loggableEvents: loggableEvents - } - }); - }; - - var maybeLogLongLoad = function maybeLogLongLoad() { + var eventLogger = new InboundEventLogger(); + eventLogger.startCapturing(); + setTimeout(function () { var placeholdersDiv = document.getElementById('message-loading'); + eventLogger.stopCapturing(); if (placeholdersDiv && !placeholdersDiv.classList.contains('hidden')) { - logLongLoad(); + eventLogger.send(); } - isTrackingLongLoad = false; - eventsDuringLongLoad = null; - }; - - setTimeout(maybeLogLongLoad, 10000); + eventLogger.reset(); + }, 10000); var showHideElement = function showHideElement(elementId, show) { var element = document.getElementById(elementId); @@ -630,13 +650,8 @@ var compiledWebviewJs = (function (exports) { var decodedData = decodeURIComponent(escape(window.atob(e.data))); var updateEvents = JSON.parse(decodedData); updateEvents.forEach(function (uevent) { + eventLogger.maybeCaptureInboundEvent(uevent); eventUpdateHandlers[uevent.type](uevent); - - if (isTrackingLongLoad && eventsDuringLongLoad !== null) { - eventsDuringLongLoad.push(_objectSpread2(_objectSpread2({}, uevent), {}, { - timestamp: Date.now() - })); - } }); scrollEventsDisabled = false; }; diff --git a/src/webview/js/js.js b/src/webview/js/js.js index 1460404c814..da7162acd9e 100644 --- a/src/webview/js/js.js +++ b/src/webview/js/js.js @@ -1,7 +1,6 @@ /* @flow strict-local */ /* eslint-disable no-useless-return */ import type { Auth } from '../../types'; -import type { JSONable } from '../../utils/jsonable'; import type { WebViewUpdateEvent, WebViewUpdateEventContent, @@ -10,10 +9,10 @@ import type { WebViewUpdateEventReady, WebViewUpdateEventMessagesRead, } from '../webViewHandleUpdates'; -import type { MessageListEvent } from '../webViewEventHandlers'; -import { ensureUnreachable } from '../../types'; import rewriteImageUrls from './rewriteImageUrls'; +import InboundEventLogger from './InboundEventLogger'; +import sendMessage from './sendMessage'; /* * Supported platforms: @@ -106,10 +105,6 @@ const escapeHtml = (text: string): string => { return element.innerHTML; }; -const sendMessage = (msg: MessageListEvent) => { - window.ReactNativeWebView.postMessage(JSON.stringify(msg)); -}; - window.onerror = (message: string, source: string, line: number, column: number, error: Error) => { if (window.enableWebViewErrorDisplay) { const elementJsError = document.getElementById('js-error-detailed'); @@ -158,65 +153,19 @@ window.onerror = (message: string, source: string, line: number, column: number, return true; }; -let isTrackingLongLoad = true; -let eventsDuringLongLoad: Array<{ ...WebViewUpdateEvent, timestamp: number }> | null = []; - -const logLongLoad = () => { - if (eventsDuringLongLoad === null) { - throw new Error(); - } - const loggableEvents: JSONable[] = eventsDuringLongLoad.map(eventWithTimestamp => { - const placeholdersDivTagFromContent = (content: string) => { - const match = new RegExp('
').exec(content); - return match !== null ? match[0] : null; - }; - // $FlowFixMe (some events don't have content or auth) - const { content, auth, ...rest } = eventWithTimestamp; /* eslint-disable-line no-unused-vars */ - switch (eventWithTimestamp.type) { - case 'content': { - return { - ...rest, - auth: 'redacted', - content: placeholdersDivTagFromContent(eventWithTimestamp.content), - }; - } - case 'read': - case 'ready': - case 'fetching': - return rest; - case 'typing': { - return { - ...rest, - content: placeholdersDivTagFromContent(eventWithTimestamp.content), - }; - } - default: - ensureUnreachable(eventWithTimestamp); - return { - type: eventWithTimestamp.type, - timestamp: eventWithTimestamp.timestamp, - }; - } - }); - - sendMessage({ - type: 'warn', - details: { - loggableEvents, - }, - }); -}; - -const maybeLogLongLoad = () => { +const eventLogger = new InboundEventLogger(); +eventLogger.startCapturing(); +// After 10 seconds, if the loading placeholders are *still* visible, +// we want to know all the inbound events that were received in that +// time (with sensitive info redacted, of course). +setTimeout(() => { const placeholdersDiv = document.getElementById('message-loading'); + eventLogger.stopCapturing(); if (placeholdersDiv && !placeholdersDiv.classList.contains('hidden')) { - logLongLoad(); + eventLogger.send(); } - isTrackingLongLoad = false; - eventsDuringLongLoad = null; -}; - -setTimeout(maybeLogLongLoad, 10000); + eventLogger.reset(); +}, 10000); const showHideElement = (elementId: string, show: boolean) => { const element = document.getElementById(elementId); @@ -669,15 +618,9 @@ const handleMessageEvent: MessageEventListener = e => { const decodedData = decodeURIComponent(escape(window.atob(e.data))); const updateEvents: WebViewUpdateEvent[] = JSON.parse(decodedData); updateEvents.forEach((uevent: WebViewUpdateEvent) => { + eventLogger.maybeCaptureInboundEvent(uevent); // $FlowFixMe eventUpdateHandlers[uevent.type](uevent); - if (isTrackingLongLoad && eventsDuringLongLoad !== null) { - // $FlowFixMe the spread seems to confuse Flow, but this is likely correct - eventsDuringLongLoad.push({ - ...uevent, - timestamp: Date.now(), - }); - } }); scrollEventsDisabled = false; }; diff --git a/src/webview/js/sendMessage.js b/src/webview/js/sendMessage.js new file mode 100644 index 00000000000..42ed05738b5 --- /dev/null +++ b/src/webview/js/sendMessage.js @@ -0,0 +1,6 @@ +/* @flow strict-local */ +import type { MessageListEvent } from '../webViewEventHandlers'; + +export default (msg: MessageListEvent) => { + window.ReactNativeWebView.postMessage(JSON.stringify(msg)); +};