ResponsePanel.js (15668B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 const { 7 Component, 8 createFactory, 9 } = require("resource://devtools/client/shared/vendor/react.mjs"); 10 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 11 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 12 const { 13 L10N, 14 } = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); 15 const { 16 decodeUnicodeBase64, 17 fetchNetworkUpdatePacket, 18 parseJSON, 19 } = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); 20 const { 21 getCORSErrorURL, 22 } = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js"); 23 const { 24 Filters, 25 } = require("resource://devtools/client/netmonitor/src/utils/filter-predicates.js"); 26 const { 27 FILTER_SEARCH_DELAY, 28 } = require("resource://devtools/client/netmonitor/src/constants.js"); 29 const { 30 BLOCKED_REASON_MESSAGES, 31 } = require("resource://devtools/client/netmonitor/src/constants.js"); 32 33 // Components 34 const PropertiesView = createFactory( 35 require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js") 36 ); 37 const ImagePreview = createFactory( 38 require("resource://devtools/client/netmonitor/src/components/previews/ImagePreview.js") 39 ); 40 const FontPreview = createFactory( 41 require("resource://devtools/client/netmonitor/src/components/previews/FontPreview.js") 42 ); 43 const SourcePreview = createFactory( 44 require("resource://devtools/client/netmonitor/src/components/previews/SourcePreview.js") 45 ); 46 const HtmlPreview = createFactory( 47 require("resource://devtools/client/netmonitor/src/components/previews/HtmlPreview.js") 48 ); 49 let { 50 NotificationBox, 51 PriorityLevels, 52 } = require("resource://devtools/client/shared/components/NotificationBox.js"); 53 NotificationBox = createFactory(NotificationBox); 54 const MessagesView = createFactory( 55 require("resource://devtools/client/netmonitor/src/components/messages/MessagesView.js") 56 ); 57 const SearchBox = createFactory( 58 require("resource://devtools/client/shared/components/SearchBox.js") 59 ); 60 61 loader.lazyGetter(this, "MODE", function () { 62 return ChromeUtils.importESModule( 63 "resource://devtools/client/shared/components/reps/index.mjs" 64 ).MODE; 65 }); 66 67 const { div, input, label, span, h2 } = dom; 68 const JSON_SCOPE_NAME = L10N.getStr("jsonScopeName"); 69 const JSON_FILTER_TEXT = L10N.getStr("jsonFilterText"); 70 const RESPONSE_PAYLOAD = L10N.getStr("responsePayload"); 71 const RAW_RESPONSE_PAYLOAD = L10N.getStr("netmonitor.response.raw"); 72 const HTML_RESPONSE = L10N.getStr("netmonitor.response.html"); 73 const RESPONSE_EMPTY_TEXT = L10N.getStr("responseEmptyText"); 74 const RESPONSE_TRUNCATED = L10N.getStr("responseTruncated"); 75 76 const JSON_VIEW_MIME_TYPE = "application/vnd.mozilla.json.view"; 77 78 /** 79 * Response panel component 80 * Displays the GET parameters and POST data of a request 81 */ 82 class ResponsePanel extends Component { 83 static get propTypes() { 84 return { 85 request: PropTypes.object.isRequired, 86 openLink: PropTypes.func, 87 targetSearchResult: PropTypes.object, 88 connector: PropTypes.object.isRequired, 89 showMessagesView: PropTypes.bool, 90 defaultRawResponse: PropTypes.bool, 91 setDefaultRawResponse: PropTypes.func, 92 }; 93 } 94 95 constructor(props) { 96 super(props); 97 98 this.state = { 99 filterText: "", 100 rawResponsePayloadDisplayed: 101 !!props.targetSearchResult || !!props.defaultRawResponse, 102 }; 103 104 this.toggleRawResponsePayload = this.toggleRawResponsePayload.bind(this); 105 this.renderCORSBlockedReason = this.renderCORSBlockedReason.bind(this); 106 this.renderRawResponsePayloadBtn = 107 this.renderRawResponsePayloadBtn.bind(this); 108 this.renderJsonHtmlAndSource = this.renderJsonHtmlAndSource.bind(this); 109 this.handleJSONResponse = this.handleJSONResponse.bind(this); 110 } 111 112 componentDidMount() { 113 const { request, connector } = this.props; 114 fetchNetworkUpdatePacket(connector.requestData, request, [ 115 "responseContent", 116 "responseHeaders", 117 ]); 118 } 119 120 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 121 UNSAFE_componentWillReceiveProps(nextProps) { 122 const { request, connector } = nextProps; 123 fetchNetworkUpdatePacket(connector.requestData, request, [ 124 "responseContent", 125 "responseHeaders", 126 ]); 127 128 // If the response contains XSSI stripped chars default to raw view 129 const text = nextProps.request?.responseContent?.content?.text; 130 const xssiStrippedChars = text && parseJSON(text)?.strippedChars; 131 if (xssiStrippedChars && !this.state.rawResponsePayloadDisplayed) { 132 this.toggleRawResponsePayload(); 133 } 134 135 if (nextProps.targetSearchResult !== null) { 136 this.setState({ 137 rawResponsePayloadDisplayed: !!nextProps.targetSearchResult, 138 }); 139 } 140 } 141 142 /** 143 * Update only if: 144 * 1) The rendered object has changed 145 * 2) The user selected another search result target. 146 * 3) Internal state changes 147 */ 148 shouldComponentUpdate(nextProps, nextState) { 149 return ( 150 this.state !== nextState || 151 this.props.request !== nextProps.request || 152 nextProps.targetSearchResult !== null 153 ); 154 } 155 156 /** 157 * Handle json, which we tentatively identify by checking the 158 * MIME type for "json" after any word boundary. This works 159 * for the standard "application/json", and also for custom 160 * types like "x-bigcorp-json". Additionally, we also 161 * directly parse the response text content to verify whether 162 * it's json or not, to handle responses incorrectly labeled 163 * as text/plain instead. 164 */ 165 handleJSONResponse(mimeType, response) { 166 const limit = Services.prefs.getIntPref( 167 "devtools.netmonitor.responseBodyLimit" 168 ); 169 const { request } = this.props; 170 171 // Check if the response has been truncated, in which case no parse should 172 // be attempted. 173 if (limit > 0 && limit <= request.responseContent.content.size) { 174 const result = {}; 175 result.error = RESPONSE_TRUNCATED; 176 return result; 177 } 178 179 const { json, error, jsonpCallback, strippedChars } = parseJSON(response); 180 181 if (/\bjson/.test(mimeType) || json) { 182 const result = {}; 183 // Make sure this is a valid JSON object first. If so, nicely display 184 // the parsing results in a tree view. 185 186 // Valid JSON 187 if (json) { 188 result.json = json; 189 } 190 // Valid JSONP 191 if (jsonpCallback) { 192 result.jsonpCallback = jsonpCallback; 193 } 194 // Malformed JSON 195 if (error) { 196 result.error = "" + error; 197 } 198 // XSSI protection sequence 199 if (strippedChars) { 200 result.strippedChars = strippedChars; 201 } 202 203 return result; 204 } 205 206 return null; 207 } 208 209 renderCORSBlockedReason(blockedReason) { 210 // ensure that the blocked reason is in the CORS range 211 if ( 212 typeof blockedReason != "number" || 213 blockedReason < 1000 || 214 blockedReason > 1015 215 ) { 216 return null; 217 } 218 219 const blockedMessage = BLOCKED_REASON_MESSAGES[blockedReason]; 220 const messageText = L10N.getFormatStr( 221 "netmonitor.headers.blockedByCORS", 222 blockedMessage 223 ); 224 225 const learnMoreTooltip = L10N.getStr( 226 "netmonitor.headers.blockedByCORSTooltip" 227 ); 228 229 // Create a notifications map with the CORS error notification 230 const notifications = new Map(); 231 notifications.set("CORS-error", { 232 label: messageText, 233 value: "CORS-error", 234 image: "", 235 priority: PriorityLevels.PRIORITY_INFO_HIGH, 236 type: "info", 237 eventCallback: () => {}, 238 buttons: [ 239 { 240 mdnUrl: getCORSErrorURL(blockedReason), 241 label: learnMoreTooltip, 242 }, 243 ], 244 }); 245 246 return NotificationBox({ 247 notifications, 248 displayBorderTop: false, 249 displayBorderBottom: true, 250 displayCloseButton: false, 251 }); 252 } 253 254 toggleRawResponsePayload() { 255 this.setState({ 256 rawResponsePayloadDisplayed: !this.state.rawResponsePayloadDisplayed, 257 }); 258 } 259 260 /** 261 * Pick correct component, componentprops, and other needed data to render 262 * the given response 263 * 264 * @returns {object} shape: 265 * {component}: React component used to render response 266 * {Object} componetProps: Props passed to component 267 * {Error} error: JSON parsing error 268 * {Object} json: parsed JSON payload 269 * {bool} hasFormattedDisplay: whether the given payload has a formatted 270 * display or if it should be rendered raw 271 * {string} responsePayloadLabel: describes type in response panel 272 * {component} xssiStrippedCharsInfoBox: React component to notifiy users 273 * that XSSI characters were stripped from the response 274 */ 275 renderJsonHtmlAndSource() { 276 const { request, targetSearchResult } = this.props; 277 const { responseContent, responseHeaders, url } = request; 278 let { encoding, mimeType, text } = responseContent.content; 279 const { filterText, rawResponsePayloadDisplayed } = this.state; 280 281 // Decode response if it's coming from JSONView. 282 if (mimeType?.includes(JSON_VIEW_MIME_TYPE) && encoding === "base64") { 283 text = decodeUnicodeBase64(text); 284 } 285 const { json, jsonpCallback, error, strippedChars } = 286 this.handleJSONResponse(mimeType, text) || {}; 287 288 let component; 289 let componentProps; 290 let xssiStrippedCharsInfoBox; 291 let responsePayloadLabel = RESPONSE_PAYLOAD; 292 let hasFormattedDisplay = false; 293 294 if (json) { 295 if (jsonpCallback) { 296 responsePayloadLabel = L10N.getFormatStr( 297 "jsonpScopeName", 298 jsonpCallback 299 ); 300 } else { 301 responsePayloadLabel = JSON_SCOPE_NAME; 302 } 303 304 // If raw response payload is not displayed render xssi info box if 305 // there are stripped chars 306 if (!rawResponsePayloadDisplayed) { 307 xssiStrippedCharsInfoBox = 308 this.renderXssiStrippedCharsInfoBox(strippedChars); 309 } else { 310 xssiStrippedCharsInfoBox = null; 311 } 312 313 component = PropertiesView; 314 componentProps = { 315 object: json, 316 useQuotes: true, 317 filterText, 318 targetSearchResult, 319 defaultSelectFirstNode: false, 320 mode: MODE.LONG, 321 useBaseTreeViewExpand: true, 322 url, 323 }; 324 hasFormattedDisplay = true; 325 } else if (Filters.html(this.props.request)) { 326 // Display HTML 327 responsePayloadLabel = HTML_RESPONSE; 328 component = HtmlPreview; 329 componentProps = { responseContent, responseHeaders, url }; 330 hasFormattedDisplay = true; 331 } 332 if (!hasFormattedDisplay || rawResponsePayloadDisplayed) { 333 component = SourcePreview; 334 componentProps = { 335 text, 336 mimeType: json ? "application/json" : mimeType.replace(/;.+/, ""), 337 targetSearchResult, 338 url, 339 }; 340 } 341 return { 342 component, 343 componentProps, 344 error, 345 hasFormattedDisplay, 346 json, 347 responsePayloadLabel, 348 xssiStrippedCharsInfoBox, 349 url, 350 }; 351 } 352 353 renderRawResponsePayloadBtn(key, checked) { 354 return [ 355 label( 356 { 357 key: `${key}RawResponsePayloadBtn`, 358 className: "raw-data-toggle", 359 onClick: event => { 360 // stop the header click event 361 event.stopPropagation(); 362 }, 363 }, 364 span({ className: "raw-data-toggle-label" }, RAW_RESPONSE_PAYLOAD), 365 span( 366 { className: "raw-data-toggle-input" }, 367 input({ 368 id: `raw-${key}-checkbox`, 369 checked, 370 className: "devtools-checkbox-toggle", 371 onChange: event => { 372 if (this.props.setDefaultRawResponse) { 373 this.props.setDefaultRawResponse(event.target.checked); 374 } 375 this.toggleRawResponsePayload(); 376 }, 377 type: "checkbox", 378 }) 379 ) 380 ), 381 ]; 382 } 383 384 renderResponsePayload(component, componentProps) { 385 return component(componentProps); 386 } 387 388 /** 389 * This function takes a string of the XSSI protection characters 390 * removed from a JSON payload and produces a notification component 391 * letting the user know that they were removed 392 * 393 * @param {string} strippedChars: string of XSSI protection characters 394 * removed from JSON payload 395 * @returns {component} NotificationBox component 396 */ 397 renderXssiStrippedCharsInfoBox(strippedChars) { 398 if (!strippedChars || this.state.rawRequestPayloadDisplayed) { 399 return null; 400 } 401 const message = L10N.getFormatStr("jsonXssiStripped", strippedChars); 402 403 const notifications = new Map(); 404 notifications.set("xssi-string-removed-info-box", { 405 label: message, 406 value: "xssi-string-removed-info-box", 407 image: "", 408 priority: PriorityLevels.PRIORITY_INFO_MEDIUM, 409 type: "info", 410 eventCallback: () => {}, 411 buttons: [], 412 }); 413 414 return NotificationBox({ 415 notifications, 416 displayBorderTop: false, 417 displayBorderBottom: true, 418 displayCloseButton: false, 419 }); 420 } 421 422 render() { 423 const { connector, showMessagesView, request } = this.props; 424 const { blockedReason, responseContent, url } = request; 425 const { filterText, rawResponsePayloadDisplayed } = this.state; 426 427 // Display CORS blocked Reason info box 428 const CORSBlockedReasonDetails = 429 this.renderCORSBlockedReason(blockedReason); 430 431 if (showMessagesView) { 432 return MessagesView({ connector }); 433 } 434 435 if ( 436 !responseContent || 437 typeof responseContent.content.text !== "string" || 438 !responseContent.content.text 439 ) { 440 return div( 441 { className: "panel-container" }, 442 CORSBlockedReasonDetails, 443 div({ className: "empty-notice" }, RESPONSE_EMPTY_TEXT) 444 ); 445 } 446 447 const { encoding, mimeType, text } = responseContent.content; 448 449 if (Filters.images({ mimeType })) { 450 return ImagePreview({ encoding, mimeType, text, url }); 451 } 452 453 if (Filters.fonts({ url, mimeType })) { 454 return FontPreview({ connector, mimeType, url }); 455 } 456 457 // Get Data needed for formatted display 458 const { 459 component, 460 componentProps, 461 error, 462 hasFormattedDisplay, 463 json, 464 responsePayloadLabel, 465 xssiStrippedCharsInfoBox, 466 } = this.renderJsonHtmlAndSource(); 467 468 const classList = ["panel-container"]; 469 if (Filters.html(this.props.request)) { 470 classList.push("contains-html-preview"); 471 } 472 473 return div( 474 { className: classList.join(" ") }, 475 error && div({ className: "response-error-header", title: error }, error), 476 json && 477 div( 478 { className: "devtools-toolbar devtools-input-toolbar" }, 479 SearchBox({ 480 delay: FILTER_SEARCH_DELAY, 481 type: "filter", 482 onChange: filter => this.setState({ filterText: filter }), 483 placeholder: JSON_FILTER_TEXT, 484 initialValue: filterText, 485 }) 486 ), 487 div({ tabIndex: "0" }, CORSBlockedReasonDetails), 488 h2({ className: "data-header", role: "heading" }, [ 489 span( 490 { 491 key: "data-label", 492 className: "data-label", 493 }, 494 responsePayloadLabel 495 ), 496 hasFormattedDisplay && 497 this.renderRawResponsePayloadBtn( 498 "response", 499 rawResponsePayloadDisplayed 500 ), 501 ]), 502 xssiStrippedCharsInfoBox, 503 this.renderResponsePayload(component, componentProps) 504 ); 505 } 506 } 507 508 module.exports = ResponsePanel;