diff options
Diffstat (limited to 'devtools/client/netmonitor/src/components/request-details/HeadersPanel.js')
-rw-r--r-- | devtools/client/netmonitor/src/components/request-details/HeadersPanel.js | 850 |
1 files changed, 850 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/components/request-details/HeadersPanel.js b/devtools/client/netmonitor/src/components/request-details/HeadersPanel.js new file mode 100644 index 0000000000..80b9aed27d --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/HeadersPanel.js @@ -0,0 +1,850 @@ +/* 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 { + connect, +} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); +const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + getFormattedIPAndPort, + getFormattedSize, + getRequestPriorityAsText, +} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js"); +const { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const { + getHeadersURL, + getTrackingProtectionURL, + getHTTPStatusCodeURL, +} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js"); +const { + fetchNetworkUpdatePacket, + writeHeaderText, + getRequestHeadersRawText, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); +const { + HeadersProvider, + HeaderList, +} = require("resource://devtools/client/netmonitor/src/utils/headers-provider.js"); +const { + FILTER_SEARCH_DELAY, +} = require("resource://devtools/client/netmonitor/src/constants.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") +); +const Accordion = createFactory( + require("resource://devtools/client/shared/components/Accordion.js") +); +const UrlPreview = createFactory( + require("resource://devtools/client/netmonitor/src/components/previews/UrlPreview.js") +); +const HeadersPanelContextMenu = require("resource://devtools/client/netmonitor/src/widgets/HeadersPanelContextMenu.js"); +const StatusCode = createFactory( + require("resource://devtools/client/netmonitor/src/components/StatusCode.js") +); + +loader.lazyGetter(this, "MDNLink", function () { + return createFactory( + require("resource://devtools/client/shared/components/MdnLink.js") + ); +}); +loader.lazyGetter(this, "Rep", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .REPS.Rep; +}); +loader.lazyGetter(this, "MODE", function () { + return require("resource://devtools/client/shared/components/reps/index.js") + .MODE; +}); +loader.lazyGetter(this, "TreeRow", function () { + return createFactory( + require("resource://devtools/client/shared/components/tree/TreeRow.js") + ); +}); +loader.lazyRequireGetter( + this, + "showMenu", + "resource://devtools/client/shared/components/menu/utils.js", + true +); +loader.lazyRequireGetter( + this, + "openContentLink", + "resource://devtools/client/shared/link.js", + true +); + +const { div, input, label, span, textarea, tr, td, button } = dom; + +const RESEND = L10N.getStr("netmonitor.context.resend.label"); +const EDIT_AND_RESEND = L10N.getStr("netmonitor.summary.editAndResend"); +const RAW_HEADERS = L10N.getStr("netmonitor.headers.raw"); +const HEADERS_EMPTY_TEXT = L10N.getStr("headersEmptyText"); +const HEADERS_FILTER_TEXT = L10N.getStr("headersFilterText"); +const REQUEST_HEADERS = L10N.getStr("requestHeaders"); +const REQUEST_HEADERS_FROM_UPLOAD = L10N.getStr("requestHeadersFromUpload"); +const RESPONSE_HEADERS = L10N.getStr("responseHeaders"); +const HEADERS_STATUS = L10N.getStr("netmonitor.headers.status"); +const HEADERS_VERSION = L10N.getStr("netmonitor.headers.version"); +const HEADERS_TRANSFERRED = L10N.getStr("netmonitor.toolbar.transferred"); +const SUMMARY_STATUS_LEARN_MORE = L10N.getStr("netmonitor.summary.learnMore"); +const SUMMARY_ETP_LEARN_MORE = L10N.getStr( + "netmonitor.enhancedTrackingProtection.learnMore" +); +const HEADERS_REFERRER = L10N.getStr("netmonitor.headers.referrerPolicy"); +const HEADERS_CONTENT_BLOCKING = L10N.getStr( + "netmonitor.headers.contentBlocking" +); +const HEADERS_ETP = L10N.getStr( + "netmonitor.trackingResource.enhancedTrackingProtection" +); +const HEADERS_PRIORITY = L10N.getStr("netmonitor.headers.requestPriority"); + +/** + * Headers panel component + * Lists basic information about the request + * + * In http/2 all response headers are in small case. + * See: https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/request_details/index.html#response-headers + * RFC: https://tools.ietf.org/html/rfc7540#section-8.1.2 + */ +class HeadersPanel extends Component { + static get propTypes() { + return { + connector: PropTypes.object.isRequired, + cloneSelectedRequest: PropTypes.func.isRequired, + member: PropTypes.object, + request: PropTypes.object.isRequired, + renderValue: PropTypes.func, + openLink: PropTypes.func, + targetSearchResult: PropTypes.object, + openRequestBlockingAndAddUrl: PropTypes.func.isRequired, + openHTTPCustomRequestTab: PropTypes.func.isRequired, + cloneRequest: PropTypes.func, + sendCustomRequest: PropTypes.func, + shouldExpandPreview: PropTypes.bool, + setHeadersUrlPreviewExpanded: PropTypes.func, + }; + } + + constructor(props) { + super(props); + + this.state = { + rawRequestHeadersOpened: false, + rawResponseHeadersOpened: false, + rawUploadHeadersOpened: false, + lastToggledRawHeader: "", + filterText: null, + }; + + this.getProperties = this.getProperties.bind(this); + this.getTargetHeaderPath = this.getTargetHeaderPath.bind(this); + this.toggleRawResponseHeaders = this.toggleRawResponseHeaders.bind(this); + this.toggleRawRequestHeaders = this.toggleRawRequestHeaders.bind(this); + this.toggleRawUploadHeaders = this.toggleRawUploadHeaders.bind(this); + this.renderSummary = this.renderSummary.bind(this); + this.renderRow = this.renderRow.bind(this); + this.renderValue = this.renderValue.bind(this); + this.renderRawHeadersBtn = this.renderRawHeadersBtn.bind(this); + this.onShowResendMenu = this.onShowResendMenu.bind(this); + this.onShowHeadersContextMenu = this.onShowHeadersContextMenu.bind(this); + } + + componentDidMount() { + const { request, connector } = this.props; + fetchNetworkUpdatePacket(connector.requestData, request, [ + "requestHeaders", + "responseHeaders", + "requestPostData", + ]); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { request, connector } = nextProps; + fetchNetworkUpdatePacket(connector.requestData, request, [ + "requestHeaders", + "responseHeaders", + "requestPostData", + ]); + } + + getHeadersTitle(headers, title) { + let result = ""; + let preHeaderText = ""; + const { + responseHeaders, + requestHeaders, + httpVersion, + status, + statusText, + method, + urlDetails, + } = this.props.request; + if (headers?.headers.length) { + if (!headers.headersSize) { + if (title == RESPONSE_HEADERS) { + preHeaderText = `${httpVersion} ${status} ${statusText}`; + result = `${title} (${getFormattedSize( + writeHeaderText(responseHeaders.headers, preHeaderText).length, + 3 + )})`; + } else { + const hostHeader = requestHeaders.headers.find( + ele => ele.name === "Host" + ); + if (hostHeader) { + preHeaderText = `${method} ${ + urlDetails.url.split(hostHeader.value)[1] + } ${httpVersion}`; + } + result = `${title} (${getFormattedSize( + writeHeaderText(requestHeaders.headers, preHeaderText).length, + 3 + )})`; + } + } else { + result = `${title} (${getFormattedSize(headers.headersSize, 3)})`; + } + } + + return result; + } + + getProperties(headers, title) { + let propertiesResult; + + if (headers?.headers.length) { + const headerKey = this.getHeadersTitle(headers, title); + propertiesResult = { + [headerKey]: new HeaderList(headers.headers), + }; + if ( + (title === RESPONSE_HEADERS && this.state.rawResponseHeadersOpened) || + (title === REQUEST_HEADERS && this.state.rawRequestHeadersOpened) || + (title === REQUEST_HEADERS_FROM_UPLOAD && + this.state.rawUploadHeadersOpened) + ) { + propertiesResult = { + [headerKey]: { RAW_HEADERS_ID: headers.rawHeaders }, + }; + } + } + return propertiesResult; + } + + toggleRawResponseHeaders() { + this.setState({ + rawResponseHeadersOpened: !this.state.rawResponseHeadersOpened, + lastToggledRawHeader: "response", + }); + } + + toggleRawRequestHeaders() { + this.setState({ + rawRequestHeadersOpened: !this.state.rawRequestHeadersOpened, + lastToggledRawHeader: "request", + }); + } + + toggleRawUploadHeaders() { + this.setState({ + rawUploadHeadersOpened: !this.state.rawUploadHeadersOpened, + lastToggledRawHeader: "upload", + }); + } + + /** + * Helper method to identify what kind of raw header this is. + * Information is in the path variable + */ + getRawHeaderType(path) { + if (path.includes(RESPONSE_HEADERS)) { + return "RESPONSE"; + } + if (path.includes(REQUEST_HEADERS_FROM_UPLOAD)) { + return "UPLOAD"; + } + return "REQUEST"; + } + + /** + * Renders the top part of the headers detail panel - Summary. + */ + renderSummary(summaryLabel, value) { + return div( + { + key: summaryLabel, + className: "tabpanel-summary-container headers-summary", + }, + span( + { className: "tabpanel-summary-label headers-summary-label" }, + summaryLabel + ), + span({ className: "tabpanel-summary-value" }, value) + ); + } + + /** + * Get path for target header if it's set. It's used to select + * the header automatically within the tree of headers. + * Note that the target header is set by the Search panel. + */ + getTargetHeaderPath(searchResult) { + if (!searchResult) { + return null; + } + if ( + searchResult.type !== "requestHeaders" && + searchResult.type !== "responseHeaders" && + searchResult.type !== "requestHeadersFromUploadStream" + ) { + return null; + } + const { + request: { + requestHeaders, + requestHeadersFromUploadStream: uploadHeaders, + responseHeaders, + }, + } = this.props; + // Using `HeaderList` ensures that we'll get the same + // header index as it's used in the tree. + const getPath = (headers, title) => { + return ( + "/" + + this.getHeadersTitle(headers, title) + + "/" + + new HeaderList(headers.headers).headers.findIndex( + header => header.name == searchResult.label + ) + ); + }; + // Calculate target header path according to the header type. + switch (searchResult.type) { + case "requestHeaders": + return getPath(requestHeaders, REQUEST_HEADERS); + case "responseHeaders": + return getPath(responseHeaders, RESPONSE_HEADERS); + case "requestHeadersFromUploadStream": + return getPath(uploadHeaders, REQUEST_HEADERS_FROM_UPLOAD); + } + return null; + } + + /** + * Custom rendering method passed to PropertiesView. It's responsible + * for rendering <textarea> element with raw headers data. + */ + renderRow(props) { + const { level, path } = props.member; + + const { + request: { + method, + httpVersion, + requestHeaders, + requestHeadersFromUploadStream: uploadHeaders, + responseHeaders, + status, + statusText, + urlDetails, + }, + } = this.props; + + let value; + let preHeaderText = ""; + if (path.includes("RAW_HEADERS_ID")) { + const rawHeaderType = this.getRawHeaderType(path); + switch (rawHeaderType) { + case "REQUEST": + value = getRequestHeadersRawText( + method, + httpVersion, + requestHeaders, + urlDetails + ); + break; + case "RESPONSE": + preHeaderText = `${httpVersion} ${status} ${statusText}`; + value = writeHeaderText( + responseHeaders.headers, + preHeaderText + ).trim(); + break; + case "UPLOAD": + value = writeHeaderText(uploadHeaders.headers, preHeaderText).trim(); + break; + } + + let rows; + if (value) { + const match = value.match(/\n/g); + rows = match !== null ? match.length : 0; + // Need to add 1 for the horizontal scrollbar + // not to cover the last row of raw data + rows = rows + 1; + } + + return tr( + { + key: path, + role: "treeitem", + className: "raw-headers-container", + onClick: event => { + event.stopPropagation(); + }, + }, + td( + { + colSpan: 2, + }, + textarea({ + className: "raw-headers", + rows, + value, + readOnly: true, + }) + ) + ); + } + + if (level !== 1) { + return null; + } + + return TreeRow(props); + } + + renderRawHeadersBtn(key, checked, onChange) { + return [ + label( + { + key: `${key}RawHeadersBtn`, + className: "raw-headers-toggle", + htmlFor: `raw-${key}-checkbox`, + onClick: event => { + // stop the header click event + event.stopPropagation(); + }, + }, + span({ className: "headers-summary-label" }, RAW_HEADERS), + span( + { className: "raw-headers-toggle-input" }, + input({ + id: `raw-${key}-checkbox`, + checked, + className: "devtools-checkbox-toggle", + onChange, + type: "checkbox", + }) + ) + ), + ]; + } + + renderValue(props) { + const { member, value } = props; + + if (typeof value !== "string") { + return null; + } + + const headerDocURL = getHeadersURL(member.name); + + return div( + { className: "treeValueCellDivider" }, + Rep( + Object.assign(props, { + // FIXME: A workaround for the issue in StringRep + // Force StringRep to crop the text everytime + member: Object.assign({}, member, { open: false }), + mode: MODE.TINY, + noGrip: true, + openLink: openContentLink, + }) + ), + headerDocURL ? MDNLink({ url: headerDocURL }) : null + ); + } + + getShouldOpen(rawHeader, filterText, targetSearchResult) { + return (item, opened) => { + // If closed, open panel for these reasons + // 1.The raw header is switched on or off + // 2.The filter text is set + // 3.The search text is set + if ( + (!opened && this.state.lastToggledRawHeader === rawHeader) || + (!opened && filterText) || + (!opened && targetSearchResult) + ) { + return true; + } + return !!opened; + }; + } + + onShowResendMenu(event) { + const { + request: { id }, + cloneSelectedRequest, + cloneRequest, + sendCustomRequest, + } = this.props; + const menuItems = [ + { + label: RESEND, + type: "button", + click: () => { + cloneRequest(id); + sendCustomRequest(); + }, + }, + { + label: EDIT_AND_RESEND, + type: "button", + click: evt => { + cloneSelectedRequest(evt); + }, + }, + ]; + + showMenu(menuItems, { button: event.target }); + } + + onShowHeadersContextMenu(event) { + if (!this.contextMenu) { + this.contextMenu = new HeadersPanelContextMenu(); + } + this.contextMenu.open(event, window.getSelection()); + } + + render() { + const { + targetSearchResult, + request: { + fromCache, + fromServiceWorker, + httpVersion, + method, + remoteAddress, + remotePort, + requestHeaders, + requestHeadersFromUploadStream: uploadHeaders, + responseHeaders, + status, + statusText, + urlDetails, + referrerPolicy, + priority, + isThirdPartyTrackingResource, + contentSize, + transferredSize, + }, + openRequestBlockingAndAddUrl, + openHTTPCustomRequestTab, + shouldExpandPreview, + setHeadersUrlPreviewExpanded, + } = this.props; + const { + rawResponseHeadersOpened, + rawRequestHeadersOpened, + rawUploadHeadersOpened, + filterText, + } = this.state; + + if ( + (!requestHeaders || !requestHeaders.headers.length) && + (!uploadHeaders || !uploadHeaders.headers.length) && + (!responseHeaders || !responseHeaders.headers.length) + ) { + return div({ className: "empty-notice" }, HEADERS_EMPTY_TEXT); + } + + const items = []; + + if (responseHeaders?.headers.length) { + items.push({ + component: PropertiesView, + componentProps: { + object: this.getProperties(responseHeaders, RESPONSE_HEADERS), + filterText, + targetSearchResult, + renderRow: this.renderRow, + renderValue: this.renderValue, + provider: HeadersProvider, + selectPath: this.getTargetHeaderPath, + defaultSelectFirstNode: false, + enableInput: false, + useQuotes: false, + }, + header: this.getHeadersTitle(responseHeaders, RESPONSE_HEADERS), + buttons: this.renderRawHeadersBtn( + "response", + rawResponseHeadersOpened, + this.toggleRawResponseHeaders + ), + id: "responseHeaders", + opened: true, + shouldOpen: this.getShouldOpen( + "response", + filterText, + targetSearchResult + ), + }); + } + + if (requestHeaders?.headers.length) { + items.push({ + component: PropertiesView, + componentProps: { + object: this.getProperties(requestHeaders, REQUEST_HEADERS), + filterText, + targetSearchResult, + renderRow: this.renderRow, + renderValue: this.renderValue, + provider: HeadersProvider, + selectPath: this.getTargetHeaderPath, + defaultSelectFirstNode: false, + enableInput: false, + useQuotes: false, + }, + header: this.getHeadersTitle(requestHeaders, REQUEST_HEADERS), + buttons: this.renderRawHeadersBtn( + "request", + rawRequestHeadersOpened, + this.toggleRawRequestHeaders + ), + id: "requestHeaders", + opened: true, + shouldOpen: this.getShouldOpen( + "request", + filterText, + targetSearchResult + ), + }); + } + + if (uploadHeaders?.headers.length) { + items.push({ + component: PropertiesView, + componentProps: { + object: this.getProperties( + uploadHeaders, + REQUEST_HEADERS_FROM_UPLOAD + ), + filterText, + targetSearchResult, + renderRow: this.renderRow, + renderValue: this.renderValue, + provider: HeadersProvider, + selectPath: this.getTargetHeaderPath, + defaultSelectFirstNode: false, + enableInput: false, + useQuotes: false, + }, + header: this.getHeadersTitle( + uploadHeaders, + REQUEST_HEADERS_FROM_UPLOAD + ), + buttons: this.renderRawHeadersBtn( + "upload", + rawUploadHeadersOpened, + this.toggleRawUploadHeaders + ), + id: "uploadHeaders", + opened: true, + shouldOpen: this.getShouldOpen( + "upload", + filterText, + targetSearchResult + ), + }); + } + + const sizeText = L10N.getFormatStrWithNumbers( + "netmonitor.headers.sizeDetails", + getFormattedSize(transferredSize), + getFormattedSize(contentSize) + ); + + const summarySize = this.renderSummary(HEADERS_TRANSFERRED, sizeText); + + let summaryStatus; + if (status) { + summaryStatus = div( + { + key: "headers-summary", + className: "tabpanel-summary-container headers-summary", + }, + span( + { + className: "tabpanel-summary-label headers-summary-label", + }, + HEADERS_STATUS + ), + span( + { + className: "tabpanel-summary-value status", + "data-code": status, + }, + StatusCode({ + item: { fromCache, fromServiceWorker, status, statusText }, + }), + statusText, + MDNLink({ + url: getHTTPStatusCodeURL(status), + title: SUMMARY_STATUS_LEARN_MORE, + }) + ) + ); + } + + let trackingProtectionStatus; + let trackingProtectionDetails = ""; + if (isThirdPartyTrackingResource) { + const trackingProtectionDocURL = getTrackingProtectionURL(); + + trackingProtectionStatus = this.renderSummary( + HEADERS_CONTENT_BLOCKING, + div(null, span({ className: "tracking-resource" }), HEADERS_ETP) + ); + trackingProtectionDetails = this.renderSummary( + "", + div( + { + key: "tracking-protection", + className: "tracking-protection", + }, + L10N.getStr("netmonitor.trackingResource.tooltip"), + trackingProtectionDocURL + ? MDNLink({ + url: trackingProtectionDocURL, + title: SUMMARY_ETP_LEARN_MORE, + }) + : span({ className: "headers-summary learn-more-link" }) + ) + ); + } + + const summaryVersion = httpVersion + ? this.renderSummary(HEADERS_VERSION, httpVersion) + : null; + + const summaryReferrerPolicy = referrerPolicy + ? this.renderSummary(HEADERS_REFERRER, referrerPolicy) + : null; + + const summaryPriority = priority + ? this.renderSummary(HEADERS_PRIORITY, getRequestPriorityAsText(priority)) + : null; + + const summaryItems = [ + summaryStatus, + summaryVersion, + summarySize, + summaryReferrerPolicy, + summaryPriority, + trackingProtectionStatus, + trackingProtectionDetails, + ].filter(summaryItem => summaryItem !== null); + + const newEditAndResendPref = Services.prefs.getBoolPref( + "devtools.netmonitor.features.newEditAndResend" + ); + + return div( + { className: "headers-panel-container" }, + div( + { className: "devtools-toolbar devtools-input-toolbar" }, + SearchBox({ + delay: FILTER_SEARCH_DELAY, + type: "filter", + onChange: text => this.setState({ filterText: text }), + placeholder: HEADERS_FILTER_TEXT, + }), + span({ className: "devtools-separator" }), + button( + { + id: "block-button", + className: "devtools-button", + title: L10N.getStr("netmonitor.context.blockURL"), + onClick: () => openRequestBlockingAndAddUrl(urlDetails.url), + }, + L10N.getStr("netmonitor.headers.toolbar.block") + ), + span({ className: "devtools-separator" }), + button( + { + id: "edit-resend-button", + className: !newEditAndResendPref + ? "devtools-button devtools-dropdown-button" + : "devtools-button", + title: RESEND, + onClick: !newEditAndResendPref + ? this.onShowResendMenu + : () => { + openHTTPCustomRequestTab(); + }, + }, + span({ className: "title" }, RESEND) + ) + ), + div( + { className: "panel-container" }, + div( + { className: "headers-overview" }, + UrlPreview({ + url: urlDetails.url, + method, + address: remoteAddress + ? getFormattedIPAndPort(remoteAddress, remotePort) + : null, + shouldExpandPreview, + onTogglePreview: expanded => setHeadersUrlPreviewExpanded(expanded), + }), + div( + { + className: "summary", + onContextMenu: this.onShowHeadersContextMenu, + }, + summaryItems + ) + ), + Accordion({ items }) + ) + ); + } +} + +module.exports = connect( + state => ({ + shouldExpandPreview: state.ui.shouldExpandHeadersUrlPreview, + }), + (dispatch, props) => ({ + setHeadersUrlPreviewExpanded: expanded => + dispatch(Actions.setHeadersUrlPreviewExpanded(expanded)), + openRequestBlockingAndAddUrl: url => + dispatch(Actions.openRequestBlockingAndAddUrl(url)), + openHTTPCustomRequestTab: () => + dispatch(Actions.openHTTPCustomRequest(true)), + cloneRequest: id => dispatch(Actions.cloneRequest(id)), + sendCustomRequest: () => dispatch(Actions.sendCustomRequest()), + }) +)(HeadersPanel); |