/* 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;