/* 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 { connect, } = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); const { L10N, } = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); const { fetchNetworkUpdatePacket, parseFormData, parseJSON, } = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); const { sortObjectKeys, } = require("resource://devtools/client/netmonitor/src/utils/sort-utils.js"); const { FILTER_SEARCH_DELAY, } = require("resource://devtools/client/netmonitor/src/constants.js"); const { updateFormDataSections, } = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js"); // Components const PropertiesView = createFactory( require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js") ); const SearchBox = createFactory( require("resource://devtools/client/shared/components/SearchBox.js") ); loader.lazyGetter(this, "SourcePreview", function () { return createFactory( require("resource://devtools/client/netmonitor/src/components/previews/SourcePreview.js") ); }); const { div, input, label, span, h2 } = dom; const JSON_SCOPE_NAME = L10N.getStr("jsonScopeName"); const REQUEST_EMPTY_TEXT = L10N.getStr("paramsNoPayloadText"); const REQUEST_FILTER_TEXT = L10N.getStr("paramsFilterText"); const REQUEST_FORM_DATA = L10N.getStr("paramsFormData"); const REQUEST_POST_PAYLOAD = L10N.getStr("paramsPostPayload"); const RAW_REQUEST_PAYLOAD = L10N.getStr("netmonitor.request.raw"); const REQUEST_TRUNCATED = L10N.getStr("requestTruncated"); /** * Params panel component * Displays the GET parameters and POST data of a request */ class RequestPanel extends Component { static get propTypes() { return { connector: PropTypes.object.isRequired, openLink: PropTypes.func, request: PropTypes.object.isRequired, updateRequest: PropTypes.func.isRequired, targetSearchResult: PropTypes.object, }; } constructor(props) { super(props); this.state = { filterText: "", rawRequestPayloadDisplayed: !!props.targetSearchResult, }; this.toggleRawRequestPayload = this.toggleRawRequestPayload.bind(this); this.renderRawRequestPayloadBtn = this.renderRawRequestPayloadBtn.bind(this); } componentDidMount() { const { request, connector } = this.props; fetchNetworkUpdatePacket(connector.requestData, request, [ "requestPostData", ]); updateFormDataSections(this.props); } // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 UNSAFE_componentWillReceiveProps(nextProps) { const { request, connector } = nextProps; fetchNetworkUpdatePacket(connector.requestData, request, [ "requestPostData", ]); updateFormDataSections(nextProps); if (nextProps.targetSearchResult !== null) { this.setState({ rawRequestPayloadDisplayed: !!nextProps.targetSearchResult, }); } } /** * Update only if: * 1) The rendered object has changed * 2) The filter text has changed * 2) The display got toggled between formatted and raw data * 3) The user selected another search result target. */ shouldComponentUpdate(nextProps, nextState) { return ( this.props.request !== nextProps.request || this.state.filterText !== nextState.filterText || this.state.rawRequestPayloadDisplayed !== nextState.rawRequestPayloadDisplayed || this.props.targetSearchResult !== nextProps.targetSearchResult ); } /** * Mapping array to dict for TreeView usage. * Since TreeView only support Object(dict) format. * This function also deal with duplicate key case * (for multiple selection and query params with same keys) * * This function is not sorting result properties since it can * results in unexpected order of params. See bug 1469533 * * @param {Object[]} arr - key-value pair array or form params * @returns {Object} Rep compatible object */ getProperties(arr) { return arr.reduce((map, obj) => { const value = map[obj.name]; if (value || value === "") { if (typeof value !== "object") { map[obj.name] = [value]; } map[obj.name].push(obj.value); } else { map[obj.name] = obj.value; } return map; }, {}); } toggleRawRequestPayload() { this.setState({ rawRequestPayloadDisplayed: !this.state.rawRequestPayloadDisplayed, }); } renderRawRequestPayloadBtn(key, checked, onChange) { return [ label( { key: `${key}RawRequestPayloadBtn`, className: "raw-data-toggle", htmlFor: `raw-${key}-checkbox`, onClick: event => { // stop the header click event event.stopPropagation(); }, }, span({ className: "raw-data-toggle-label" }, RAW_REQUEST_PAYLOAD), span( { className: "raw-data-toggle-input" }, input({ id: `raw-${key}-checkbox`, checked, className: "devtools-checkbox-toggle", onChange, type: "checkbox", }) ) ), ]; } renderRequestPayload(component, componentProps) { return component(componentProps); } render() { const { request, targetSearchResult } = this.props; const { filterText, rawRequestPayloadDisplayed } = this.state; const { formDataSections, mimeType, requestPostData } = request; const postData = requestPostData ? requestPostData.postData?.text : null; if ((!formDataSections || formDataSections.length === 0) && !postData) { return div({ className: "empty-notice" }, REQUEST_EMPTY_TEXT); } let component; let componentProps; let requestPayloadLabel = REQUEST_POST_PAYLOAD; let hasFormattedDisplay = false; let error; // Form Data section if (formDataSections && formDataSections.length) { const sections = formDataSections.filter(str => /\S/.test(str)).join("&"); component = PropertiesView; componentProps = { object: this.getProperties(parseFormData(sections)), filterText, targetSearchResult, defaultSelectFirstNode: false, }; requestPayloadLabel = REQUEST_FORM_DATA; hasFormattedDisplay = true; } // Request payload section const limit = Services.prefs.getIntPref( "devtools.netmonitor.requestBodyLimit" ); // Check if the request post data has been truncated from the backend, // in which case no parse should be attempted. if (postData && limit <= postData.length) { error = REQUEST_TRUNCATED; } if (formDataSections && formDataSections.length === 0 && postData) { if (!error) { const jsonParsedPostData = parseJSON(postData); const { json, strippedChars } = jsonParsedPostData; // If XSSI characters were present in the request just display the raw // data because a request should never have XSSI escape characters if (strippedChars) { hasFormattedDisplay = false; } else if (json) { component = PropertiesView; componentProps = { object: sortObjectKeys(json), filterText, targetSearchResult, defaultSelectFirstNode: false, }; requestPayloadLabel = JSON_SCOPE_NAME; hasFormattedDisplay = true; } } } if ( (!hasFormattedDisplay || this.state.rawRequestPayloadDisplayed) && postData ) { component = SourcePreview; componentProps = { text: postData, mode: mimeType?.replace(/;.+/, ""), targetSearchResult, }; requestPayloadLabel = REQUEST_POST_PAYLOAD; } return div( { className: "panel-container" }, error && div({ className: "request-error-header", title: error }, error), div( { className: "devtools-toolbar devtools-input-toolbar" }, SearchBox({ delay: FILTER_SEARCH_DELAY, type: "filter", onChange: text => this.setState({ filterText: text }), placeholder: REQUEST_FILTER_TEXT, }) ), h2({ className: "data-header", role: "heading" }, [ span( { key: "data-label", className: "data-label", }, requestPayloadLabel ), hasFormattedDisplay && this.renderRawRequestPayloadBtn( "request", rawRequestPayloadDisplayed, this.toggleRawRequestPayload ), ]), this.renderRequestPayload(component, componentProps) ); } } module.exports = connect(null, dispatch => ({ updateRequest: (id, data, batch) => dispatch(Actions.updateRequest(id, data, batch)), }))(RequestPanel);