diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/client/netmonitor/src/widgets/RequestListContextMenu.js | 793 |
1 files changed, 793 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/widgets/RequestListContextMenu.js b/devtools/client/netmonitor/src/widgets/RequestListContextMenu.js new file mode 100644 index 0000000000..1702cdbc64 --- /dev/null +++ b/devtools/client/netmonitor/src/widgets/RequestListContextMenu.js @@ -0,0 +1,793 @@ +/* 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 { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const { + formDataURI, + getUrlQuery, + getUrlBaseName, + parseQueryString, + getRequestHeadersRawText, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); +const { + hasMatchingBlockingRequestPattern, +} = require("resource://devtools/client/netmonitor/src/utils/request-blocking.js"); + +loader.lazyRequireGetter( + this, + "Curl", + "resource://devtools/client/shared/curl.js", + true +); +loader.lazyRequireGetter( + this, + "saveAs", + "resource://devtools/shared/DevToolsUtils.js", + true +); +loader.lazyRequireGetter( + this, + "PowerShell", + "resource://devtools/client/netmonitor/src/utils/powershell.js", + true +); +loader.lazyRequireGetter( + this, + "copyString", + "resource://devtools/shared/platform/clipboard.js", + true +); +loader.lazyRequireGetter( + this, + "showMenu", + "resource://devtools/client/shared/components/menu/utils.js", + true +); +loader.lazyRequireGetter( + this, + "HarMenuUtils", + "resource://devtools/client/netmonitor/src/har/har-menu-utils.js", + true +); + +const { OS } = Services.appinfo; + +class RequestListContextMenu { + constructor(props) { + this.props = props; + } + + createCopySubMenu(clickedRequest, requests) { + const { connector } = this.props; + + const { + id, + formDataSections, + method, + mimeType, + httpVersion, + requestHeaders, + requestHeadersAvailable, + requestPostData, + requestPostDataAvailable, + responseHeaders, + responseHeadersAvailable, + responseContent, + responseContentAvailable, + url, + } = clickedRequest; + + const copySubMenu = []; + + copySubMenu.push({ + id: "request-list-context-copy-url", + label: L10N.getStr("netmonitor.context.copyUrl"), + accesskey: L10N.getStr("netmonitor.context.copyUrl.accesskey"), + visible: !!clickedRequest, + click: () => this.copyUrl(url), + }); + + copySubMenu.push({ + id: "request-list-context-copy-url-params", + label: L10N.getStr("netmonitor.context.copyUrlParams"), + accesskey: L10N.getStr("netmonitor.context.copyUrlParams.accesskey"), + visible: !!(clickedRequest && getUrlQuery(url)), + click: () => this.copyUrlParams(url), + }); + + copySubMenu.push({ + id: "request-list-context-copy-post-data", + label: L10N.getFormatStr("netmonitor.context.copyRequestData", method), + accesskey: L10N.getStr("netmonitor.context.copyRequestData.accesskey"), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!( + clickedRequest && + (requestPostDataAvailable || requestPostData) + ), + click: () => this.copyPostData(id, formDataSections, requestPostData), + }); + + if (OS === "WINNT") { + copySubMenu.push({ + id: "request-list-context-copy-as-curl-win", + label: L10N.getFormatStr( + "netmonitor.context.copyAsCurl.win", + L10N.getStr("netmonitor.context.copyAsCurl") + ), + accesskey: L10N.getStr("netmonitor.context.copyAsCurl.win.accesskey"), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!clickedRequest, + click: () => + this.copyAsCurl( + id, + url, + method, + httpVersion, + requestHeaders, + requestPostData, + responseHeaders, + "WINNT" + ), + }); + + copySubMenu.push({ + id: "request-list-context-copy-as-curl-posix", + label: L10N.getFormatStr( + "netmonitor.context.copyAsCurl.posix", + L10N.getStr("netmonitor.context.copyAsCurl") + ), + accesskey: L10N.getStr("netmonitor.context.copyAsCurl.posix.accesskey"), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!clickedRequest, + click: () => + this.copyAsCurl( + id, + url, + method, + httpVersion, + requestHeaders, + requestPostData, + responseHeaders, + "Linux" + ), + }); + } else { + copySubMenu.push({ + id: "request-list-context-copy-as-curl", + label: L10N.getStr("netmonitor.context.copyAsCurl"), + accesskey: L10N.getStr("netmonitor.context.copyAsCurl.accesskey"), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!clickedRequest, + click: () => + this.copyAsCurl( + id, + url, + method, + httpVersion, + requestHeaders, + requestPostData, + responseHeaders + ), + }); + } + + copySubMenu.push({ + id: "request-list-context-copy-as-powershell", + label: L10N.getStr("netmonitor.context.copyAsPowerShell"), + accesskey: L10N.getStr("netmonitor.context.copyAsPowerShell.accesskey"), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!clickedRequest, + click: () => this.copyAsPowerShell(clickedRequest), + }); + + copySubMenu.push({ + id: "request-list-context-copy-as-fetch", + label: L10N.getStr("netmonitor.context.copyAsFetch"), + accesskey: L10N.getStr("netmonitor.context.copyAsFetch.accesskey"), + visible: !!clickedRequest, + click: () => + this.copyAsFetch(id, url, method, requestHeaders, requestPostData), + }); + + copySubMenu.push({ + type: "separator", + visible: copySubMenu.slice(0, 4).some(subMenu => subMenu.visible), + }); + + copySubMenu.push({ + id: "request-list-context-copy-request-headers", + label: L10N.getStr("netmonitor.context.copyRequestHeaders"), + accesskey: L10N.getStr("netmonitor.context.copyRequestHeaders.accesskey"), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!( + clickedRequest && + (requestHeadersAvailable || requestHeaders) + ), + click: () => this.copyRequestHeaders(id, clickedRequest), + }); + + copySubMenu.push({ + id: "response-list-context-copy-response-headers", + label: L10N.getStr("netmonitor.context.copyResponseHeaders"), + accesskey: L10N.getStr( + "netmonitor.context.copyResponseHeaders.accesskey" + ), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!( + clickedRequest && + (responseHeadersAvailable || responseHeaders) + ), + click: () => this.copyResponseHeaders(id, responseHeaders), + }); + + copySubMenu.push({ + id: "request-list-context-copy-response", + label: L10N.getStr("netmonitor.context.copyResponse"), + accesskey: L10N.getStr("netmonitor.context.copyResponse.accesskey"), + // Menu item will be visible even if data hasn't arrived, so we need to check + // *Available property and then fetch data lazily once user triggers the action. + visible: !!( + clickedRequest && + (responseContentAvailable || responseContent) + ), + click: () => this.copyResponse(id, responseContent), + }); + + copySubMenu.push({ + id: "request-list-context-copy-image-as-data-uri", + label: L10N.getStr("netmonitor.context.copyImageAsDataUri"), + accesskey: L10N.getStr("netmonitor.context.copyImageAsDataUri.accesskey"), + visible: !!( + clickedRequest && + (responseContentAvailable || responseContent) && + mimeType && + mimeType.includes("image/") + ), + click: () => this.copyImageAsDataUri(id, mimeType, responseContent), + }); + + copySubMenu.push({ + type: "separator", + visible: copySubMenu.slice(5, 9).some(subMenu => subMenu.visible), + }); + + copySubMenu.push({ + id: "request-list-context-copy-all-as-har", + label: L10N.getStr("netmonitor.context.copyAllAsHar"), + accesskey: L10N.getStr("netmonitor.context.copyAllAsHar.accesskey"), + visible: !!requests.length, + click: () => HarMenuUtils.copyAllAsHar(requests, connector), + }); + + return copySubMenu; + } + + createMenu(clickedRequest, requests, blockedUrls) { + const { + connector, + cloneRequest, + openDetailsPanelTab, + openHTTPCustomRequestTab, + closeHTTPCustomRequestTab, + sendCustomRequest, + sendHTTPCustomRequest, + openStatistics, + openRequestInTab, + openRequestBlockingAndAddUrl, + openRequestBlockingAndDisableUrls, + removeBlockedUrl, + } = this.props; + + const { + id, + isCustom, + method, + mimeType, + requestHeaders, + requestPostData, + responseContent, + responseContentAvailable, + url, + } = clickedRequest; + + const copySubMenu = this.createCopySubMenu(clickedRequest, requests); + const newEditAndResendPref = Services.prefs.getBoolPref( + "devtools.netmonitor.features.newEditAndResend" + ); + + return [ + { + label: L10N.getStr("netmonitor.context.copyValue"), + accesskey: L10N.getStr("netmonitor.context.copyValue.accesskey"), + visible: !!clickedRequest, + submenu: copySubMenu, + }, + { + id: "request-list-context-save-all-as-har", + label: L10N.getStr("netmonitor.context.saveAllAsHar"), + accesskey: L10N.getStr("netmonitor.context.saveAllAsHar.accesskey"), + visible: !!requests.length, + click: () => HarMenuUtils.saveAllAsHar(requests, connector), + }, + { + id: "request-list-context-save-image-as", + label: L10N.getStr("netmonitor.context.saveImageAs"), + accesskey: L10N.getStr("netmonitor.context.saveImageAs.accesskey"), + visible: !!( + clickedRequest && + (responseContentAvailable || responseContent) && + mimeType && + mimeType.includes("image/") + ), + click: () => this.saveImageAs(id, url, responseContent), + }, + { + type: "separator", + visible: copySubMenu.slice(10, 14).some(subMenu => subMenu.visible), + }, + { + id: "request-list-context-resend-only", + label: L10N.getStr("netmonitor.context.resend.label"), + accesskey: L10N.getStr("netmonitor.context.resend.accesskey"), + visible: !!(clickedRequest && !isCustom), + click: () => { + if (!newEditAndResendPref) { + cloneRequest(id); + sendCustomRequest(); + } else { + sendHTTPCustomRequest(clickedRequest); + } + }, + }, + + { + id: "request-list-context-edit-resend", + label: L10N.getStr("netmonitor.context.editAndResend"), + accesskey: L10N.getStr("netmonitor.context.editAndResend.accesskey"), + visible: !!(clickedRequest && !isCustom), + click: () => { + this.fetchRequestHeaders(id).then(() => { + if (!newEditAndResendPref) { + cloneRequest(id); + openDetailsPanelTab(); + } else { + closeHTTPCustomRequestTab(); + openHTTPCustomRequestTab(); + } + }); + }, + }, + { + id: "request-list-context-block-url", + label: L10N.getStr("netmonitor.context.blockURL"), + visible: !hasMatchingBlockingRequestPattern( + blockedUrls, + clickedRequest.url + ), + click: () => { + openRequestBlockingAndAddUrl(clickedRequest.url); + }, + }, + { + id: "request-list-context-unblock-url", + label: L10N.getStr("netmonitor.context.unblockURL"), + visible: hasMatchingBlockingRequestPattern( + blockedUrls, + clickedRequest.url + ), + click: () => { + if ( + blockedUrls.find(blockedUrl => blockedUrl === clickedRequest.url) + ) { + removeBlockedUrl(clickedRequest.url); + } else { + openRequestBlockingAndDisableUrls(clickedRequest.url); + } + }, + }, + { + type: "separator", + visible: copySubMenu.slice(15, 16).some(subMenu => subMenu.visible), + }, + { + id: "request-list-context-newtab", + label: L10N.getStr("netmonitor.context.newTab"), + accesskey: L10N.getStr("netmonitor.context.newTab.accesskey"), + visible: !!clickedRequest, + click: () => openRequestInTab(id, url, requestHeaders, requestPostData), + }, + { + id: "request-list-context-open-in-debugger", + label: L10N.getStr("netmonitor.context.openInDebugger"), + accesskey: L10N.getStr("netmonitor.context.openInDebugger.accesskey"), + visible: !!( + clickedRequest && + mimeType && + mimeType.includes("javascript") + ), + click: () => this.openInDebugger(url), + }, + { + id: "request-list-context-open-in-style-editor", + label: L10N.getStr("netmonitor.context.openInStyleEditor"), + accesskey: L10N.getStr( + "netmonitor.context.openInStyleEditor.accesskey" + ), + visible: !!( + clickedRequest && + Services.prefs.getBoolPref("devtools.styleeditor.enabled") && + mimeType && + mimeType.includes("css") + ), + click: () => this.openInStyleEditor(url), + }, + { + id: "request-list-context-perf", + label: L10N.getStr("netmonitor.context.perfTools"), + accesskey: L10N.getStr("netmonitor.context.perfTools.accesskey"), + visible: !!requests.length, + click: () => openStatistics(true), + }, + { + type: "separator", + }, + { + id: "request-list-context-use-as-fetch", + label: L10N.getStr("netmonitor.context.useAsFetch"), + accesskey: L10N.getStr("netmonitor.context.useAsFetch.accesskey"), + visible: !!clickedRequest, + click: () => + this.useAsFetch(id, url, method, requestHeaders, requestPostData), + }, + ]; + } + + open(event, clickedRequest, requests, blockedUrls) { + const menu = this.createMenu(clickedRequest, requests, blockedUrls); + + showMenu(menu, { + screenX: event.screenX, + screenY: event.screenY, + }); + } + + /** + * Opens selected item in the debugger + */ + openInDebugger(url) { + const toolbox = this.props.connector.getToolbox(); + toolbox.viewGeneratedSourceInDebugger(url); + } + + /** + * Opens selected item in the style editor + */ + openInStyleEditor(url) { + const toolbox = this.props.connector.getToolbox(); + toolbox.viewGeneratedSourceInStyleEditor(url); + } + + /** + * Copy the request url from the currently selected item. + */ + copyUrl(url) { + copyString(url); + } + + /** + * Copy the request url query string parameters from the currently + * selected item. + */ + copyUrlParams(url) { + const params = getUrlQuery(url).split("&"); + copyString(params.join(Services.appinfo.OS === "WINNT" ? "\r\n" : "\n")); + } + + /** + * Copy the request form data parameters (or raw payload) from + * the currently selected item. + */ + async copyPostData(id, formDataSections, requestPostData) { + let params = []; + // Try to extract any form data parameters if formDataSections is already + // available, which is only true if RequestPanel has ever been mounted before. + if (formDataSections) { + formDataSections.forEach(section => { + const paramsArray = parseQueryString(section); + if (paramsArray) { + params = [...params, ...paramsArray]; + } + }); + } + + let string = params + .map(param => param.name + (param.value ? "=" + param.value : "")) + .join(Services.appinfo.OS === "WINNT" ? "\r\n" : "\n"); + + // Fall back to raw payload. + if (!string) { + requestPostData = + requestPostData || + (await this.props.connector.requestData(id, "requestPostData")); + + string = requestPostData.postData.text; + if (Services.appinfo.OS !== "WINNT") { + string = string.replace(/\r/g, ""); + } + } + copyString(string); + } + + /** + * Copy a cURL command from the currently selected item. + */ + async copyAsCurl( + id, + url, + method, + httpVersion, + requestHeaders, + requestPostData, + responseHeaders, + platform + ) { + requestHeaders = + requestHeaders || + (await this.props.connector.requestData(id, "requestHeaders")); + + requestPostData = + requestPostData || + (await this.props.connector.requestData(id, "requestPostData")); + + responseHeaders = + responseHeaders || + (await this.props.connector.requestData(id, "responseHeaders")); + + // Create a sanitized object for the Curl command generator. + const data = { + url, + method, + headers: requestHeaders.headers, + responseHeaders: responseHeaders.headers, + httpVersion, + postDataText: requestPostData ? requestPostData.postData.text : "", + }; + copyString(Curl.generateCommand(data, platform)); + } + + async copyAsPowerShell(request) { + let { id, url, method, requestHeaders, requestPostData, requestCookies } = + request; + + requestHeaders = + requestHeaders || + (await this.props.connector.requestData(id, "requestHeaders")); + + requestPostData = + requestPostData || + (await this.props.connector.requestData(id, "requestPostData")); + + requestCookies = + requestCookies || + (await this.props.connector.requestData(id, "requestCookies")); + + return copyString( + PowerShell.generateCommand( + url, + method, + requestHeaders.headers, + requestPostData.postData, + requestCookies.cookies || requestCookies + ) + ); + } + + /** + * Generate fetch string + */ + async generateFetchString(id, url, method, requestHeaders, requestPostData) { + requestHeaders = + requestHeaders || + (await this.props.connector.requestData(id, "requestHeaders")); + + requestPostData = + requestPostData || + (await this.props.connector.requestData(id, "requestPostData")); + + // https://fetch.spec.whatwg.org/#forbidden-header-name + const forbiddenHeaders = { + "accept-charset": 1, + "accept-encoding": 1, + "access-control-request-headers": 1, + "access-control-request-method": 1, + connection: 1, + "content-length": 1, + cookie: 1, + cookie2: 1, + date: 1, + dnt: 1, + expect: 1, + host: 1, + "keep-alive": 1, + origin: 1, + referer: 1, + te: 1, + trailer: 1, + "transfer-encoding": 1, + upgrade: 1, + via: 1, + }; + const credentialHeaders = { cookie: 1, authorization: 1 }; + + const headers = {}; + for (const { name, value } of requestHeaders.headers) { + if (!forbiddenHeaders[name.toLowerCase()]) { + headers[name] = value; + } + } + + const referrerHeader = requestHeaders.headers.find( + ({ name }) => name.toLowerCase() === "referer" + ); + + const referrerPolicy = requestHeaders.headers.find( + ({ name }) => name.toLowerCase() === "referrer-policy" + ); + + const referrer = referrerHeader ? referrerHeader.value : undefined; + const credentials = requestHeaders.headers.some( + ({ name }) => credentialHeaders[name.toLowerCase()] + ) + ? "include" + : "omit"; + + const fetchOptions = { + credentials, + headers, + referrer, + referrerPolicy, + body: requestPostData.postData.text, + method, + mode: "cors", + }; + + const options = JSON.stringify(fetchOptions, null, 4); + const fetchString = `await fetch("${url}", ${options});`; + return fetchString; + } + + /** + * Copy the currently selected item as fetch request. + */ + async copyAsFetch(id, url, method, requestHeaders, requestPostData) { + const fetchString = await this.generateFetchString( + id, + url, + method, + requestHeaders, + requestPostData + ); + copyString(fetchString); + } + + /** + * Open split console and fill it with fetch command for selected item + */ + async useAsFetch(id, url, method, requestHeaders, requestPostData) { + const fetchString = await this.generateFetchString( + id, + url, + method, + requestHeaders, + requestPostData + ); + const toolbox = this.props.connector.getToolbox(); + await toolbox.openSplitConsole(); + const { hud } = await toolbox.getPanel("webconsole"); + hud.setInputValue(fetchString); + } + + /** + * Copy the raw request headers from the currently selected item. + */ + async copyRequestHeaders( + id, + { method, httpVersion, requestHeaders, urlDetails } + ) { + requestHeaders = + requestHeaders || + (await this.props.connector.requestData(id, "requestHeaders")); + + let rawHeaders = getRequestHeadersRawText( + method, + httpVersion, + requestHeaders, + urlDetails + ); + + if (Services.appinfo.OS !== "WINNT") { + rawHeaders = rawHeaders.replace(/\r/g, ""); + } + copyString(rawHeaders); + } + + /** + * Copy the raw response headers from the currently selected item. + */ + async copyResponseHeaders(id, responseHeaders) { + responseHeaders = + responseHeaders || + (await this.props.connector.requestData(id, "responseHeaders")); + + let rawHeaders = responseHeaders.rawHeaders.trim(); + + if (Services.appinfo.OS !== "WINNT") { + rawHeaders = rawHeaders.replace(/\r/g, ""); + } + copyString(rawHeaders); + } + + /** + * Copy image as data uri. + */ + async copyImageAsDataUri(id, mimeType, responseContent) { + responseContent = + responseContent || + (await this.props.connector.requestData(id, "responseContent")); + + const { encoding, text } = responseContent.content; + copyString(formDataURI(mimeType, encoding, text)); + } + + /** + * Save image as. + */ + async saveImageAs(id, url, responseContent) { + responseContent = + responseContent || + (await this.props.connector.requestData(id, "responseContent")); + + const { encoding, text } = responseContent.content; + const fileName = getUrlBaseName(url); + let data; + if (encoding === "base64") { + const decoded = atob(text); + data = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; ++i) { + data[i] = decoded.charCodeAt(i); + } + } else { + data = new TextEncoder().encode(text); + } + saveAs(window, data, fileName); + } + + /** + * Copy response data as a string. + */ + async copyResponse(id, responseContent) { + responseContent = + responseContent || + (await this.props.connector.requestData(id, "responseContent")); + + copyString(responseContent.content.text); + } + + async fetchRequestHeaders(id) { + await this.props.connector.requestData(id, "requestHeaders"); + } +} + +module.exports = RequestListContextMenu; |