2015-11-27 16:02:32 +01:00
|
|
|
/*
|
2016-01-07 05:06:39 +01:00
|
|
|
Copyright 2015, 2016 OpenMarket Ltd
|
2015-11-27 16:02:32 +01:00
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
var React = require('react');
|
|
|
|
var sanitizeHtml = require('sanitize-html');
|
|
|
|
var highlight = require('highlight.js');
|
2016-03-20 04:05:07 +01:00
|
|
|
var linkifyMatrix = require('./linkify-matrix');
|
2016-07-04 23:43:53 +02:00
|
|
|
import escape from 'lodash/escape';
|
2016-07-05 06:28:28 +02:00
|
|
|
import emojione from 'emojione';
|
2016-07-05 00:34:57 +02:00
|
|
|
import classNames from 'classnames';
|
|
|
|
|
2016-08-09 18:10:05 +02:00
|
|
|
emojione.imagePathSVG = 'emojione/svg/';
|
|
|
|
emojione.imageType = 'svg';
|
|
|
|
|
2016-07-05 06:28:28 +02:00
|
|
|
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
|
2017-01-11 17:35:37 +01:00
|
|
|
const COLOR_REGEX = /^[#a-z0-9]+$/;
|
|
|
|
const ALLOWED_CSS = {
|
|
|
|
"background-color": COLOR_REGEX,
|
|
|
|
"color": COLOR_REGEX,
|
|
|
|
};
|
2015-11-27 16:02:32 +01:00
|
|
|
|
2016-08-09 18:10:05 +02:00
|
|
|
/* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js
|
|
|
|
* because we want to include emoji shortnames in title text
|
|
|
|
*/
|
|
|
|
export function unicodeToImage(str) {
|
|
|
|
let replaceWith, unicode, alt;
|
|
|
|
const mappedUnicode = emojione.mapUnicodeToShort();
|
|
|
|
|
|
|
|
str = str.replace(emojione.regUnicode, function(unicodeChar) {
|
|
|
|
if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) {
|
|
|
|
// if the unicodeChar doesnt exist just return the entire match
|
|
|
|
return unicodeChar;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// get the unicode codepoint from the actual char
|
|
|
|
unicode = emojione.jsEscapeMap[unicodeChar];
|
|
|
|
|
|
|
|
// depending on the settings, we'll either add the native unicode as the alt tag, otherwise the shortname
|
|
|
|
alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode];
|
|
|
|
const title = mappedUnicode[unicode];
|
|
|
|
|
2016-08-28 00:59:55 +02:00
|
|
|
replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${unicode}.svg${emojione.cacheBustParam}"/>`;
|
2016-08-09 18:10:05 +02:00
|
|
|
return replaceWith;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return str;
|
2016-09-16 17:02:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export function stripParagraphs(html: string): string {
|
|
|
|
const contentDiv = document.createElement('div');
|
|
|
|
contentDiv.innerHTML = html;
|
|
|
|
|
|
|
|
if (contentDiv.children.length === 0) {
|
|
|
|
return contentDiv.innerHTML;
|
|
|
|
}
|
|
|
|
|
|
|
|
let contentHTML = "";
|
|
|
|
for (let i=0; i<contentDiv.children.length; i++) {
|
|
|
|
const element = contentDiv.children[i];
|
|
|
|
if (element.tagName.toLowerCase() === 'p') {
|
|
|
|
contentHTML += element.innerHTML + '<br />';
|
|
|
|
} else {
|
|
|
|
const temp = document.createElement('div');
|
|
|
|
temp.appendChild(element.cloneNode(true));
|
|
|
|
contentHTML += temp.innerHTML;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return contentHTML;
|
|
|
|
}
|
2016-08-09 18:10:05 +02:00
|
|
|
|
2015-11-27 16:02:32 +01:00
|
|
|
var sanitizeHtmlParams = {
|
|
|
|
allowedTags: [
|
2016-02-09 16:07:39 +01:00
|
|
|
'font', // custom to matrix for IRC-style font coloring
|
2015-11-28 13:44:10 +01:00
|
|
|
'del', // for markdown
|
2016-02-09 16:07:39 +01:00
|
|
|
// deliberately no h1/h2 to stop people shouting.
|
2015-11-27 16:02:32 +01:00
|
|
|
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
|
2016-04-02 18:45:29 +02:00
|
|
|
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
|
2016-09-21 17:19:41 +02:00
|
|
|
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre'
|
2015-11-27 16:02:32 +01:00
|
|
|
],
|
|
|
|
allowedAttributes: {
|
|
|
|
// custom ones first:
|
2017-02-09 14:14:15 +01:00
|
|
|
font: ['color', 'style'], // custom to matrix
|
2017-01-20 15:22:27 +01:00
|
|
|
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
|
2015-11-27 16:02:32 +01:00
|
|
|
// We don't currently allow img itself by default, but this
|
|
|
|
// would make sense if we did
|
2017-01-20 15:22:27 +01:00
|
|
|
img: ['src'],
|
2015-11-27 16:02:32 +01:00
|
|
|
},
|
|
|
|
// Lots of these won't come up by default because we don't allow them
|
2017-01-20 15:22:27 +01:00
|
|
|
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
|
2015-11-27 16:02:32 +01:00
|
|
|
// URL schemes we permit
|
2017-01-20 15:22:27 +01:00
|
|
|
allowedSchemes: ['http', 'https', 'ftp', 'mailto'],
|
2016-09-21 17:25:18 +02:00
|
|
|
|
|
|
|
// DO NOT USE. sanitize-html allows all URL starting with '//'
|
|
|
|
// so this will always allow links to whatever scheme the
|
|
|
|
// host page is served over.
|
2016-09-21 17:19:41 +02:00
|
|
|
allowedSchemesByTag: {},
|
2016-08-15 22:37:26 +02:00
|
|
|
|
2015-11-27 16:02:32 +01:00
|
|
|
transformTags: { // custom to matrix
|
2016-03-20 04:05:07 +01:00
|
|
|
// add blank targets to all hyperlinks except vector URLs
|
|
|
|
'a': function(tagName, attribs) {
|
2016-08-28 02:55:42 +02:00
|
|
|
if (attribs.href) {
|
|
|
|
attribs.target = '_blank'; // by default
|
|
|
|
|
|
|
|
var m;
|
|
|
|
// FIXME: horrible duplication with linkify-matrix
|
|
|
|
m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN);
|
|
|
|
if (m) {
|
|
|
|
attribs.href = m[1];
|
|
|
|
delete attribs.target;
|
|
|
|
}
|
|
|
|
|
|
|
|
m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN);
|
|
|
|
if (m) {
|
|
|
|
var entity = m[1];
|
|
|
|
if (entity[0] === '@') {
|
2016-08-28 03:05:23 +02:00
|
|
|
attribs.href = '#/user/' + entity;
|
2016-08-28 02:55:42 +02:00
|
|
|
}
|
2016-08-28 03:05:23 +02:00
|
|
|
else if (entity[0] === '#' || entity[0] === '!') {
|
2016-08-28 02:55:42 +02:00
|
|
|
attribs.href = '#/room/' + entity;
|
|
|
|
}
|
|
|
|
delete attribs.target;
|
|
|
|
}
|
2016-03-20 04:05:07 +01:00
|
|
|
}
|
2016-08-15 22:37:26 +02:00
|
|
|
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
|
2016-03-25 02:25:32 +01:00
|
|
|
return { tagName: tagName, attribs : attribs };
|
2016-03-20 04:05:07 +01:00
|
|
|
},
|
2017-02-27 12:23:37 +01:00
|
|
|
'*': function(tagName, attribs) {
|
2017-01-11 17:35:37 +01:00
|
|
|
// Only allow certain CSS attributes to avoid XSS attacks
|
|
|
|
// Sanitizing values to avoid `url(...)` and `expression(...)` attacks
|
|
|
|
if (!attribs.style) {
|
2017-02-27 12:23:37 +01:00
|
|
|
return { tagName: tagName, attribs: attribs };
|
2017-01-11 17:35:37 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const pairs = attribs.style.split(';');
|
|
|
|
let sanitisedStyle = "";
|
|
|
|
for (let i = 0; i < pairs.length; i++) {
|
|
|
|
const pair = pairs[i].split(':');
|
2017-02-27 12:23:37 +01:00
|
|
|
if (!Object.keys(ALLOWED_CSS).includes(pair[0]) ||
|
|
|
|
!ALLOWED_CSS[pair[0]].test(pair[1])
|
|
|
|
) {
|
2017-01-11 17:35:37 +01:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
sanitisedStyle += pair[0] + ":" + pair[1] + ";";
|
|
|
|
}
|
|
|
|
|
|
|
|
if (sanitisedStyle) {
|
|
|
|
attribs.style = sanitisedStyle;
|
|
|
|
} else {
|
|
|
|
delete attribs.style;
|
|
|
|
}
|
|
|
|
|
2017-02-27 12:23:37 +01:00
|
|
|
return { tagName: tagName, attribs: attribs };
|
2017-01-11 17:35:37 +01:00
|
|
|
},
|
2015-11-27 16:02:32 +01:00
|
|
|
},
|
|
|
|
};
|
|
|
|
|
2016-02-17 20:50:04 +01:00
|
|
|
class BaseHighlighter {
|
|
|
|
constructor(highlightClass, highlightLink) {
|
2015-12-24 00:50:35 +01:00
|
|
|
this.highlightClass = highlightClass;
|
2016-02-17 20:50:04 +01:00
|
|
|
this.highlightLink = highlightLink;
|
2015-12-24 00:50:35 +01:00
|
|
|
}
|
|
|
|
|
2016-02-17 20:50:04 +01:00
|
|
|
/**
|
|
|
|
* apply the highlights to a section of text
|
|
|
|
*
|
|
|
|
* @param {string} safeSnippet The snippet of text to apply the highlights
|
|
|
|
* to.
|
|
|
|
* @param {string[]} safeHighlights A list of substrings to highlight,
|
|
|
|
* sorted by descending length.
|
|
|
|
*
|
|
|
|
* returns a list of results (strings for HtmlHighligher, react nodes for
|
|
|
|
* TextHighlighter).
|
|
|
|
*/
|
2016-02-10 21:25:32 +01:00
|
|
|
applyHighlights(safeSnippet, safeHighlights) {
|
2015-11-29 04:22:01 +01:00
|
|
|
var lastOffset = 0;
|
|
|
|
var offset;
|
|
|
|
var nodes = [];
|
|
|
|
|
2016-02-10 21:25:32 +01:00
|
|
|
var safeHighlight = safeHighlights[0];
|
2015-12-28 04:14:50 +01:00
|
|
|
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
|
2015-11-29 04:22:01 +01:00
|
|
|
// handle preamble
|
|
|
|
if (offset > lastOffset) {
|
2015-12-24 00:50:35 +01:00
|
|
|
var subSnippet = safeSnippet.substring(lastOffset, offset);
|
2016-02-10 21:25:32 +01:00
|
|
|
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
2015-11-29 04:22:01 +01:00
|
|
|
}
|
|
|
|
|
2016-02-17 20:50:04 +01:00
|
|
|
// do highlight. use the original string rather than safeHighlight
|
|
|
|
// to preserve the original casing.
|
|
|
|
var endOffset = offset + safeHighlight.length;
|
|
|
|
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
|
2015-11-29 04:22:01 +01:00
|
|
|
|
2016-02-17 20:50:04 +01:00
|
|
|
lastOffset = endOffset;
|
2015-11-29 04:22:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// handle postamble
|
2016-09-16 17:02:08 +02:00
|
|
|
if (lastOffset !== safeSnippet.length) {
|
|
|
|
subSnippet = safeSnippet.substring(lastOffset, undefined);
|
2016-02-10 21:25:32 +01:00
|
|
|
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
2015-11-29 14:00:58 +01:00
|
|
|
}
|
|
|
|
return nodes;
|
2015-12-24 00:50:35 +01:00
|
|
|
}
|
2015-11-29 14:00:58 +01:00
|
|
|
|
2016-02-10 21:25:32 +01:00
|
|
|
_applySubHighlights(safeSnippet, safeHighlights) {
|
|
|
|
if (safeHighlights[1]) {
|
2015-11-29 14:00:58 +01:00
|
|
|
// recurse into this range to check for the next set of highlight matches
|
2016-02-10 21:25:32 +01:00
|
|
|
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
|
2015-11-29 14:00:58 +01:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
// no more highlights to be found, just return the unhighlighted string
|
2016-02-17 20:50:04 +01:00
|
|
|
return [this._processSnippet(safeSnippet, false)];
|
2015-12-24 00:50:35 +01:00
|
|
|
}
|
|
|
|
}
|
2016-02-17 20:50:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
class HtmlHighlighter extends BaseHighlighter {
|
|
|
|
/* highlight the given snippet if required
|
|
|
|
*
|
|
|
|
* snippet: content of the span; must have been sanitised
|
|
|
|
* highlight: true to highlight as a search match
|
|
|
|
*
|
|
|
|
* returns an HTML string
|
|
|
|
*/
|
|
|
|
_processSnippet(snippet, highlight) {
|
|
|
|
if (!highlight) {
|
|
|
|
// nothing required here
|
|
|
|
return snippet;
|
|
|
|
}
|
|
|
|
|
|
|
|
var span = "<span class=\""+this.highlightClass+"\">"
|
|
|
|
+ snippet + "</span>";
|
|
|
|
|
|
|
|
if (this.highlightLink) {
|
|
|
|
span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
|
|
|
|
+span+"</a>";
|
|
|
|
}
|
|
|
|
return span;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class TextHighlighter extends BaseHighlighter {
|
|
|
|
constructor(highlightClass, highlightLink) {
|
|
|
|
super(highlightClass, highlightLink);
|
|
|
|
this._key = 0;
|
|
|
|
}
|
2015-12-24 00:50:35 +01:00
|
|
|
|
|
|
|
/* create a <span> node to hold the given content
|
|
|
|
*
|
2016-02-17 20:50:04 +01:00
|
|
|
* snippet: content of the span
|
2015-12-24 00:50:35 +01:00
|
|
|
* highlight: true to highlight as a search match
|
2016-02-17 20:50:04 +01:00
|
|
|
*
|
|
|
|
* returns a React node
|
2015-12-24 00:50:35 +01:00
|
|
|
*/
|
2016-02-17 20:50:04 +01:00
|
|
|
_processSnippet(snippet, highlight) {
|
2016-02-22 18:44:34 +01:00
|
|
|
var key = this._key++;
|
2015-12-24 00:50:35 +01:00
|
|
|
|
2016-02-22 18:44:34 +01:00
|
|
|
var node =
|
|
|
|
<span key={key} className={highlight ? this.highlightClass : null }>
|
|
|
|
{ snippet }
|
|
|
|
</span>;
|
2016-02-17 20:50:04 +01:00
|
|
|
|
|
|
|
if (highlight && this.highlightLink) {
|
2016-09-16 17:02:08 +02:00
|
|
|
node = <a key={key} href={this.highlightLink}>{node}</a>;
|
2015-12-24 00:50:35 +01:00
|
|
|
}
|
2016-02-22 18:44:34 +01:00
|
|
|
|
2016-02-17 20:50:04 +01:00
|
|
|
return node;
|
2015-12-24 00:50:35 +01:00
|
|
|
}
|
|
|
|
}
|
2015-11-27 16:02:32 +01:00
|
|
|
|
|
|
|
|
2015-12-24 00:50:35 +01:00
|
|
|
/* turn a matrix event body into html
|
|
|
|
*
|
|
|
|
* content: 'content' of the MatrixEvent
|
|
|
|
*
|
2016-02-10 21:25:32 +01:00
|
|
|
* highlights: optional list of words to highlight, ordered by longest word first
|
2015-12-24 00:50:35 +01:00
|
|
|
*
|
2016-07-18 02:34:26 +02:00
|
|
|
* opts.highlightLink: optional href to add to highlighted words
|
2015-12-24 00:50:35 +01:00
|
|
|
*/
|
2016-09-16 17:02:08 +02:00
|
|
|
export function bodyToHtml(content, highlights, opts) {
|
|
|
|
opts = opts || {};
|
|
|
|
|
|
|
|
var isHtml = (content.format === "org.matrix.custom.html");
|
|
|
|
let body = isHtml ? content.formatted_body : escape(content.body);
|
|
|
|
|
|
|
|
var safeBody;
|
|
|
|
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
|
|
|
|
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
|
|
|
|
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
|
|
|
|
// by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either
|
|
|
|
try {
|
|
|
|
if (highlights && highlights.length > 0) {
|
|
|
|
var highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
|
|
|
|
var safeHighlights = highlights.map(function(highlight) {
|
|
|
|
return sanitizeHtml(highlight, sanitizeHtmlParams);
|
|
|
|
});
|
|
|
|
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure.
|
|
|
|
sanitizeHtmlParams.textFilter = function(safeText) {
|
|
|
|
return highlighter.applyHighlights(safeText, safeHighlights).join('');
|
|
|
|
};
|
2015-11-27 16:02:32 +01:00
|
|
|
}
|
2016-09-16 17:02:08 +02:00
|
|
|
safeBody = sanitizeHtml(body, sanitizeHtmlParams);
|
|
|
|
safeBody = unicodeToImage(safeBody);
|
|
|
|
}
|
|
|
|
finally {
|
|
|
|
delete sanitizeHtmlParams.textFilter;
|
|
|
|
}
|
2016-07-05 00:34:57 +02:00
|
|
|
|
2016-09-16 17:02:08 +02:00
|
|
|
EMOJI_REGEX.lastIndex = 0;
|
2017-02-09 02:18:09 +01:00
|
|
|
let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : '';
|
2016-09-16 17:02:08 +02:00
|
|
|
let match = EMOJI_REGEX.exec(contentBodyTrimmed);
|
|
|
|
let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
|
2015-11-27 16:02:32 +01:00
|
|
|
|
2016-09-16 17:02:08 +02:00
|
|
|
const className = classNames({
|
|
|
|
'mx_EventTile_body': true,
|
|
|
|
'mx_EventTile_bigEmoji': emojiBody,
|
|
|
|
'markdown-body': isHtml,
|
|
|
|
});
|
|
|
|
return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} />;
|
|
|
|
}
|
2015-11-27 16:02:32 +01:00
|
|
|
|
2016-09-16 17:02:08 +02:00
|
|
|
export function emojifyText(text) {
|
|
|
|
return {
|
|
|
|
__html: unicodeToImage(escape(text)),
|
|
|
|
};
|
|
|
|
}
|