diff options
Diffstat (limited to 'devtools/client/netmonitor/src/components/request-details/ResponsePanel.js')
-rw-r--r-- | devtools/client/netmonitor/src/components/request-details/ResponsePanel.js | 496 |
1 files changed, 496 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/components/request-details/ResponsePanel.js b/devtools/client/netmonitor/src/components/request-details/ResponsePanel.js new file mode 100644 index 0000000000..ac4435ea1d --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/ResponsePanel.js @@ -0,0 +1,496 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const { + decodeUnicodeBase64, + fetchNetworkUpdatePacket, + parseJSON, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); +const { + getCORSErrorURL, +} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js"); +const { + Filters, +} = require("resource://devtools/client/netmonitor/src/utils/filter-predicates.js"); +const { + FILTER_SEARCH_DELAY, +} = require("resource://devtools/client/netmonitor/src/constants.js"); +const { + BLOCKED_REASON_MESSAGES, +} = require("resource://devtools/client/netmonitor/src/constants.js"); + +// Components +const PropertiesView = createFactory( + require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js") +); +const ImagePreview = createFactory( + require("resource://devtools/client/netmonitor/src/components/previews/ImagePreview.js") +); +const FontPreview = createFactory( + require("resource://devtools/client/netmonitor/src/components/previews/FontPreview.js") +); +const SourcePreview = createFactory( + require("resource://devtools/client/netmonitor/src/components/previews/SourcePreview.js") +); +const HtmlPreview = createFactory( + require("resource://devtools/client/netmonitor/src/components/previews/HtmlPreview.js") +); +let { + NotificationBox, + PriorityLevels, +} = require("resource://devtools/client/shared/components/NotificationBox.js"); +NotificationBox = createFactory(NotificationBox); +const MessagesView = createFactory( + require("resource://devtools/client/netmonitor/src/components/messages/MessagesView.js") +); +const SearchBox = createFactory( + require("resource://devtools/client/shared/components/SearchBox.js") +); + +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); + +const { div, input, label, span, h2 } = dom; +const JSON_SCOPE_NAME = L10N.getStr("jsonScopeName"); +const JSON_FILTER_TEXT = L10N.getStr("jsonFilterText"); +const RESPONSE_PAYLOAD = L10N.getStr("responsePayload"); +const RAW_RESPONSE_PAYLOAD = L10N.getStr("netmonitor.response.raw"); +const HTML_RESPONSE = L10N.getStr("netmonitor.response.html"); +const RESPONSE_EMPTY_TEXT = L10N.getStr("responseEmptyText"); +const RESPONSE_TRUNCATED = L10N.getStr("responseTruncated"); + +const JSON_VIEW_MIME_TYPE = "application/vnd.mozilla.json.view"; + +/** + * Response panel component + * Displays the GET parameters and POST data of a request + */ +class ResponsePanel extends Component { + static get propTypes() { + return { + request: PropTypes.object.isRequired, + openLink: PropTypes.func, + targetSearchResult: PropTypes.object, + connector: PropTypes.object.isRequired, + showMessagesView: PropTypes.bool, + }; + } + + constructor(props) { + super(props); + + this.state = { + filterText: "", + rawResponsePayloadDisplayed: !!props.targetSearchResult, + }; + + this.toggleRawResponsePayload = this.toggleRawResponsePayload.bind(this); + this.renderCORSBlockedReason = this.renderCORSBlockedReason.bind(this); + this.renderRawResponsePayloadBtn = + this.renderRawResponsePayloadBtn.bind(this); + this.renderJsonHtmlAndSource = this.renderJsonHtmlAndSource.bind(this); + this.handleJSONResponse = this.handleJSONResponse.bind(this); + } + + componentDidMount() { + const { request, connector } = this.props; + fetchNetworkUpdatePacket(connector.requestData, request, [ + "responseContent", + ]); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { request, connector } = nextProps; + fetchNetworkUpdatePacket(connector.requestData, request, [ + "responseContent", + ]); + + // If the response contains XSSI stripped chars default to raw view + const text = nextProps.request?.responseContent?.content?.text; + const xssiStrippedChars = text && parseJSON(text)?.strippedChars; + if (xssiStrippedChars && !this.state.rawResponsePayloadDisplayed) { + this.toggleRawResponsePayload(); + } + + if (nextProps.targetSearchResult !== null) { + this.setState({ + rawResponsePayloadDisplayed: !!nextProps.targetSearchResult, + }); + } + } + + /** + * Update only if: + * 1) The rendered object has changed + * 2) The user selected another search result target. + * 3) Internal state changes + */ + shouldComponentUpdate(nextProps, nextState) { + return ( + this.state !== nextState || + this.props.request !== nextProps.request || + nextProps.targetSearchResult !== null + ); + } + + /** + * Handle json, which we tentatively identify by checking the + * MIME type for "json" after any word boundary. This works + * for the standard "application/json", and also for custom + * types like "x-bigcorp-json". Additionally, we also + * directly parse the response text content to verify whether + * it's json or not, to handle responses incorrectly labeled + * as text/plain instead. + */ + handleJSONResponse(mimeType, response) { + const limit = Services.prefs.getIntPref( + "devtools.netmonitor.responseBodyLimit" + ); + const { request } = this.props; + + // Check if the response has been truncated, in which case no parse should + // be attempted. + if (limit > 0 && limit <= request.responseContent.content.size) { + const result = {}; + result.error = RESPONSE_TRUNCATED; + return result; + } + + const { json, error, jsonpCallback, strippedChars } = parseJSON(response); + + if (/\bjson/.test(mimeType) || json) { + const result = {}; + // Make sure this is a valid JSON object first. If so, nicely display + // the parsing results in a tree view. + + // Valid JSON + if (json) { + result.json = json; + } + // Valid JSONP + if (jsonpCallback) { + result.jsonpCallback = jsonpCallback; + } + // Malformed JSON + if (error) { + result.error = "" + error; + } + // XSSI protection sequence + if (strippedChars) { + result.strippedChars = strippedChars; + } + + return result; + } + + return null; + } + + renderCORSBlockedReason(blockedReason) { + // ensure that the blocked reason is in the CORS range + if ( + typeof blockedReason != "number" || + blockedReason < 1000 || + blockedReason > 1015 + ) { + return null; + } + + const blockedMessage = BLOCKED_REASON_MESSAGES[blockedReason]; + const messageText = L10N.getFormatStr( + "netmonitor.headers.blockedByCORS", + blockedMessage + ); + + const learnMoreTooltip = L10N.getStr( + "netmonitor.headers.blockedByCORSTooltip" + ); + + // Create a notifications map with the CORS error notification + const notifications = new Map(); + notifications.set("CORS-error", { + label: messageText, + value: "CORS-error", + image: "", + priority: PriorityLevels.PRIORITY_INFO_HIGH, + type: "info", + eventCallback: e => {}, + buttons: [ + { + mdnUrl: getCORSErrorURL(blockedReason), + label: learnMoreTooltip, + }, + ], + }); + + return NotificationBox({ + notifications, + displayBorderTop: false, + displayBorderBottom: true, + displayCloseButton: false, + }); + } + + toggleRawResponsePayload() { + this.setState({ + rawResponsePayloadDisplayed: !this.state.rawResponsePayloadDisplayed, + }); + } + + /** + * Pick correct component, componentprops, and other needed data to render + * the given response + * + * @returns {Object} shape: + * {component}: React component used to render response + * {Object} componetProps: Props passed to component + * {Error} error: JSON parsing error + * {Object} json: parsed JSON payload + * {bool} hasFormattedDisplay: whether the given payload has a formatted + * display or if it should be rendered raw + * {string} responsePayloadLabel: describes type in response panel + * {component} xssiStrippedCharsInfoBox: React component to notifiy users + * that XSSI characters were stripped from the response + */ + renderJsonHtmlAndSource() { + const { request, targetSearchResult } = this.props; + const { responseContent } = request; + let { encoding, mimeType, text } = responseContent.content; + const { filterText, rawResponsePayloadDisplayed } = this.state; + + // Decode response if it's coming from JSONView. + if (mimeType?.includes(JSON_VIEW_MIME_TYPE) && encoding === "base64") { + text = decodeUnicodeBase64(text); + } + const { json, jsonpCallback, error, strippedChars } = + this.handleJSONResponse(mimeType, text) || {}; + + let component; + let componentProps; + let xssiStrippedCharsInfoBox; + let responsePayloadLabel = RESPONSE_PAYLOAD; + let hasFormattedDisplay = false; + + if (json) { + if (jsonpCallback) { + responsePayloadLabel = L10N.getFormatStr( + "jsonpScopeName", + jsonpCallback + ); + } else { + responsePayloadLabel = JSON_SCOPE_NAME; + } + + // If raw response payload is not displayed render xssi info box if + // there are stripped chars + if (!rawResponsePayloadDisplayed) { + xssiStrippedCharsInfoBox = + this.renderXssiStrippedCharsInfoBox(strippedChars); + } else { + xssiStrippedCharsInfoBox = null; + } + + component = PropertiesView; + componentProps = { + object: json, + useQuotes: true, + filterText, + targetSearchResult, + defaultSelectFirstNode: false, + mode: MODE.LONG, + useBaseTreeViewExpand: true, + }; + hasFormattedDisplay = true; + } else if (Filters.html(this.props.request)) { + // Display HTML + responsePayloadLabel = HTML_RESPONSE; + component = HtmlPreview; + componentProps = { responseContent }; + hasFormattedDisplay = true; + } + if (!hasFormattedDisplay || rawResponsePayloadDisplayed) { + component = SourcePreview; + componentProps = { + text, + mode: json ? "application/json" : mimeType.replace(/;.+/, ""), + targetSearchResult, + }; + } + return { + component, + componentProps, + error, + hasFormattedDisplay, + json, + responsePayloadLabel, + xssiStrippedCharsInfoBox, + }; + } + + renderRawResponsePayloadBtn(key, checked, onChange) { + return [ + label( + { + key: `${key}RawResponsePayloadBtn`, + className: "raw-data-toggle", + htmlFor: `raw-${key}-checkbox`, + onClick: event => { + // stop the header click event + event.stopPropagation(); + }, + }, + span({ className: "raw-data-toggle-label" }, RAW_RESPONSE_PAYLOAD), + span( + { className: "raw-data-toggle-input" }, + input({ + id: `raw-${key}-checkbox`, + checked, + className: "devtools-checkbox-toggle", + onChange, + type: "checkbox", + }) + ) + ), + ]; + } + + renderResponsePayload(component, componentProps) { + return component(componentProps); + } + + /** + * This function takes a string of the XSSI protection characters + * removed from a JSON payload and produces a notification component + * letting the user know that they were removed + * + * @param {string} strippedChars: string of XSSI protection characters + * removed from JSON payload + * @returns {component} NotificationBox component + */ + renderXssiStrippedCharsInfoBox(strippedChars) { + if (!strippedChars || this.state.rawRequestPayloadDisplayed) { + return null; + } + const message = L10N.getFormatStr("jsonXssiStripped", strippedChars); + + const notifications = new Map(); + notifications.set("xssi-string-removed-info-box", { + label: message, + value: "xssi-string-removed-info-box", + image: "", + priority: PriorityLevels.PRIORITY_INFO_MEDIUM, + type: "info", + eventCallback: e => {}, + buttons: [], + }); + + return NotificationBox({ + notifications, + displayBorderTop: false, + displayBorderBottom: true, + displayCloseButton: false, + }); + } + + render() { + const { connector, showMessagesView, request } = this.props; + const { blockedReason, responseContent, url } = request; + const { filterText, rawResponsePayloadDisplayed } = this.state; + + // Display CORS blocked Reason info box + const CORSBlockedReasonDetails = + this.renderCORSBlockedReason(blockedReason); + + if (showMessagesView) { + return MessagesView({ connector }); + } + + if ( + !responseContent || + typeof responseContent.content.text !== "string" || + !responseContent.content.text + ) { + return div( + { className: "panel-container" }, + CORSBlockedReasonDetails, + div({ className: "empty-notice" }, RESPONSE_EMPTY_TEXT) + ); + } + + const { encoding, mimeType, text } = responseContent.content; + + if (Filters.images({ mimeType })) { + return ImagePreview({ encoding, mimeType, text, url }); + } + + if (Filters.fonts({ url, mimeType })) { + return FontPreview({ connector, mimeType, url }); + } + + // Get Data needed for formatted display + const { + component, + componentProps, + error, + hasFormattedDisplay, + json, + responsePayloadLabel, + xssiStrippedCharsInfoBox, + } = this.renderJsonHtmlAndSource(); + + const classList = ["panel-container"]; + if (Filters.html(this.props.request)) { + classList.push("contains-html-preview"); + } + + return div( + { className: classList.join(" ") }, + error && div({ className: "response-error-header", title: error }, error), + json && + div( + { className: "devtools-toolbar devtools-input-toolbar" }, + SearchBox({ + delay: FILTER_SEARCH_DELAY, + type: "filter", + onChange: filter => this.setState({ filterText: filter }), + placeholder: JSON_FILTER_TEXT, + value: filterText, + }) + ), + div({ tabIndex: "0" }, CORSBlockedReasonDetails), + h2({ className: "data-header", role: "heading" }, [ + span( + { + key: "data-label", + className: "data-label", + }, + responsePayloadLabel + ), + hasFormattedDisplay && + this.renderRawResponsePayloadBtn( + "response", + rawResponsePayloadDisplayed, + this.toggleRawResponsePayload + ), + ]), + xssiStrippedCharsInfoBox, + this.renderResponsePayload(component, componentProps) + ); + } +} + +module.exports = ResponsePanel; |