summaryrefslogtreecommitdiffstats
path: root/comm/chat/modules/imContentSink.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--comm/chat/modules/imContentSink.sys.mjs495
1 files changed, 495 insertions, 0 deletions
diff --git a/comm/chat/modules/imContentSink.sys.mjs b/comm/chat/modules/imContentSink.sys.mjs
new file mode 100644
index 0000000000..b3ff617048
--- /dev/null
+++ b/comm/chat/modules/imContentSink.sys.mjs
@@ -0,0 +1,495 @@
+/* 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/. */
+
+var kAllowedURLs = aValue => /^(https?|ftp|mailto|magnet):/.test(aValue);
+var kAllowedMozClasses = aClassName =>
+ aClassName == "moz-txt-underscore" ||
+ aClassName == "moz-txt-tag" ||
+ aClassName == "ib-person";
+var kAllowedAnchorClasses = aClassName => aClassName == "ib-person";
+
+/* Tags whose content should be fully removed, and reported in the Error Console. */
+var kForbiddenTags = {
+ script: true,
+ style: true,
+};
+
+/**
+ * In strict mode, remove all formatting. Keep only links and line breaks.
+ *
+ * @type {CleanRules}
+ */
+var kStrictMode = {
+ attrs: {},
+
+ tags: {
+ a: {
+ title: true,
+ href: kAllowedURLs,
+ class: kAllowedAnchorClasses,
+ },
+ br: true,
+ p: true,
+ },
+
+ styles: {},
+};
+
+/**
+ * Standard mode allows basic formattings (bold, italic, underlined).
+ *
+ * @type {CleanRules}
+ */
+var kStandardMode = {
+ attrs: {
+ style: true,
+ },
+
+ tags: {
+ div: true,
+ a: {
+ title: true,
+ href: kAllowedURLs,
+ class: kAllowedAnchorClasses,
+ },
+ em: true,
+ strong: true,
+ b: true,
+ i: true,
+ u: true,
+ s: true,
+ span: {
+ class: kAllowedMozClasses,
+ },
+ br: true,
+ code: true,
+ ul: true,
+ li: true,
+ ol: {
+ start: true,
+ },
+ cite: true,
+ blockquote: true,
+ p: true,
+ del: true,
+ strike: true,
+ ins: true,
+ sub: true,
+ sup: true,
+ pre: true,
+ table: true,
+ thead: true,
+ tbody: true,
+ tr: true,
+ th: true,
+ td: true,
+ caption: true,
+ details: true,
+ summary: true,
+ },
+
+ styles: {
+ "font-style": true,
+ "font-weight": true,
+ "text-decoration-line": true,
+ },
+};
+
+/**
+ * Permissive mode allows just about anything that isn't going to mess up the chat window.
+ * In comparison to normal mode this primarily means elements that can vary font sizes and
+ * colors.
+ *
+ * @type {CleanRules}
+ */
+var kPermissiveMode = {
+ attrs: {
+ style: true,
+ },
+
+ tags: {
+ div: true,
+ a: {
+ title: true,
+ href: kAllowedURLs,
+ class: kAllowedAnchorClasses,
+ },
+ font: {
+ face: true,
+ color: true,
+ size: true,
+ },
+ em: true,
+ strong: true,
+ b: true,
+ i: true,
+ u: true,
+ s: true,
+ span: {
+ class: kAllowedMozClasses,
+ },
+ br: true,
+ hr: true,
+ code: true,
+ ul: true,
+ li: true,
+ ol: {
+ start: true,
+ },
+ cite: true,
+ blockquote: true,
+ p: true,
+ del: true,
+ strike: true,
+ ins: true,
+ sub: true,
+ sup: true,
+ pre: true,
+ table: true,
+ thead: true,
+ tbody: true,
+ tr: true,
+ th: true,
+ td: true,
+ caption: true,
+ details: true,
+ summary: true,
+ h1: true,
+ h2: true,
+ h3: true,
+ h4: true,
+ h5: true,
+ h6: true,
+ },
+
+ // FIXME: should be possible to use functions to filter values
+ styles: {
+ color: true,
+ font: true,
+ "font-family": true,
+ "font-size": true,
+ "font-style": true,
+ "font-weight": true,
+ "text-decoration-color": true,
+ "text-decoration-style": true,
+ "text-decoration-line": true,
+ },
+};
+
+var kModePref = "messenger.options.filterMode";
+var kModes = [kStrictMode, kStandardMode, kPermissiveMode];
+
+var gGlobalRuleset = null;
+
+function initGlobalRuleset() {
+ gGlobalRuleset = newRuleset();
+
+ Services.prefs.addObserver(kModePref, styleObserver);
+}
+
+var styleObserver = {
+ observe(aObject, aTopic, aMsg) {
+ if (aTopic != "nsPref:changed" || aMsg != kModePref) {
+ throw new Error("bad notification");
+ }
+
+ if (!gGlobalRuleset) {
+ throw new Error("gGlobalRuleset not initialized");
+ }
+
+ setBaseRuleset(getModePref(), gGlobalRuleset);
+ },
+};
+
+function getModePref() {
+ let baseNum = Services.prefs.getIntPref(kModePref);
+ if (baseNum < 0 || baseNum > 2) {
+ baseNum = 1;
+ }
+
+ return kModes[baseNum];
+}
+
+function setBaseRuleset(aBase, aResult) {
+ for (let property in aBase) {
+ aResult[property] = Object.create(aBase[property], aResult[property]);
+ }
+}
+
+function newRuleset(aBase) {
+ let result = {
+ tags: {},
+ attrs: {},
+ styles: {},
+ };
+ setBaseRuleset(aBase || getModePref(), result);
+ return result;
+}
+
+export function createDerivedRuleset() {
+ if (!gGlobalRuleset) {
+ initGlobalRuleset();
+ }
+ return newRuleset(gGlobalRuleset);
+}
+
+export function addGlobalAllowedTag(aTag, aAttrs = true) {
+ gGlobalRuleset.tags[aTag] = aAttrs;
+}
+
+export function removeGlobalAllowedTag(aTag) {
+ delete gGlobalRuleset.tags[aTag];
+}
+
+export function addGlobalAllowedAttribute(aAttr, aRule = true) {
+ gGlobalRuleset.attrs[aAttr] = aRule;
+}
+
+export function removeGlobalAllowedAttribute(aAttr) {
+ delete gGlobalRuleset.attrs[aAttr];
+}
+
+export function addGlobalAllowedStyleRule(aStyle, aRule = true) {
+ gGlobalRuleset.styles[aStyle] = aRule;
+}
+
+export function removeGlobalAllowedStyleRule(aStyle) {
+ delete gGlobalRuleset.styles[aStyle];
+}
+
+/**
+ * A dynamic rule which decides if an attribute is allowed based on the
+ * attribute's value.
+ *
+ * @callback ValueRule
+ * @param {string} value - The attribute value.
+ * @returns {bool} - True if the attribute should be allowed.
+ *
+ * @example
+ *
+ * aValue => aValue == 'about:blank'
+ */
+
+/**
+ * An object whose properties are the allowed attributes.
+ *
+ * The value of the property should be true to unconditionally accept the
+ * attribute, or a function which accepts the value of the attribute and
+ * returns a boolean of whether the attribute should be accepted or not.
+ *
+ * @typedef Ruleset
+ * @type {Object<string, (boolean | ValueRule)>}}
+ */
+
+/**
+ * A set of rules for which tags, attributes, and styles should be allowed when
+ * rendering HTML.
+ *
+ * See kStrictMode, kStandardMode, kPermissiveMode for examples of Rulesets.
+ *
+ * @typedef CleanRules
+ * @type {object}
+ * @property {Ruleset} attrs
+ * An object whose properties are the allowed attributes for any tag.
+ * @property {Object<string, (boolean|Ruleset)>} tags
+ * An object whose properties are the allowed tags.
+ *
+ * The value can point to a {@link Ruleset} for that tag which augments the
+ * ones provided by attrs. If either of the {@link Ruleset}s from attrs or
+ * tags allows an attribute, then it is accepted.
+ * @property {Object<string, boolean>} styles
+ * An object whose properties are the allowed CSS style rules.
+ *
+ * The value of each property is unused.
+ *
+ * FIXME: make styles accept functions to filter the CSS values like Ruleset.
+ *
+ * @example
+ *
+ * {
+ * attrs: { 'style': true },
+ * tags: {
+ * a: { 'href': true },
+ * },
+ * styles: {
+ * 'font-size': true
+ * }
+ * }
+ */
+
+/**
+ * A function to modify text nodes.
+ *
+ * @callback TextModifier
+ * @param {Node} - The text node to modify.
+ * @returns {int} - The number of nodes added.
+ *
+ * -1 if the current textnode was deleted
+ * 0 if the node count is unchanged
+ * positive value if nodes were added.
+ *
+ * For instance, adding an <img> tag for a smiley adds 2 nodes:
+ * the img tag
+ * the new text node after the img tag.
+ */
+
+/**
+ * Removes nodes, attributes and styles that are not allowed according to the
+ * given rules.
+ *
+ * @param {Node} aNode
+ * A DOM node to inspect recursively against the rules.
+ * @param {CleanRules} aRules
+ * The rules for what tags, attributes, and styles are allowed.
+ * @param {TextModifier[]} aTextModifiers
+ * A list of functions to modify text content.
+ */
+function cleanupNode(aNode, aRules, aTextModifiers) {
+ // Iterate each node and apply rules for what content is allowed. This has two
+ // modes: one for element nodes and one for text nodes.
+ for (let i = 0; i < aNode.childNodes.length; ++i) {
+ let node = aNode.childNodes[i];
+ if (
+ node.nodeType == node.ELEMENT_NODE &&
+ node.namespaceURI == "http://www.w3.org/1999/xhtml"
+ ) {
+ // If the node is an element, check if the node is an allowed tag.
+ let nodeName = node.localName;
+ if (!(nodeName in aRules.tags)) {
+ // If the node is not allowed, either remove it completely (if
+ // it is forbidden) or replace it with its children.
+ if (nodeName in kForbiddenTags) {
+ console.error(
+ "removing a " + nodeName + " tag from a message before display"
+ );
+ } else {
+ while (node.hasChildNodes()) {
+ aNode.insertBefore(node.firstChild, node);
+ }
+ }
+ aNode.removeChild(node);
+ // We want to process again the node at the index i which is
+ // now the first child of the node we removed
+ --i;
+ continue;
+ }
+
+ // This node is being kept, cleanup each child node.
+ cleanupNode(node, aRules, aTextModifiers);
+
+ // Cleanup the attributes of this node.
+ let attrs = node.attributes;
+ let acceptFunction = function (aAttrRules, aAttr) {
+ // An attribute is always accepted if its rule is true, or conditionally
+ // accepted if its rule is a function that evaluates to true.
+ // If its rule does not exist, it is removed.
+ let localName = aAttr.localName;
+ let rule = localName in aAttrRules && aAttrRules[localName];
+ return (
+ rule === true || (typeof rule == "function" && rule(aAttr.value))
+ );
+ };
+ for (let j = 0; j < attrs.length; ++j) {
+ let attr = attrs[j];
+ // If either the attribute is accepted for all tags or for this specific
+ // tag then it is allowed.
+ if (
+ !(
+ acceptFunction(aRules.attrs, attr) ||
+ (typeof aRules.tags[nodeName] == "object" &&
+ acceptFunction(aRules.tags[nodeName], attr))
+ )
+ ) {
+ node.removeAttribute(attr.name);
+ --j;
+ }
+ }
+
+ // Cleanup the style attribute.
+ let style = node.style;
+ for (let j = 0; j < style.length; ++j) {
+ if (!(style[j] in aRules.styles)) {
+ style.removeProperty(style[j]);
+ --j;
+ }
+ }
+
+ // If the style attribute is now empty or if it contained unsupported or
+ // unparsable CSS it should be dropped completely.
+ if (!style.length) {
+ node.removeAttribute("style");
+ }
+
+ // Sort the style attributes for easier checking/comparing later.
+ if (node.hasAttribute("style")) {
+ let trailingSemi = false;
+ let attrs = node.getAttribute("style").trim();
+ if (attrs.endsWith(";")) {
+ attrs = attrs.slice(0, -1);
+ trailingSemi = true;
+ }
+ attrs = attrs.split(";").map(a => a.trim());
+ attrs.sort();
+ node.setAttribute(
+ "style",
+ attrs.join("; ") + (trailingSemi ? ";" : "")
+ );
+ }
+ } else {
+ // We are on a text node, we need to apply the functions
+ // provided in the aTextModifiers array.
+
+ // Each of these function should return the number of nodes added:
+ // * -1 if the current textnode was deleted
+ // * 0 if the node count is unchanged
+ // * positive value if nodes were added.
+ // For instance, adding an <img> tag for a smiley adds 2 nodes:
+ // - the img tag
+ // - the new text node after the img tag.
+
+ // This is the number of nodes we need to process. If new nodes
+ // are created, the next text modifier functions have more nodes
+ // to process.
+ let textNodeCount = 1;
+ for (let modifier of aTextModifiers) {
+ for (let n = 0; n < textNodeCount; ++n) {
+ let textNode = aNode.childNodes[i + n];
+
+ // If we are processing nodes created by one of the previous
+ // text modifier function, some of the nodes are likely not
+ // text node, skip them.
+ if (
+ textNode.nodeType != textNode.TEXT_NODE &&
+ textNode.nodeType != textNode.CDATA_SECTION_NODE
+ ) {
+ continue;
+ }
+
+ let result = modifier(textNode);
+ textNodeCount += result;
+ n += result;
+ }
+ }
+
+ // newly created nodes should not be filtered, be sure we skip them!
+ i += textNodeCount - 1;
+ }
+ }
+}
+
+export function cleanupImMarkup(aText, aRuleset, aTextModifiers = []) {
+ if (!gGlobalRuleset) {
+ initGlobalRuleset();
+ }
+
+ let parser = new DOMParser();
+ // Wrap the text to be parsed in a <span> to avoid losing leading whitespace.
+ let doc = parser.parseFromString(
+ "<!DOCTYPE html><html><body><span>" + aText + "</span></body></html>",
+ "text/html"
+ );
+ let span = doc.querySelector("span");
+ cleanupNode(span, aRuleset || gGlobalRuleset, aTextModifiers);
+ return span.innerHTML;
+}