Render and sanitize chat messages server-side. (#237)
* Render and sanitize chat messages server-side. Closes #235 * Render content.md server-side and return it in the client config * Remove showdown from web project * Update api spec * Move example user content file
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { h, Component } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
import showdown from '/js/web_modules/showdown.js';
|
||||
|
||||
import { OwncastPlayer } from './components/player.js';
|
||||
import SocialIconsList from './components/social-icons-list.js';
|
||||
@@ -97,7 +96,6 @@ export default class App extends Component {
|
||||
// fetch events
|
||||
this.getConfig = this.getConfig.bind(this);
|
||||
this.getStreamStatus = this.getStreamStatus.bind(this);
|
||||
this.getExtraUserContent = this.getExtraUserContent.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -164,30 +162,9 @@ export default class App extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
// fetch content.md
|
||||
getExtraUserContent(path) {
|
||||
fetch(path)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Network response was not ok ${response.ok}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => {
|
||||
this.setState({
|
||||
extraUserContent: new showdown.Converter().makeHtml(text),
|
||||
});
|
||||
})
|
||||
.catch((error) => {});
|
||||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { title, extraUserInfoFileName, summary } = data;
|
||||
|
||||
const { title, summary } = data;
|
||||
window.document.title = title;
|
||||
if (extraUserInfoFileName) {
|
||||
this.getExtraUserContent(extraUserInfoFileName);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
configData: {
|
||||
@@ -349,7 +326,6 @@ export default class App extends Component {
|
||||
chatInputEnabled,
|
||||
configData,
|
||||
displayChat,
|
||||
extraUserContent,
|
||||
orientation,
|
||||
playerActive,
|
||||
streamOnline,
|
||||
@@ -371,6 +347,7 @@ export default class App extends Component {
|
||||
summary,
|
||||
tags = [],
|
||||
title,
|
||||
extraUserContent,
|
||||
} = configData;
|
||||
const {
|
||||
small: smallLogo = TEMP_IMAGE,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { h, Component, createRef } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import '/js/web_modules/@justinribeiro/lite-youtube.js';
|
||||
|
||||
import Message from './message.js';
|
||||
import ChatInput from './chat-input.js';
|
||||
import { CALLBACKS, SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
|
||||
@@ -3,15 +3,14 @@ import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import { messageBubbleColorForString } from '../../utils/user-colors.js';
|
||||
import { formatMessageText, formatTimestamp } from '../../utils/chat.js';
|
||||
import { generateAvatar } from '../../utils/helpers.js';
|
||||
import { convertToText } from '../../utils/chat.js';
|
||||
import { SOCKET_MESSAGE_TYPES } from '../../utils/websocket.js';
|
||||
|
||||
export default class Message extends Component {
|
||||
render(props) {
|
||||
const { message, username } = props;
|
||||
const { type } = message;
|
||||
|
||||
if (type === SOCKET_MESSAGE_TYPES.CHAT) {
|
||||
const { image, author, body, timestamp } = message;
|
||||
const formattedMessage = formatMessageText(body, username);
|
||||
@@ -66,3 +65,118 @@ export default class Message extends Component {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMessageText(message, username) {
|
||||
let formattedText = highlightUsername(message, username);
|
||||
formattedText = getMessageWithEmbeds(formattedText);
|
||||
return convertToMarkup(formattedText);
|
||||
}
|
||||
|
||||
function highlightUsername(message, username) {
|
||||
const pattern = new RegExp(
|
||||
'@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'),
|
||||
'gi'
|
||||
);
|
||||
return message.replace(
|
||||
pattern,
|
||||
'<span class="highlighted px-1 rounded font-bold bg-orange-500">$&</span>'
|
||||
);
|
||||
}
|
||||
|
||||
function getMessageWithEmbeds(message) {
|
||||
var embedText = '';
|
||||
// Make a temporary element so we can actually parse the html and pull anchor tags from it.
|
||||
// This is a better approach than regex.
|
||||
var container = document.createElement('p');
|
||||
container.innerHTML = message;
|
||||
|
||||
var anchors = container.getElementsByTagName('a');
|
||||
for (var i = 0; i < anchors.length; i++) {
|
||||
const url = anchors[i].href;
|
||||
if (getYoutubeIdFromURL(url)) {
|
||||
const youtubeID = getYoutubeIdFromURL(url);
|
||||
embedText += getYoutubeEmbedFromID(youtubeID);
|
||||
} else if (url.indexOf('instagram.com/p/') > -1) {
|
||||
embedText += getInstagramEmbedFromURL(url);
|
||||
} else if (isImage(url)) {
|
||||
embedText += getImageForURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
// If this message only consists of a single embeddable link
|
||||
// then only return the embed and strip the link url from the text.
|
||||
if (embedText !== '' && anchors.length == 1 && isMessageJustAnchor(message, anchors[0])) {
|
||||
return embedText;
|
||||
}
|
||||
return message + embedText;
|
||||
}
|
||||
|
||||
function getYoutubeIdFromURL(url) {
|
||||
try {
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
|
||||
if (match && match[2].length == 11) {
|
||||
return match[2];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getYoutubeEmbedFromID(id) {
|
||||
return `<div class="chat-embed youtube-embed"><lite-youtube videoid="${id}" /></div>`;
|
||||
}
|
||||
|
||||
function getInstagramEmbedFromURL(url) {
|
||||
const urlObject = new URL(url.replace(/\/$/, ''));
|
||||
urlObject.pathname += '/embed';
|
||||
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
function isImage(url) {
|
||||
const re = /\.(jpe?g|png|gif)$/i;
|
||||
return re.test(url);
|
||||
}
|
||||
|
||||
function getImageForURL(url) {
|
||||
return `<a target="_blank" href="${url}"><img class="chat-embed embedded-image" src="${url}" /></a>`;
|
||||
}
|
||||
|
||||
function isMessageJustAnchor(message, anchor) {
|
||||
return stripTags(message) === stripTags(anchor.innerHTML);
|
||||
}
|
||||
|
||||
function formatTimestamp(sentAt) {
|
||||
sentAt = new Date(sentAt);
|
||||
if (isNaN(sentAt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let diffInDays = (new Date() - sentAt) / (24 * 3600 * 1000);
|
||||
if (diffInDays >= 1) {
|
||||
return (
|
||||
`Sent at ${sentAt.toLocaleDateString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
})} at ` + sentAt.toLocaleTimeString()
|
||||
);
|
||||
}
|
||||
|
||||
return `Sent at ${sentAt.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
/*
|
||||
You would call this when receiving a plain text
|
||||
value back from an API, and before inserting the
|
||||
text into the `contenteditable` area on a page.
|
||||
*/
|
||||
function convertToMarkup(str = '') {
|
||||
return convertToText(str).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function stripTags(str) {
|
||||
return str.replace(/<\/?[^>]+(>|$)/g, '');
|
||||
}
|
||||
|
||||
@@ -4,134 +4,6 @@ import {
|
||||
CHAT_PLACEHOLDER_OFFLINE,
|
||||
} from './constants.js';
|
||||
|
||||
import showdown from '/js/web_modules/showdown.js';
|
||||
export function formatMessageText(message, username) {
|
||||
showdown.setFlavor('github');
|
||||
let formattedText = new showdown.Converter({
|
||||
emoji: true,
|
||||
openLinksInNewWindow: true,
|
||||
tables: false,
|
||||
simplifiedAutoLink: false,
|
||||
literalMidWordUnderscores: true,
|
||||
strikethrough: true,
|
||||
ghMentions: false,
|
||||
}).makeHtml(message);
|
||||
|
||||
formattedText = linkify(formattedText, message);
|
||||
formattedText = highlightUsername(formattedText, username);
|
||||
|
||||
return convertToMarkup(formattedText);
|
||||
}
|
||||
|
||||
function highlightUsername(message, username) {
|
||||
const pattern = new RegExp('@?' + username.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi');
|
||||
return message.replace(
|
||||
pattern,
|
||||
'<span class="highlighted px-1 rounded font-bold bg-orange-500">$&</span>'
|
||||
);
|
||||
}
|
||||
|
||||
function linkify(text, rawText) {
|
||||
const urls = getURLs(stripTags(rawText));
|
||||
if (urls) {
|
||||
urls.forEach(function (url) {
|
||||
let linkURL = url;
|
||||
|
||||
// Add http prefix if none exist in the URL so it actually
|
||||
// will work in an anchor tag.
|
||||
if (linkURL.indexOf('http') === -1) {
|
||||
linkURL = 'http://' + linkURL;
|
||||
}
|
||||
|
||||
// Remove the protocol prefix in the display URLs just to make
|
||||
// things look a little nicer.
|
||||
const displayURL = url.replace(/(^\w+:|^)\/\//, '');
|
||||
const link = `<a href="${linkURL}" target="_blank">${displayURL}</a>`;
|
||||
text = text.replace(url, link);
|
||||
|
||||
if (getYoutubeIdFromURL(url)) {
|
||||
if (isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += '<br/>';
|
||||
}
|
||||
|
||||
const youtubeID = getYoutubeIdFromURL(url);
|
||||
text += getYoutubeEmbedFromID(youtubeID);
|
||||
} else if (url.indexOf('instagram.com/p/') > -1) {
|
||||
if (isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += `<br/>`;
|
||||
}
|
||||
text += getInstagramEmbedFromURL(url);
|
||||
} else if (isImage(url)) {
|
||||
if (isTextJustURLs(text, [url, displayURL])) {
|
||||
text = '';
|
||||
} else {
|
||||
text += `<br/>`;
|
||||
}
|
||||
text += getImageForURL(url);
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function isTextJustURLs(text, urls) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
const url = urls[i];
|
||||
if (stripTags(text) === url) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function stripTags(str) {
|
||||
return str.replace(/<\/?[^>]+(>|$)/g, "");
|
||||
}
|
||||
|
||||
function getURLs(str) {
|
||||
var exp = /((\w+:\/\/\S+)|(\w+[\.:]\w+\S+))[^\s,\.]/ig;
|
||||
return str.match(exp);
|
||||
}
|
||||
|
||||
function getYoutubeIdFromURL(url) {
|
||||
try {
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
|
||||
if (match && match[2].length == 11) {
|
||||
return match[2];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getYoutubeEmbedFromID(id) {
|
||||
return `<div class="chat-embed youtube-embed"><lite-youtube videoid="${id}" /></div>`;
|
||||
}
|
||||
|
||||
function getInstagramEmbedFromURL(url) {
|
||||
const urlObject = new URL(url.replace(/\/$/, ""));
|
||||
urlObject.pathname += "/embed";
|
||||
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
function isImage(url) {
|
||||
const re = /\.(jpe?g|png|gif)$/i;
|
||||
return re.test(url);
|
||||
}
|
||||
|
||||
function getImageForURL(url) {
|
||||
return `<a target="_blank" href="${url}"><img class="chat-embed embedded-image" src="${url}" /></a>`;
|
||||
}
|
||||
|
||||
// Taken from https://stackoverflow.com/questions/3972014/get-contenteditable-caret-index-position
|
||||
export function getCaretPosition(editableDiv) {
|
||||
@@ -234,15 +106,6 @@ export function convertToText(str = '') {
|
||||
return value;
|
||||
}
|
||||
|
||||
/*
|
||||
You would call this when receiving a plain text
|
||||
value back from an API, and before inserting the
|
||||
text into the `contenteditable` area on a page.
|
||||
*/
|
||||
export function convertToMarkup(str = '') {
|
||||
return convertToText(str).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/*
|
||||
You would call this when a user pastes from
|
||||
the clipboard into a `contenteditable` area.
|
||||
@@ -279,18 +142,3 @@ export function convertOnPaste( event = { preventDefault() {} }) {
|
||||
document.execCommand('insertText', false, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimestamp(sentAt) {
|
||||
sentAt = new Date(sentAt);
|
||||
if (isNaN(sentAt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let diffInDays = ((new Date()) - sentAt) / (24 * 3600 * 1000);
|
||||
if (diffInDays >= 1) {
|
||||
return `Sent at ${sentAt.toLocaleDateString('en-US', {dateStyle: 'medium'})} at ` +
|
||||
sentAt.toLocaleTimeString();
|
||||
}
|
||||
|
||||
return `Sent at ${sentAt.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { c as createCommonjsModule, a as commonjsGlobal, g as getDefaultExportFromCjs } from '../../../common/_commonjsHelpers-37fa8da4.js';
|
||||
import { d as document_1, w as window_1$1 } from '../../../common/window-2f8a9a85.js';
|
||||
import { c as createCommonjsModule, a as commonjsGlobal, d as document_1, w as window_1$1, g as getDefaultExportFromCjs } from '../../../common/window-1e586371.js';
|
||||
|
||||
var _extends_1 = createCommonjsModule(function (module) {
|
||||
function _extends() {
|
||||
|
||||
66
webroot/js/web_modules/common/window-1e586371.js
Normal file
66
webroot/js/web_modules/common/window-1e586371.js
Normal file
@@ -0,0 +1,66 @@
|
||||
var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
|
||||
|
||||
function getDefaultExportFromCjs (x) {
|
||||
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
||||
}
|
||||
|
||||
function createCommonjsModule(fn, basedir, module) {
|
||||
return module = {
|
||||
path: basedir,
|
||||
exports: {},
|
||||
require: function (path, base) {
|
||||
return commonjsRequire(path, (base === undefined || base === null) ? module.path : base);
|
||||
}
|
||||
}, fn(module, module.exports), module.exports;
|
||||
}
|
||||
|
||||
function getDefaultExportFromNamespaceIfNotNamed (n) {
|
||||
return n && Object.prototype.hasOwnProperty.call(n, 'default') && Object.keys(n).length === 1 ? n['default'] : n;
|
||||
}
|
||||
|
||||
function commonjsRequire () {
|
||||
throw new Error('Dynamic requires are not currently supported by @rollup/plugin-commonjs');
|
||||
}
|
||||
|
||||
var _nodeResolve_empty = {};
|
||||
|
||||
var _nodeResolve_empty$1 = /*#__PURE__*/Object.freeze({
|
||||
__proto__: null,
|
||||
'default': _nodeResolve_empty
|
||||
});
|
||||
|
||||
var minDoc = /*@__PURE__*/getDefaultExportFromNamespaceIfNotNamed(_nodeResolve_empty$1);
|
||||
|
||||
var topLevel = typeof commonjsGlobal !== 'undefined' ? commonjsGlobal :
|
||||
typeof window !== 'undefined' ? window : {};
|
||||
|
||||
|
||||
var doccy;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
doccy = document;
|
||||
} else {
|
||||
doccy = topLevel['__GLOBAL_DOCUMENT_CACHE@4'];
|
||||
|
||||
if (!doccy) {
|
||||
doccy = topLevel['__GLOBAL_DOCUMENT_CACHE@4'] = minDoc;
|
||||
}
|
||||
}
|
||||
|
||||
var document_1 = doccy;
|
||||
|
||||
var win;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
win = window;
|
||||
} else if (typeof commonjsGlobal !== "undefined") {
|
||||
win = commonjsGlobal;
|
||||
} else if (typeof self !== "undefined"){
|
||||
win = self;
|
||||
} else {
|
||||
win = {};
|
||||
}
|
||||
|
||||
var window_1 = win;
|
||||
|
||||
export { commonjsGlobal as a, createCommonjsModule as c, document_1 as d, getDefaultExportFromCjs as g, window_1 as w };
|
||||
1
webroot/js/web_modules/import-map.json
vendored
1
webroot/js/web_modules/import-map.json
vendored
@@ -6,7 +6,6 @@
|
||||
"@videojs/themes/fantasy/index.css": "./@videojs/themes/fantasy/index.css",
|
||||
"htm": "./htm.js",
|
||||
"preact": "./preact.js",
|
||||
"showdown": "./showdown.js",
|
||||
"tailwindcss/dist/tailwind.min.css": "./tailwindcss/dist/tailwind.min.css",
|
||||
"video.js/dist/video-js.min.css": "./videojs/dist/video-js.min.css",
|
||||
"video.js/dist/video.min.js": "./videojs/dist/video.min.js"
|
||||
|
||||
5044
webroot/js/web_modules/showdown.js
vendored
5044
webroot/js/web_modules/showdown.js
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { c as createCommonjsModule, a as commonjsGlobal } from '../../common/_commonjsHelpers-37fa8da4.js';
|
||||
import { w as window_1, d as document_1 } from '../../common/window-2f8a9a85.js';
|
||||
import { c as createCommonjsModule, w as window_1, d as document_1, a as commonjsGlobal } from '../../common/window-1e586371.js';
|
||||
|
||||
var video_min = createCommonjsModule(function (module, exports) {
|
||||
/**
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Stream description and content can go here.
|
||||
|
||||
1. Edit `content.md` in markdown.
|
||||
1. And it'll go here.
|
||||
@@ -152,6 +152,8 @@
|
||||
|
||||
|
||||
.message-text .emoji {
|
||||
position: relative;
|
||||
top: -5px;
|
||||
width: 3rem;
|
||||
padding: .25rem
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user