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/* eslint-env mozilla/frame-script */
6
7const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
8const { XPCOMUtils } = ChromeUtils.import(
9  "resource://gre/modules/XPCOMUtils.jsm"
10);
11
12var EXPORTED_SYMBOLS = ["ViewSourcePageChild"];
13
14XPCOMUtils.defineLazyGlobalGetters(this, ["NodeFilter"]);
15
16const BUNDLE_URL = "chrome://global/locale/viewSource.properties";
17
18// These are markers used to delimit the selection during processing. They
19// are removed from the final rendering.
20// We use noncharacter Unicode codepoints to minimize the risk of clashing
21// with anything that might legitimately be present in the document.
22// U+FDD0..FDEF <noncharacters>
23const MARK_SELECTION_START = "\uFDD0";
24const MARK_SELECTION_END = "\uFDEF";
25
26/**
27 * When showing selection source, chrome will construct a page fragment to
28 * show, and then instruct content to draw a selection after load.  This is
29 * set true when there is a pending request to draw selection.
30 */
31let gNeedsDrawSelection = false;
32
33/**
34 * Start at a specific line number.
35 */
36let gInitialLineNumber = -1;
37
38class ViewSourcePageChild extends JSWindowActorChild {
39  constructor() {
40    super();
41
42    XPCOMUtils.defineLazyGetter(this, "bundle", function() {
43      return Services.strings.createBundle(BUNDLE_URL);
44    });
45  }
46
47  static setNeedsDrawSelection(value) {
48    gNeedsDrawSelection = value;
49  }
50
51  static setInitialLineNumber(value) {
52    gInitialLineNumber = value;
53  }
54
55  receiveMessage(msg) {
56    switch (msg.name) {
57      case "ViewSource:GoToLine":
58        this.goToLine(msg.data.lineNumber);
59        break;
60      case "ViewSource:IsWrapping":
61        return this.isWrapping;
62      case "ViewSource:IsSyntaxHighlighting":
63        return this.isSyntaxHighlighting;
64      case "ViewSource:ToggleWrapping":
65        this.toggleWrapping();
66        break;
67      case "ViewSource:ToggleSyntaxHighlighting":
68        this.toggleSyntaxHighlighting();
69        break;
70    }
71    return undefined;
72  }
73
74  /**
75   * Any events should get handled here, and should get dispatched to
76   * a specific function for the event type.
77   */
78  handleEvent(event) {
79    switch (event.type) {
80      case "pageshow":
81        this.onPageShow(event);
82        break;
83      case "click":
84        this.onClick(event);
85        break;
86    }
87  }
88
89  /**
90   * A shortcut to the nsISelectionController for the content.
91   */
92  get selectionController() {
93    return this.docShell
94      .QueryInterface(Ci.nsIInterfaceRequestor)
95      .getInterface(Ci.nsISelectionDisplay)
96      .QueryInterface(Ci.nsISelectionController);
97  }
98
99  /**
100   * A shortcut to the nsIWebBrowserFind for the content.
101   */
102  get webBrowserFind() {
103    return this.docShell
104      .QueryInterface(Ci.nsIInterfaceRequestor)
105      .getInterface(Ci.nsIWebBrowserFind);
106  }
107
108  /**
109   * This handler is for click events from:
110   *   * error page content, which can show up if the user attempts to view the
111   *     source of an attack page.
112   */
113  onClick(event) {
114    let target = event.originalTarget;
115
116    // Don't trust synthetic events
117    if (!event.isTrusted || event.target.localName != "button") {
118      return;
119    }
120
121    let errorDoc = target.ownerDocument;
122
123    if (/^about:blocked/.test(errorDoc.documentURI)) {
124      // The event came from a button on a malware/phishing block page
125
126      if (target == errorDoc.getElementById("goBackButton")) {
127        // Instead of loading some safe page, just close the window
128        this.sendAsyncMessage("ViewSource:Close");
129      }
130    }
131  }
132
133  /**
134   * Handler for the pageshow event.
135   *
136   * @param event
137   *        The pageshow event being handled.
138   */
139  onPageShow(event) {
140    // If we need to draw the selection, wait until an actual view source page
141    // has loaded, instead of about:blank.
142    if (
143      gNeedsDrawSelection &&
144      this.document.documentURI.startsWith("view-source:")
145    ) {
146      gNeedsDrawSelection = false;
147      this.drawSelection();
148    }
149
150    if (gInitialLineNumber >= 0) {
151      this.goToLine(gInitialLineNumber);
152      gInitialLineNumber = -1;
153    }
154  }
155
156  /**
157   * Attempts to go to a particular line in the source code being
158   * shown. If it succeeds in finding the line, it will fire a
159   * "ViewSource:GoToLine:Success" message, passing up an object
160   * with the lineNumber we just went to. If it cannot find the line,
161   * it will fire a "ViewSource:GoToLine:Failed" message.
162   *
163   * @param lineNumber
164   *        The line number to attempt to go to.
165   */
166  goToLine(lineNumber) {
167    let body = this.document.body;
168
169    // The source document is made up of a number of pre elements with
170    // id attributes in the format <pre id="line123">, meaning that
171    // the first line in the pre element is number 123.
172    // Do binary search to find the pre element containing the line.
173    // However, in the plain text case, we have only one pre without an
174    // attribute, so assume it begins on line 1.
175    let pre;
176    for (let lbound = 0, ubound = body.childNodes.length; ; ) {
177      let middle = (lbound + ubound) >> 1;
178      pre = body.childNodes[middle];
179
180      let firstLine = pre.id ? parseInt(pre.id.substring(4)) : 1;
181
182      if (lbound == ubound - 1) {
183        break;
184      }
185
186      if (lineNumber >= firstLine) {
187        lbound = middle;
188      } else {
189        ubound = middle;
190      }
191    }
192
193    let result = {};
194    let found = this.findLocation(pre, lineNumber, null, -1, false, result);
195
196    if (!found) {
197      this.sendAsyncMessage("ViewSource:GoToLine:Failed");
198      return;
199    }
200
201    let selection = this.document.defaultView.getSelection();
202    selection.removeAllRanges();
203
204    // In our case, the range's startOffset is after "\n" on the previous line.
205    // Tune the selection at the beginning of the next line and do some tweaking
206    // to position the focusNode and the caret at the beginning of the line.
207    selection.interlinePosition = true;
208
209    selection.addRange(result.range);
210
211    if (!selection.isCollapsed) {
212      selection.collapseToEnd();
213
214      let offset = result.range.startOffset;
215      let node = result.range.startContainer;
216      if (offset < node.data.length) {
217        // The same text node spans across the "\n", just focus where we were.
218        selection.extend(node, offset);
219      } else {
220        // There is another tag just after the "\n", hook there. We need
221        // to focus a safe point because there are edgy cases such as
222        // <span>...\n</span><span>...</span> vs.
223        // <span>...\n<span>...</span></span><span>...</span>
224        node = node.nextSibling
225          ? node.nextSibling
226          : node.parentNode.nextSibling;
227        selection.extend(node, 0);
228      }
229    }
230
231    let selCon = this.selectionController;
232    selCon.setDisplaySelection(Ci.nsISelectionController.SELECTION_ON);
233    selCon.setCaretVisibilityDuringSelection(true);
234
235    // Scroll the beginning of the line into view.
236    selCon.scrollSelectionIntoView(
237      Ci.nsISelectionController.SELECTION_NORMAL,
238      Ci.nsISelectionController.SELECTION_FOCUS_REGION,
239      true
240    );
241
242    this.sendAsyncMessage("ViewSource:GoToLine:Success", { lineNumber });
243  }
244
245  /**
246   * Some old code from the original view source implementation. Original
247   * documentation follows:
248   *
249   * "Loops through the text lines in the pre element. The arguments are either
250   *  (pre, line) or (node, offset, interlinePosition). result is an out
251   *  argument. If (pre, line) are specified (and node == null), result.range is
252   *  a range spanning the specified line. If the (node, offset,
253   *  interlinePosition) are specified, result.line and result.col are the line
254   *  and column number of the specified offset in the specified node relative to
255   *  the whole file."
256   */
257  findLocation(pre, lineNumber, node, offset, interlinePosition, result) {
258    if (node && !pre) {
259      // Look upwards to find the current pre element.
260      // eslint-disable-next-line no-empty
261      for (pre = node; pre.nodeName != "PRE"; pre = pre.parentNode) {}
262    }
263
264    // The source document is made up of a number of pre elements with
265    // id attributes in the format <pre id="line123">, meaning that
266    // the first line in the pre element is number 123.
267    // However, in the plain text case, there is only one <pre> without an id,
268    // so assume line 1.
269    let curLine = pre.id ? parseInt(pre.id.substring(4)) : 1;
270
271    // Walk through each of the text nodes and count newlines.
272    let treewalker = this.document.createTreeWalker(
273      pre,
274      NodeFilter.SHOW_TEXT,
275      null
276    );
277
278    // The column number of the first character in the current text node.
279    let firstCol = 1;
280
281    let found = false;
282    for (
283      let textNode = treewalker.firstChild();
284      textNode && !found;
285      textNode = treewalker.nextNode()
286    ) {
287      // \r is not a valid character in the DOM, so we only check for \n.
288      let lineArray = textNode.data.split(/\n/);
289      let lastLineInNode = curLine + lineArray.length - 1;
290
291      // Check if we can skip the text node without further inspection.
292      if (node ? textNode != node : lastLineInNode < lineNumber) {
293        if (lineArray.length > 1) {
294          firstCol = 1;
295        }
296        firstCol += lineArray[lineArray.length - 1].length;
297        curLine = lastLineInNode;
298        continue;
299      }
300
301      // curPos is the offset within the current text node of the first
302      // character in the current line.
303      for (
304        var i = 0, curPos = 0;
305        i < lineArray.length;
306        curPos += lineArray[i++].length + 1
307      ) {
308        if (i > 0) {
309          curLine++;
310        }
311
312        if (node) {
313          if (offset >= curPos && offset <= curPos + lineArray[i].length) {
314            // If we are right after the \n of a line and interlinePosition is
315            // false, the caret looks as if it were at the end of the previous
316            // line, so we display that line and column instead.
317
318            if (i > 0 && offset == curPos && !interlinePosition) {
319              result.line = curLine - 1;
320              var prevPos = curPos - lineArray[i - 1].length;
321              result.col = (i == 1 ? firstCol : 1) + offset - prevPos;
322            } else {
323              result.line = curLine;
324              result.col = (i == 0 ? firstCol : 1) + offset - curPos;
325            }
326            found = true;
327
328            break;
329          }
330        } else if (curLine == lineNumber && !("range" in result)) {
331          result.range = this.document.createRange();
332          result.range.setStart(textNode, curPos);
333
334          // This will always be overridden later, except when we look for
335          // the very last line in the file (this is the only line that does
336          // not end with \n).
337          result.range.setEndAfter(pre.lastChild);
338        } else if (curLine == lineNumber + 1) {
339          result.range.setEnd(textNode, curPos - 1);
340          found = true;
341          break;
342        }
343      }
344    }
345
346    return found || "range" in result;
347  }
348
349  /**
350   * @return {boolean} whether the "wrap" class exists on the document body.
351   */
352  get isWrapping() {
353    return this.document.body.classList.contains("wrap");
354  }
355
356  /**
357   * @return {boolean} whether the "highlight" class exists on the document body.
358   */
359  get isSyntaxHighlighting() {
360    return this.document.body.classList.contains("highlight");
361  }
362
363  /**
364   * Toggles the "wrap" class on the document body, which sets whether
365   * or not long lines are wrapped.  Notifies parent to update the pref.
366   */
367  toggleWrapping() {
368    let body = this.document.body;
369    let state = body.classList.toggle("wrap");
370    this.sendAsyncMessage("ViewSource:StoreWrapping", { state });
371  }
372
373  /**
374   * Toggles the "highlight" class on the document body, which sets whether
375   * or not syntax highlighting is displayed.  Notifies parent to update the
376   * pref.
377   */
378  toggleSyntaxHighlighting() {
379    let body = this.document.body;
380    let state = body.classList.toggle("highlight");
381    this.sendAsyncMessage("ViewSource:StoreSyntaxHighlighting", { state });
382  }
383
384  /**
385   * Using special markers left in the serialized source, this helper makes the
386   * underlying markup of the selected fragment to automatically appear as
387   * selected on the inflated view-source DOM.
388   */
389  drawSelection() {
390    this.document.title = this.bundle.GetStringFromName(
391      "viewSelectionSourceTitle"
392    );
393
394    // find the special selection markers that we added earlier, and
395    // draw the selection between the two...
396    var findService = null;
397    try {
398      // get the find service which stores the global find state
399      findService = Cc["@mozilla.org/find/find_service;1"].getService(
400        Ci.nsIFindService
401      );
402    } catch (e) {}
403    if (!findService) {
404      return;
405    }
406
407    // cache the current global find state
408    var matchCase = findService.matchCase;
409    var entireWord = findService.entireWord;
410    var wrapFind = findService.wrapFind;
411    var findBackwards = findService.findBackwards;
412    var searchString = findService.searchString;
413    var replaceString = findService.replaceString;
414
415    // setup our find instance
416    var findInst = this.webBrowserFind;
417    findInst.matchCase = true;
418    findInst.entireWord = false;
419    findInst.wrapFind = true;
420    findInst.findBackwards = false;
421
422    // ...lookup the start mark
423    findInst.searchString = MARK_SELECTION_START;
424    var startLength = MARK_SELECTION_START.length;
425    findInst.findNext();
426
427    var selection = this.document.defaultView.getSelection();
428    if (!selection.rangeCount) {
429      return;
430    }
431
432    var range = selection.getRangeAt(0);
433
434    var startContainer = range.startContainer;
435    var startOffset = range.startOffset;
436
437    // ...lookup the end mark
438    findInst.searchString = MARK_SELECTION_END;
439    var endLength = MARK_SELECTION_END.length;
440    findInst.findNext();
441
442    var endContainer = selection.anchorNode;
443    var endOffset = selection.anchorOffset;
444
445    // reset the selection that find has left
446    selection.removeAllRanges();
447
448    // delete the special markers now...
449    endContainer.deleteData(endOffset, endLength);
450    startContainer.deleteData(startOffset, startLength);
451    if (startContainer == endContainer) {
452      endOffset -= startLength;
453    } // has shrunk if on same text node...
454    range.setEnd(endContainer, endOffset);
455
456    // show the selection and scroll it into view
457    selection.addRange(range);
458    // the default behavior of the selection is to scroll at the end of
459    // the selection, whereas in this situation, it is more user-friendly
460    // to scroll at the beginning. So we override the default behavior here
461    try {
462      this.selectionController.scrollSelectionIntoView(
463        Ci.nsISelectionController.SELECTION_NORMAL,
464        Ci.nsISelectionController.SELECTION_ANCHOR_REGION,
465        true
466      );
467    } catch (e) {}
468
469    // restore the current find state
470    findService.matchCase = matchCase;
471    findService.entireWord = entireWord;
472    findService.wrapFind = wrapFind;
473    findService.findBackwards = findBackwards;
474    findService.searchString = searchString;
475    findService.replaceString = replaceString;
476
477    findInst.matchCase = matchCase;
478    findInst.entireWord = entireWord;
479    findInst.wrapFind = wrapFind;
480    findInst.findBackwards = findBackwards;
481    findInst.searchString = searchString;
482  }
483}
484