diff options
Diffstat (limited to 'devtools/client/netmonitor/src/components/request-details')
11 files changed, 2978 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/components/request-details/CachePanel.js b/devtools/client/netmonitor/src/components/request-details/CachePanel.js new file mode 100644 index 0000000000..5f3db6c501 --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/CachePanel.js @@ -0,0 +1,144 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const { + fetchNetworkUpdatePacket, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +// Components +const TreeViewClass = require("resource://devtools/client/shared/components/tree/TreeView.js"); +const PropertiesView = createFactory( + require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js") +); + +const { div, input } = dom; + +const CACHE = L10N.getStr("netmonitor.cache.cache"); +const DATA_SIZE = L10N.getStr("netmonitor.cache.dataSize"); +const EXPIRES = L10N.getStr("netmonitor.cache.expires"); +const FETCH_COUNT = L10N.getStr("netmonitor.cache.fetchCount"); +const LAST_FETCHED = L10N.getStr("netmonitor.cache.lastFetched"); +const LAST_MODIFIED = L10N.getStr("netmonitor.cache.lastModified"); +const DEVICE = L10N.getStr("netmonitor.cache.device"); +const NOT_AVAILABLE = L10N.getStr("netmonitor.cache.notAvailable"); +const EMPTY = L10N.getStr("netmonitor.cache.empty"); + +/** + * Cache panel component + * This tab lists full details of any cache information of the response. + */ +class CachePanel extends Component { + static get propTypes() { + return { + connector: PropTypes.object.isRequired, + openLink: PropTypes.func, + request: PropTypes.object.isRequired, + }; + } + + componentDidMount() { + const { connector, request } = this.props; + fetchNetworkUpdatePacket(connector.requestData, request, ["responseCache"]); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { connector, request } = nextProps; + fetchNetworkUpdatePacket(connector.requestData, request, ["responseCache"]); + } + + renderSummary(label, value) { + return div( + { className: "tabpanel-summary-container cache-summary" }, + div( + { + className: "tabpanel-summary-label cache-summary-label", + }, + label + ":" + ), + input({ + className: "tabpanel-summary-value textbox-input devtools-monospace", + readOnly: true, + value, + }) + ); + } + + getProperties(responseCache) { + let responseCacheObj; + let cacheObj; + try { + responseCacheObj = Object.assign({}, responseCache); + responseCacheObj = responseCacheObj.cache; + } catch (e) { + return null; + } + try { + cacheObj = Object.assign({}, responseCacheObj); + } catch (e) { + return null; + } + return cacheObj; + } + + getDate(timestamp) { + if (!timestamp) { + return null; + } + const d = new Date(parseInt(timestamp, 10) * 1000); + return d.toLocaleDateString() + " " + d.toLocaleTimeString(); + } + + render() { + const { request } = this.props; + const { responseCache } = request; + + let object; + const cache = this.getProperties(responseCache); + + if ( + cache.lastFetched || + cache.fetchCount || + cache.storageDataSize || + cache.lastModified || + cache.expirationTime || + cache.deviceID + ) { + object = { + [CACHE]: { + [LAST_FETCHED]: this.getDate(cache.lastFetched) || NOT_AVAILABLE, + [FETCH_COUNT]: cache.fetchCount || NOT_AVAILABLE, + [DATA_SIZE]: cache.storageDataSize || NOT_AVAILABLE, + [LAST_MODIFIED]: this.getDate(cache.lastModified) || NOT_AVAILABLE, + [EXPIRES]: this.getDate(cache.expirationTime) || NOT_AVAILABLE, + [DEVICE]: cache.deviceID || NOT_AVAILABLE, + }, + }; + } else { + return div({ className: "empty-notice" }, EMPTY); + } + + return div( + { className: "panel-container security-panel" }, + PropertiesView({ + object, + enableFilter: false, + expandedNodes: TreeViewClass.getExpandedNodes(object), + }) + ); + } +} + +module.exports = CachePanel; diff --git a/devtools/client/netmonitor/src/components/request-details/CookiesPanel.js b/devtools/client/netmonitor/src/components/request-details/CookiesPanel.js new file mode 100644 index 0000000000..466c0379cd --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/CookiesPanel.js @@ -0,0 +1,225 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const { + fetchNetworkUpdatePacket, +} = 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"); + +// Component +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") +); + +loader.lazyGetter(this, "TreeRow", function () { + return createFactory( + require("resource://devtools/client/shared/components/tree/TreeRow.js") + ); +}); + +const { div } = dom; + +const COOKIES_EMPTY_TEXT = L10N.getStr("cookiesEmptyText"); +const COOKIES_FILTER_TEXT = L10N.getStr("cookiesFilterText"); +const REQUEST_COOKIES = L10N.getStr("requestCookies"); +const RESPONSE_COOKIES = L10N.getStr("responseCookies"); + +/* + * Cookies panel component + * This tab lists full details of any cookies sent with the request or response + */ +class CookiesPanel extends Component { + static get propTypes() { + return { + connector: PropTypes.object.isRequired, + openLink: PropTypes.func, + request: PropTypes.object.isRequired, + targetSearchResult: PropTypes.object, + }; + } + + constructor(props) { + super(props); + this.state = { + filterText: "", + }; + } + + componentDidMount() { + const { connector, request } = this.props; + fetchNetworkUpdatePacket(connector.requestData, request, [ + "requestCookies", + "responseCookies", + ]); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { connector, request } = nextProps; + fetchNetworkUpdatePacket(connector.requestData, request, [ + "requestCookies", + "responseCookies", + ]); + } + + /** + * Mapping array to dict for TreeView usage. + * Since TreeView only support Object(dict) format. + * + * @param {Object[]} arr - key-value pair array like cookies or params + * @returns {Object} + */ + getProperties(arr, title) { + const cookies = arr.reduce((map, obj) => { + // Generally cookies object contains only name and value properties and can + // be rendered as name: value pair. + // When there are more properties in cookies object such as extra or path, + // We will pass the object to display these extra information + if (Object.keys(obj).length > 2) { + map[obj.name] = Object.assign({}, obj); + delete map[obj.name].name; + } else { + map[obj.name] = obj.value; + } + return map; + }, Object.create(null)); + + // To have different roots for Request and Response cookies + return { [title]: cookies }; + } + + /** + * Custom rendering method passed to PropertiesView. It's + * responsible to filter out level 0 node in the tree + * + * @param {Object} props + */ + renderRow(props) { + const { level } = props.member; + + if (level === 0) { + return null; + } + + return TreeRow(props); + } + + /** + * Get the selected cookies path + * @param {Object} searchResult + * @returns {string} + */ + getTargetCookiePath(searchResult) { + if (!searchResult) { + return null; + } + + switch (searchResult.type) { + case "requestCookies": { + return `/${REQUEST_COOKIES}/${searchResult.label}`; + } + case "responseCookies": + return `/${RESPONSE_COOKIES}/${searchResult.label}`; + } + + return null; + } + + render() { + let { + request: { + requestCookies = { cookies: [] }, + responseCookies = { cookies: [] }, + }, + targetSearchResult, + } = this.props; + + const { filterText } = this.state; + + requestCookies = requestCookies.cookies || requestCookies; + responseCookies = responseCookies.cookies || responseCookies; + + if (!requestCookies.length && !responseCookies.length) { + return div({ className: "empty-notice" }, COOKIES_EMPTY_TEXT); + } + + const items = []; + + if (responseCookies.length) { + items.push({ + component: PropertiesView, + componentProps: { + object: sortObjectKeys( + this.getProperties(responseCookies, RESPONSE_COOKIES) + ), + filterText, + targetSearchResult, + defaultSelectFirstNode: false, + selectPath: this.getTargetCookiePath, + renderRow: this.renderRow, + }, + header: RESPONSE_COOKIES, + id: "responseCookies", + opened: true, + }); + } + + if (requestCookies.length) { + items.push({ + component: PropertiesView, + componentProps: { + object: sortObjectKeys( + this.getProperties(requestCookies, REQUEST_COOKIES) + ), + filterText, + targetSearchResult, + defaultSelectFirstNode: false, + selectPath: this.getTargetCookiePath, + renderRow: this.renderRow, + }, + header: REQUEST_COOKIES, + id: "requestCookies", + opened: true, + }); + } + + return div( + { className: "panel-container cookies-panel-container" }, + div( + { className: "devtools-toolbar devtools-input-toolbar" }, + SearchBox({ + delay: FILTER_SEARCH_DELAY, + type: "filter", + onChange: text => this.setState({ filterText: text }), + placeholder: COOKIES_FILTER_TEXT, + }) + ), + Accordion({ items }) + ); + } +} + +module.exports = CookiesPanel; 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); diff --git a/devtools/client/netmonitor/src/components/request-details/NetworkDetailsBar.js b/devtools/client/netmonitor/src/components/request-details/NetworkDetailsBar.js new file mode 100644 index 0000000000..8092eedc87 --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/NetworkDetailsBar.js @@ -0,0 +1,106 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.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 { + connect, +} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); +const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js"); +const { + getSelectedRequest, +} = require("resource://devtools/client/netmonitor/src/selectors/index.js"); + +// Components +loader.lazyGetter(this, "CustomRequestPanel", function () { + return createFactory( + require("resource://devtools/client/netmonitor/src/components/CustomRequestPanel.js") + ); +}); +loader.lazyGetter(this, "TabboxPanel", function () { + return createFactory( + require("resource://devtools/client/netmonitor/src/components/TabboxPanel.js") + ); +}); + +const { div } = dom; + +/** + * Network details panel component + */ +function NetworkDetailsBar({ + connector, + activeTabId, + cloneSelectedRequest, + request, + selectTab, + sourceMapURLService, + toggleNetworkDetails, + openNetworkDetails, + openLink, + targetSearchResult, +}) { + if (!request) { + return null; + } + + const newEditAndResendPref = Services.prefs.getBoolPref( + "devtools.netmonitor.features.newEditAndResend" + ); + + return div( + { className: "network-details-bar" }, + request.isCustom && !newEditAndResendPref + ? CustomRequestPanel({ + connector, + request, + }) + : TabboxPanel({ + activeTabId, + cloneSelectedRequest, + connector, + openLink, + request, + selectTab, + sourceMapURLService, + toggleNetworkDetails, + openNetworkDetails, + targetSearchResult, + }) + ); +} + +NetworkDetailsBar.displayName = "NetworkDetailsBar"; + +NetworkDetailsBar.propTypes = { + connector: PropTypes.object.isRequired, + activeTabId: PropTypes.string, + cloneSelectedRequest: PropTypes.func.isRequired, + open: PropTypes.bool, + request: PropTypes.object, + selectTab: PropTypes.func.isRequired, + sourceMapURLService: PropTypes.object, + toggleNetworkDetails: PropTypes.func.isRequired, + openLink: PropTypes.func, + targetSearchResult: PropTypes.object, +}; + +module.exports = connect( + state => ({ + activeTabId: state.ui.detailsPanelSelectedTab, + request: getSelectedRequest(state), + targetSearchResult: state.search.targetSearchResult, + }), + dispatch => ({ + cloneSelectedRequest: () => dispatch(Actions.cloneSelectedRequest()), + selectTab: tabId => dispatch(Actions.selectDetailsPanelTab(tabId)), + toggleNetworkDetails: () => dispatch(Actions.toggleNetworkDetails()), + openNetworkDetails: open => dispatch(Actions.openNetworkDetails(open)), + }) +)(NetworkDetailsBar); diff --git a/devtools/client/netmonitor/src/components/request-details/PropertiesView.js b/devtools/client/netmonitor/src/components/request-details/PropertiesView.js new file mode 100644 index 0000000000..759653c1aa --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/PropertiesView.js @@ -0,0 +1,247 @@ +/* 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/. */ + +/* eslint-disable react/prop-types */ + +"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 { + setTargetSearchResult, +} = require("resource://devtools/client/netmonitor/src/actions/search.js"); + +// Components +const TreeViewClass = require("resource://devtools/client/shared/components/tree/TreeView.js"); +const TreeView = createFactory(TreeViewClass); +const PropertiesViewContextMenu = require("resource://devtools/client/netmonitor/src/widgets/PropertiesViewContextMenu.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; +}); + +// Constants +const { + AUTO_EXPAND_MAX_LEVEL, + AUTO_EXPAND_MAX_NODES, +} = require("resource://devtools/client/netmonitor/src/constants.js"); + +const { div } = dom; + +/** + * Properties View component + * A scrollable tree view component which provides some useful features for + * representing object properties. + * + * Tree view + * Rep + */ +class PropertiesView extends Component { + static get propTypes() { + return { + object: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), + provider: PropTypes.object, + enableInput: PropTypes.bool, + expandableStrings: PropTypes.bool, + expandedNodes: PropTypes.object, + useBaseTreeViewExpand: PropTypes.bool, + filterText: PropTypes.string, + cropLimit: PropTypes.number, + targetSearchResult: PropTypes.object, + resetTargetSearchResult: PropTypes.func, + selectPath: PropTypes.func, + mode: PropTypes.symbol, + defaultSelectFirstNode: PropTypes.bool, + useQuotes: PropTypes.bool, + onClickRow: PropTypes.func, + contextMenuFormatters: PropTypes.object, + }; + } + + static get defaultProps() { + return { + enableInput: true, + enableFilter: true, + expandableStrings: false, + cropLimit: 1024, + useQuotes: true, + contextMenuFormatters: {}, + useBaseTreeViewExpand: false, + }; + } + + constructor(props) { + super(props); + this.onFilter = this.onFilter.bind(this); + this.renderValueWithRep = this.renderValueWithRep.bind(this); + this.getSelectedPath = this.getSelectedPath.bind(this); + } + + /** + * Update only if: + * 1) The rendered object has changed + * 2) The filter text has changed + * 3) The user selected another search result target. + */ + shouldComponentUpdate(nextProps) { + return ( + this.props.object !== nextProps.object || + this.props.filterText !== nextProps.filterText || + (this.props.targetSearchResult !== nextProps.targetSearchResult && + nextProps.targetSearchResult !== null) + ); + } + + onFilter(props) { + const { name, value } = props; + const { filterText } = this.props; + + if (!filterText) { + return true; + } + + const jsonString = JSON.stringify({ [name]: value }).toLowerCase(); + return jsonString.includes(filterText.toLowerCase()); + } + + getSelectedPath(targetSearchResult) { + if (!targetSearchResult) { + return null; + } + + return `/${targetSearchResult.label}`; + } + + /** + * If target is selected, let's scroll the content + * so the property is visible. This is used for search result navigation, + * which happens when the user clicks on a search result. + */ + scrollSelectedIntoView() { + const { targetSearchResult, resetTargetSearchResult, selectPath } = + this.props; + if (!targetSearchResult) { + return; + } + + const path = + typeof selectPath == "function" + ? selectPath(targetSearchResult) + : this.getSelectedPath(targetSearchResult); + const element = document.getElementById(path); + if (element) { + element.scrollIntoView({ block: "center" }); + } + + resetTargetSearchResult(); + } + + onContextMenuRow(member, evt) { + evt.preventDefault(); + + const { object } = member; + + // Select the right clicked row + this.selectRow({ props: { member } }); + + // if data exists and can be copied, then show the contextmenu + if (typeof object === "object") { + if (!this.contextMenu) { + this.contextMenu = new PropertiesViewContextMenu({ + customFormatters: this.props.contextMenuFormatters, + }); + } + this.contextMenu.open(evt, window.getSelection(), { + member, + object: this.props.object, + }); + } + } + + renderValueWithRep(props) { + const { member } = props; + + /* Hide strings with following conditions + * - the `value` object has a `value` property (only happens in Cookies panel) + */ + if (typeof member.value === "object" && member.value?.value) { + return null; + } + + return Rep( + Object.assign(props, { + // FIXME: A workaround for the issue in StringRep + // Force StringRep to crop the text every time + member: Object.assign({}, member, { open: false }), + mode: this.props.mode || MODE.TINY, + cropLimit: this.props.cropLimit, + noGrip: true, + }) + ); + } + + render() { + const { + useBaseTreeViewExpand, + expandedNodes, + object, + renderValue, + targetSearchResult, + selectPath, + } = this.props; + + let currentExpandedNodes; + // In the TreeView, when the component is re-rendered + // the state of `expandedNodes` is persisted by default + // e.g. when you open a node and filter the properties list, + // the node remains open. + // We have the prop `useBaseTreeViewExpand` to flag when we want to use + // this functionality or not. + if (!useBaseTreeViewExpand) { + currentExpandedNodes = + expandedNodes || + TreeViewClass.getExpandedNodes(object, { + maxLevel: AUTO_EXPAND_MAX_LEVEL, + maxNodes: AUTO_EXPAND_MAX_NODES, + }); + } + return div( + { className: "properties-view" }, + div( + { className: "tree-container" }, + TreeView({ + ...this.props, + ref: () => this.scrollSelectedIntoView(), + columns: [{ id: "value", width: "100%" }], + + expandedNodes: currentExpandedNodes, + + onFilter: props => this.onFilter(props), + renderValue: renderValue || this.renderValueWithRep, + onContextMenuRow: this.onContextMenuRow, + selected: + typeof selectPath == "function" + ? selectPath(targetSearchResult) + : this.getSelectedPath(targetSearchResult), + }) + ) + ); + } +} + +module.exports = connect(null, dispatch => ({ + resetTargetSearchResult: () => dispatch(setTargetSearchResult(null)), +}))(PropertiesView); diff --git a/devtools/client/netmonitor/src/components/request-details/RequestPanel.js b/devtools/client/netmonitor/src/components/request-details/RequestPanel.js new file mode 100644 index 0000000000..5306161d2e --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/RequestPanel.js @@ -0,0 +1,301 @@ +/* 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); 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; diff --git a/devtools/client/netmonitor/src/components/request-details/SecurityPanel.js b/devtools/client/netmonitor/src/components/request-details/SecurityPanel.js new file mode 100644 index 0000000000..74dca02cc8 --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/SecurityPanel.js @@ -0,0 +1,283 @@ +/* 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 PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const { + fetchNetworkUpdatePacket, + getUrlHost, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +// Components +const TreeViewClass = require("resource://devtools/client/shared/components/tree/TreeView.js"); +const PropertiesView = createFactory( + require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.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; +}); + +const { div, span } = dom; +const NOT_AVAILABLE = L10N.getStr("netmonitor.security.notAvailable"); +const ERROR_LABEL = L10N.getStr("netmonitor.security.error"); +const CIPHER_SUITE_LABEL = L10N.getStr("netmonitor.security.cipherSuite"); +const WARNING_CIPHER_LABEL = L10N.getStr("netmonitor.security.warning.cipher"); +const ENABLED_LABEL = L10N.getStr("netmonitor.security.enabled"); +const DISABLED_LABEL = L10N.getStr("netmonitor.security.disabled"); +const CONNECTION_LABEL = L10N.getStr("netmonitor.security.connection"); +const PROTOCOL_VERSION_LABEL = L10N.getStr( + "netmonitor.security.protocolVersion" +); +const KEA_GROUP_LABEL = L10N.getStr("netmonitor.security.keaGroup"); +const KEA_GROUP_NONE = L10N.getStr("netmonitor.security.keaGroup.none"); +const KEA_GROUP_CUSTOM = L10N.getStr("netmonitor.security.keaGroup.custom"); +const KEA_GROUP_UNKNOWN = L10N.getStr("netmonitor.security.keaGroup.unknown"); +const SIGNATURE_SCHEME_LABEL = L10N.getStr( + "netmonitor.security.signatureScheme" +); +const SIGNATURE_SCHEME_NONE = L10N.getStr( + "netmonitor.security.signatureScheme.none" +); +const SIGNATURE_SCHEME_UNKNOWN = L10N.getStr( + "netmonitor.security.signatureScheme.unknown" +); +const HSTS_LABEL = L10N.getStr("netmonitor.security.hsts"); +const HPKP_LABEL = L10N.getStr("netmonitor.security.hpkp"); +const CERTIFICATE_LABEL = L10N.getStr("netmonitor.security.certificate"); +const CERTIFICATE_TRANSPARENCY_LABEL = L10N.getStr( + "certmgr.certificateTransparency.label" +); +const CERTIFICATE_TRANSPARENCY_POLICY_COMPLIANT = L10N.getStr( + "certmgr.certificateTransparency.status.ok" +); +const CERTIFICATE_TRANSPARENCY_POLICY_NOT_ENOUGH_SCTS = L10N.getStr( + "certmgr.certificateTransparency.status.notEnoughSCTS" +); +const CERTIFICATE_TRANSPARENCY_POLICY_NOT_DIVERSE_SCTS = L10N.getStr( + "certmgr.certificateTransparency.status.notDiverseSCTS" +); +const SUBJECT_INFO_LABEL = L10N.getStr("certmgr.subjectinfo.label"); +const CERT_DETAIL_COMMON_NAME_LABEL = L10N.getStr("certmgr.certdetail.cn"); +const CERT_DETAIL_ORG_LABEL = L10N.getStr("certmgr.certdetail.o"); +const CERT_DETAIL_ORG_UNIT_LABEL = L10N.getStr("certmgr.certdetail.ou"); +const ISSUER_INFO_LABEL = L10N.getStr("certmgr.issuerinfo.label"); +const PERIOD_OF_VALIDITY_LABEL = L10N.getStr("certmgr.periodofvalidity.label"); +const BEGINS_LABEL = L10N.getStr("certmgr.begins"); +const EXPIRES_LABEL = L10N.getStr("certmgr.expires"); +const FINGERPRINTS_LABEL = L10N.getStr("certmgr.fingerprints.label"); +const SHA256_FINGERPRINT_LABEL = L10N.getStr( + "certmgr.certdetail.sha256fingerprint" +); +const SHA1_FINGERPRINT_LABEL = L10N.getStr( + "certmgr.certdetail.sha1fingerprint" +); + +/* + * Localize special values for key exchange group name, + * certificate signature scheme, and certificate + * transparency status. + */ +const formatSecurityInfo = securityInfo => { + const formattedSecurityInfo = { ...securityInfo }; + + const formatters = { + keaGroupName: value => { + if (value === "none") { + return KEA_GROUP_NONE; + } + if (value === "custom") { + return KEA_GROUP_CUSTOM; + } + if (value === "unknown group") { + return KEA_GROUP_UNKNOWN; + } + return value; + }, + signatureSchemeName: value => { + if (value === "none") { + return SIGNATURE_SCHEME_NONE; + } + if (value === "unknown signature") { + return SIGNATURE_SCHEME_UNKNOWN; + } + return value; + }, + certificateTransparency: value => { + if (value === 5) { + return CERTIFICATE_TRANSPARENCY_POLICY_COMPLIANT; + } + if (value === 6) { + return CERTIFICATE_TRANSPARENCY_POLICY_NOT_ENOUGH_SCTS; + } + if (value === 7) { + return CERTIFICATE_TRANSPARENCY_POLICY_NOT_DIVERSE_SCTS; + } + return value; + }, + }; + + return Object.keys(formatters).reduce((acc, key) => { + const formatter = formatters[key]; + acc[key] = formatter(acc[key]); + return acc; + }, formattedSecurityInfo); +}; + +const getConnectionLabel = securityInfo => ({ + [PROTOCOL_VERSION_LABEL]: securityInfo.protocolVersion || NOT_AVAILABLE, + [CIPHER_SUITE_LABEL]: securityInfo.cipherSuite || NOT_AVAILABLE, + [KEA_GROUP_LABEL]: securityInfo.keaGroupName || NOT_AVAILABLE, + [SIGNATURE_SCHEME_LABEL]: securityInfo.signatureSchemeName || NOT_AVAILABLE, +}); + +const getHostHeaderLabel = securityInfo => ({ + [HSTS_LABEL]: securityInfo.hsts ? ENABLED_LABEL : DISABLED_LABEL, + [HPKP_LABEL]: securityInfo.hpkp ? ENABLED_LABEL : DISABLED_LABEL, +}); + +const getCertificateLabel = securityInfo => { + const { fingerprint, issuer, subject, validity } = securityInfo.cert; + + return { + [SUBJECT_INFO_LABEL]: { + [CERT_DETAIL_COMMON_NAME_LABEL]: subject?.commonName || NOT_AVAILABLE, + [CERT_DETAIL_ORG_LABEL]: subject?.organization || NOT_AVAILABLE, + [CERT_DETAIL_ORG_UNIT_LABEL]: subject?.organizationUnit || NOT_AVAILABLE, + }, + [ISSUER_INFO_LABEL]: { + [CERT_DETAIL_COMMON_NAME_LABEL]: issuer?.commonName || NOT_AVAILABLE, + [CERT_DETAIL_ORG_LABEL]: issuer?.organization || NOT_AVAILABLE, + [CERT_DETAIL_ORG_UNIT_LABEL]: issuer?.organizationUnit || NOT_AVAILABLE, + }, + [PERIOD_OF_VALIDITY_LABEL]: { + [BEGINS_LABEL]: validity?.start || NOT_AVAILABLE, + [EXPIRES_LABEL]: validity?.end || NOT_AVAILABLE, + }, + [FINGERPRINTS_LABEL]: { + [SHA256_FINGERPRINT_LABEL]: fingerprint?.sha256 || NOT_AVAILABLE, + [SHA1_FINGERPRINT_LABEL]: fingerprint?.sha1 || NOT_AVAILABLE, + }, + [CERTIFICATE_TRANSPARENCY_LABEL]: + securityInfo.certificateTransparency || NOT_AVAILABLE, + }; +}; + +const getObject = ({ securityInfo, url }) => { + if (securityInfo.state !== "secure" && securityInfo.state !== "weak") { + return { + [ERROR_LABEL]: securityInfo.errorMessage || NOT_AVAILABLE, + }; + } + + const HOST_HEADER_LABEL = L10N.getFormatStr( + "netmonitor.security.hostHeader", + getUrlHost(url) + ); + const formattedSecurityInfo = formatSecurityInfo(securityInfo); + + return { + [CONNECTION_LABEL]: getConnectionLabel(formattedSecurityInfo), + [HOST_HEADER_LABEL]: getHostHeaderLabel(formattedSecurityInfo), + [CERTIFICATE_LABEL]: getCertificateLabel(formattedSecurityInfo), + }; +}; + +/* + * Security panel component + * If the site is being served over HTTPS, you get an extra tab labeled "Security". + * This contains details about the secure connection used including the protocol, + * the cipher suite, and certificate details + */ +class SecurityPanel extends Component { + static get propTypes() { + return { + connector: PropTypes.object.isRequired, + openLink: PropTypes.func, + request: PropTypes.object.isRequired, + }; + } + + componentDidMount() { + const { request, connector } = this.props; + fetchNetworkUpdatePacket(connector.requestData, request, ["securityInfo"]); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { request, connector } = nextProps; + fetchNetworkUpdatePacket(connector.requestData, request, ["securityInfo"]); + } + + renderValue(props, weaknessReasons = []) { + const { member, value } = props; + + // Hide object summary + if (typeof member.value === "object") { + return null; + } + + return span( + { className: "security-info-value" }, + member.name === ERROR_LABEL + ? // Display multiline text for security error for a label using a rep. + value + : 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, + cropLimit: 60, + noGrip: true, + }) + ), + weaknessReasons.includes("cipher") && member.name === CIPHER_SUITE_LABEL + ? // Display an extra warning icon after the cipher suite + div({ + id: "security-warning-cipher", + className: "security-warning-icon", + title: WARNING_CIPHER_LABEL, + }) + : null + ); + } + + render() { + const { request } = this.props; + const { securityInfo, url } = request; + + if (!securityInfo || !url) { + return null; + } + + const object = getObject({ securityInfo, url }); + return div( + { className: "panel-container security-panel" }, + PropertiesView({ + object, + renderValue: props => + this.renderValue(props, securityInfo.weaknessReasons), + enableFilter: false, + expandedNodes: TreeViewClass.getExpandedNodes(object), + }) + ); + } +} + +module.exports = SecurityPanel; diff --git a/devtools/client/netmonitor/src/components/request-details/StackTracePanel.js b/devtools/client/netmonitor/src/components/request-details/StackTracePanel.js new file mode 100644 index 0000000000..6b01ee27b7 --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/StackTracePanel.js @@ -0,0 +1,81 @@ +/* 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 { + fetchNetworkUpdatePacket, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +const { div } = dom; + +// Components +const StackTrace = createFactory( + require("resource://devtools/client/shared/components/StackTrace.js") +); + +/** + * This component represents a side panel responsible for + * rendering stack-trace info for selected request. + */ +class StackTracePanel extends Component { + static get propTypes() { + return { + connector: PropTypes.object.isRequired, + request: PropTypes.object.isRequired, + sourceMapURLService: PropTypes.object, + openLink: PropTypes.func, + }; + } + + /** + * `componentDidMount` is called when opening the StackTracePanel + * for the first time + */ + componentDidMount() { + const { request, connector } = this.props; + if (!request.stacktrace) { + fetchNetworkUpdatePacket(connector.requestData, request, ["stackTrace"]); + } + } + + /** + * `componentWillReceiveProps` is the only method called when + * switching between two requests while this panel is displayed. + */ + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { request, connector } = nextProps; + // Only try to fetch the stacktrace if we don't already have the stacktrace yet + if (!request.stacktrace) { + fetchNetworkUpdatePacket(connector.requestData, request, ["stackTrace"]); + } + } + + render() { + const { connector, openLink, request, sourceMapURLService } = this.props; + + const { stacktrace } = request; + + return div( + { className: "panel-container" }, + StackTrace({ + stacktrace: stacktrace || [], + onViewSourceInDebugger: ({ url, line, column }) => { + return connector.viewSourceInDebugger(url, line, column); + }, + sourceMapURLService, + openLink, + }) + ); + } +} + +module.exports = StackTracePanel; diff --git a/devtools/client/netmonitor/src/components/request-details/TimingsPanel.js b/devtools/client/netmonitor/src/components/request-details/TimingsPanel.js new file mode 100644 index 0000000000..30053f09ea --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/TimingsPanel.js @@ -0,0 +1,229 @@ +/* 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 { + connect, +} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); +const { + Component, +} = require("resource://devtools/client/shared/vendor/react.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 { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const { + getNetMonitorTimingsURL, +} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js"); +const { + fetchNetworkUpdatePacket, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); +const { + getFormattedTime, +} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js"); +const { + TIMING_KEYS, +} = require("resource://devtools/client/netmonitor/src/constants.js"); + +// Components +const MDNLink = require("resource://devtools/client/shared/components/MdnLink.js"); + +const { div, span } = dom; + +const TIMINGS_END_PADDING = "80px"; + +/** + * Timings panel component + * Display timeline bars that shows the total wait time for various stages + */ +class TimingsPanel extends Component { + static get propTypes() { + return { + connector: PropTypes.object.isRequired, + request: PropTypes.object.isRequired, + firstRequestStartedMs: PropTypes.number.isRequired, + }; + } + + componentDidMount() { + const { connector, request } = this.props; + fetchNetworkUpdatePacket(connector.requestData, request, ["eventTimings"]); + } + + // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 + UNSAFE_componentWillReceiveProps(nextProps) { + const { connector, request } = nextProps; + fetchNetworkUpdatePacket(connector.requestData, request, ["eventTimings"]); + } + + renderServerTimings() { + const { serverTimings, totalTime } = this.props.request.eventTimings; + + if (!serverTimings?.length) { + return null; + } + + return div( + {}, + div( + { className: "label-separator" }, + L10N.getStr("netmonitor.timings.serverTiming") + ), + ...serverTimings.map(({ name, duration, description }, index) => { + const color = name === "total" ? "total" : (index % 3) + 1; + + return div( + { + key: index, + className: "tabpanel-summary-container timings-container server", + }, + span( + { className: "tabpanel-summary-label timings-label" }, + description || name + ), + div( + { className: "requests-list-timings-container" }, + span({ + className: "requests-list-timings-offset", + style: { + width: `calc(${ + (totalTime - duration) / totalTime + } * (100% - ${TIMINGS_END_PADDING})`, + }, + }), + span({ + className: `requests-list-timings-box server-timings-color-${color}`, + style: { + width: `calc(${ + duration / totalTime + } * (100% - ${TIMINGS_END_PADDING}))`, + }, + }), + span( + { className: "requests-list-timings-total" }, + getFormattedTime(duration) + ) + ) + ); + }) + ); + } + + render() { + const { eventTimings, totalTime, startedMs } = this.props.request; + const { firstRequestStartedMs } = this.props; + + if (!eventTimings) { + return div( + { + className: + "tabpanel-summary-container timings-container empty-notice", + }, + L10N.getStr("netmonitor.timings.noTimings") + ); + } + + const { timings, offsets } = eventTimings; + let queuedAt, startedAt, downloadedAt; + const isFirstRequestStartedAvailable = firstRequestStartedMs !== null; + + if (isFirstRequestStartedAvailable) { + queuedAt = startedMs - firstRequestStartedMs; + startedAt = queuedAt + timings.blocked; + downloadedAt = queuedAt + totalTime; + } + + const timelines = TIMING_KEYS.map((type, idx) => { + // Determine the relative offset for each timings box. For example, the + // offset of third timings box will be 0 + blocked offset + dns offset + // If offsets sent from the backend aren't available calculate it + // from the timing info. + const offset = offsets + ? offsets[type] + : TIMING_KEYS.slice(0, idx).reduce( + (acc, cur) => acc + timings[cur] || 0, + 0 + ); + + const offsetScale = offset / totalTime || 0; + const timelineScale = timings[type] / totalTime || 0; + + return div( + { + key: type, + id: `timings-summary-${type}`, + className: "tabpanel-summary-container timings-container request", + }, + span( + { className: "tabpanel-summary-label timings-label" }, + L10N.getStr(`netmonitor.timings.${type}`) + ), + div( + { className: "requests-list-timings-container" }, + span({ + className: "requests-list-timings-offset", + style: { + width: `calc(${offsetScale} * (100% - ${TIMINGS_END_PADDING})`, + }, + }), + span({ + className: `requests-list-timings-box ${type}`, + style: { + width: `calc(${timelineScale} * (100% - ${TIMINGS_END_PADDING}))`, + }, + }), + span( + { className: "requests-list-timings-total" }, + getFormattedTime(timings[type]) + ) + ) + ); + }); + + return div( + { className: "panel-container" }, + isFirstRequestStartedAvailable && + div( + { className: "timings-overview" }, + span( + { className: "timings-overview-item" }, + L10N.getFormatStr( + "netmonitor.timings.queuedAt", + getFormattedTime(queuedAt) + ) + ), + span( + { className: "timings-overview-item" }, + L10N.getFormatStr( + "netmonitor.timings.startedAt", + getFormattedTime(startedAt) + ) + ), + span( + { className: "timings-overview-item" }, + L10N.getFormatStr( + "netmonitor.timings.downloadedAt", + getFormattedTime(downloadedAt) + ) + ) + ), + div( + { className: "label-separator" }, + L10N.getStr("netmonitor.timings.requestTiming") + ), + timelines, + this.renderServerTimings(), + MDNLink({ + url: getNetMonitorTimingsURL(), + title: L10N.getStr("netmonitor.timings.learnMore"), + }) + ); + } +} + +module.exports = connect(state => ({ + firstRequestStartedMs: state.requests ? state.requests.firstStartedMs : null, +}))(TimingsPanel); diff --git a/devtools/client/netmonitor/src/components/request-details/moz.build b/devtools/client/netmonitor/src/components/request-details/moz.build new file mode 100644 index 0000000000..09896f2de3 --- /dev/null +++ b/devtools/client/netmonitor/src/components/request-details/moz.build @@ -0,0 +1,16 @@ +# 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/. + +DevToolsModules( + "CachePanel.js", + "CookiesPanel.js", + "HeadersPanel.js", + "NetworkDetailsBar.js", + "PropertiesView.js", + "RequestPanel.js", + "ResponsePanel.js", + "SecurityPanel.js", + "StackTracePanel.js", + "TimingsPanel.js", +) |