From 71d1d9ad981750cac1e3f05d36c8efa8a1f1dded Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Wed, 7 Aug 2019 23:44:45 -0400 Subject: Bug fix: bug#2589 #2575, Add Features: allowing user to insert/paste link, image, video --- client/components/activities/comments.js | 3 +- client/components/cards/attachments.js | 58 +--------- client/components/main/editor.js | 183 +++++++++++++++++++++---------- client/lib/utils.js | 52 +++++++++ 4 files changed, 186 insertions(+), 110 deletions(-) (limited to 'client') diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js index 8289b628..95084646 100644 --- a/client/components/activities/comments.js +++ b/client/components/activities/comments.js @@ -38,6 +38,7 @@ BlazeComponent.extendComponent({ resetCommentInput(input); Tracker.flush(); autosize.update(input); + input.trigger('submitted'); } evt.preventDefault(); }, @@ -54,7 +55,7 @@ BlazeComponent.extendComponent({ // XXX This should be a static method of the `commentForm` component function resetCommentInput(input) { - input.val('').trigger('input'); // without manually trigger, input event won't be fired + input.val(''); // without manually trigger, input event won't be fired input.blur(); commentFormIsOpen.set(false); } diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index f536a655..2cf68c59 100644 --- a/client/components/cards/attachments.js +++ b/client/components/cards/attachments.js @@ -86,7 +86,7 @@ Template.cardAttachmentsPopup.events({ reader.onload = function(e) { const dataurl = e && e.target && e.target.result; if (dataurl !== undefined) { - shrinkImage({ + Utils.shrinkImage({ dataurl, maxSize: MAX_IMAGE_PIXEL, ratio: COMPRESS_RATIO, @@ -118,59 +118,9 @@ Template.cardAttachmentsPopup.events({ 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'), }); -const MAX_IMAGE_PIXEL = Meteor.settings.public.MAX_IMAGE_PIXEL; -const COMPRESS_RATIO = Meteor.settings.public.IMAGE_COMPRESS_RATIO; +const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL; +const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO; let pastedResults = null; -const shrinkImage = function(options) { - // shrink image to certain size - const dataurl = options.dataurl, - callback = options.callback, - toBlob = options.toBlob; - let canvas = document.createElement('canvas'), - image = document.createElement('img'); - const maxSize = options.maxSize || 1024; - const ratio = options.ratio || 1.0; - const next = function(result) { - image = null; - canvas = null; - if (typeof callback === 'function') { - callback(result); - } - }; - image.onload = function() { - let width = this.width, - height = this.height; - let changed = false; - if (width > height) { - if (width > maxSize) { - height *= maxSize / width; - width = maxSize; - changed = true; - } - } else if (height > maxSize) { - width *= maxSize / height; - height = maxSize; - changed = true; - } - canvas.width = width; - canvas.height = height; - canvas.getContext('2d').drawImage(this, 0, 0, width, height); - if (changed === true) { - const type = 'image/jpeg'; - if (toBlob) { - canvas.toBlob(next, type, ratio); - } else { - next(canvas.toDataURL(type, ratio)); - } - } else { - next(changed); - } - }; - image.onerror = function() { - next(false); - }; - image.src = dataurl; -}; Template.previewClipboardImagePopup.onRendered(() => { // we can paste image from clipboard @@ -182,7 +132,7 @@ Template.previewClipboardImagePopup.onRendered(() => { }; if (MAX_IMAGE_PIXEL) { // if has size limitation on image we shrink it before uploading - shrinkImage({ + Utils.shrinkImage({ dataurl: results.dataURL, maxSize: MAX_IMAGE_PIXEL, ratio: COMPRESS_RATIO, diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 98461c4f..735aac1e 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,7 +1,79 @@ +import _sanitizeXss from 'xss'; +const enableRicherEditor = + Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR || true; +const sanitizeXss = (input, options) => { + const defaultAllowedIframeSrc = /^(https:){0,1}\/\/.*?(youtube|vimeo|dailymotion|youku)/i; + const allowedIframeSrcRegex = (function() { + let reg = defaultAllowedIframeSrc; + const SAFE_IFRAME_SRC_PATTERN = + Meteor.settings.public.SAFE_IFRAME_SRC_PATTERN; + try { + if (SAFE_IFRAME_SRC_PATTERN !== undefined) { + reg = new RegExp(SAFE_IFRAME_SRC_PATTERN, 'i'); + } + } catch (e) { + /*eslint no-console: ["error", { allow: ["warn", "error"] }] */ + + console.error('Wrong pattern specified', SAFE_IFRAM_SRC_PATTERN, e); + } + return reg; + })(); + const targetWindow = '_blank'; + options = { + onTag(tag, html, options) { + if (tag === 'iframe') { + const clipCls = 'note-vide-clip'; + if (!options.isClosing) { + const srcp = /src=(['"]{0,1})(\S*)(\1)/; + let safe = html.indexOf(`class="${clipCls}"`) > -1; + if (srcp.exec(html)) { + const src = RegExp.$2; + if (allowedIframeSrcRegex.exec(src)) { + safe = true; + } + if (safe) + return ``; + } + } else { + return ''; + } + } else if (tag === 'a') { + if (!options.isClosing) { + if (/href=(['"]{0,1})(\S*)(\1)/.exec(html)) { + const href = RegExp.$2; + if (href.match(/^((http(s){0,1}:){0,1}\/\/|\/)/)) { + // a valid url + return ``; + } + } + } + } else if (tag === 'img') { + if (!options.isClosing) { + if (new RegExp('src=([\'"]{0,1})(\\S*)(\\1)').exec(html)) { + const src = RegExp.$2; + return ``; + } + } + } + return undefined; + }, + onTagAttr(tag, name, value) { + if (tag === 'img' && name === 'src') { + if (value && value.substr(0, 5) === 'data:') { + // allow image with dataURI src + return `${name}='${value}'`; + } + } else if (tag === 'a' && name === 'target') { + return `${name}='${targetWindow}'`; // always change a href target to a new window + } + return undefined; + }, + ...options, + }; + return _sanitizeXss(input, options); +}; Template.editor.onRendered(() => { const textareaSelector = 'textarea'; - const enableRicherEditor = - Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR || true; const mentions = [ // User mentions { @@ -50,47 +122,11 @@ Template.editor.onRendered(() => { ['color', ['color']], ['para', ['ul', 'ol', 'paragraph']], ['table', ['table']], - //['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled + ['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled //['insert', ['link', 'picture']], // modal popup has issue somehow :( ['view', ['fullscreen', 'help']], ]; - const cleanPastedHTML = function(input) { - const badTags = [ - 'style', - 'script', - 'applet', - 'embed', - 'noframes', - 'noscript', - 'meta', - 'link', - 'button', - 'form', - ].join('|'); - const badPatterns = new RegExp( - `(?:${[ - `<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`, - `<(${badTags})[^>]*?\\/>`, - ].join('|')})`, - 'gi', - ); - let output = input; - // remove bad Tags - output = output.replace(badPatterns, ''); - // remove attributes ' style="..."' - const badAttributes = new RegExp( - `(?:${[ - 'on\\S+=([\'"]?).*?\\1', - 'href=([\'"]?)javascript:.*?\\2', - 'style=([\'"]?).*?\\3', - 'target=\\S+', - ].join('|')})`, - 'gi', - ); - output = output.replace(badAttributes, ''); - output = output.replace(/( { callbacks: { onInit(object) { const originalInput = this; - $(originalInput).on('input', function() { - // when comment is submitted, the original textarea will be set to '', so shall we + $(originalInput).on('submitted', function() { + // resetCommentInput has been called if (!this.value) { const sn = getSummernote(this); sn && sn.summernote('reset'); @@ -138,6 +174,42 @@ Template.editor.onRendered(() => { }); } }, + onImageUpload(files) { + const $summernote = getSummernote(this); + if (files && files.length > 0) { + const image = files[0]; + const reader = new FileReader(); + const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL; + const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO; + const processData = function(dataURL) { + const img = document.createElement('img'); + img.src = dataURL; + img.setAttribute('width', '100%'); + $summernote.summernote('insertNode', img); + }; + reader.onload = function(e) { + const dataurl = e && e.target && e.target.result; + if (dataurl !== undefined) { + if (MAX_IMAGE_PIXEL) { + // need to shrink image + Utils.shrinkImage({ + dataurl, + maxSize: MAX_IMAGE_PIXEL, + ratio: COMPRESS_RATIO, + callback(changed) { + if (changed !== false && !!changed) { + processData(changed); + } + }, + }); + } else { + processData(dataurl); + } + } + }; + reader.readAsDataURL(image); + } + }, onPaste() { // clear up unwanted tag info when user pasted in text const thisNote = this; @@ -185,8 +257,6 @@ Template.editor.onRendered(() => { } }); -import sanitizeXss from 'xss'; - // XXX I believe we should compute a HTML rendered field on the server that // would handle markdown and user mentions. We can simply have two // fields, one source, and one compiled version (in HTML) and send only the @@ -237,32 +307,35 @@ Blaze.Template.registerHelper( content = content.replace(fullMention, Blaze.toHTML(link)); } - return HTML.Raw(sanitizeXss(content)); }), ); - Template.viewer.events({ // Viewer sometimes have click-able wrapper around them (for instance to edit // the corresponding text). Clicking a link shouldn't fire these actions, stop // we stop these event at the viewer component level. 'click a'(event, templateInstance) { - event.stopPropagation(); - - // XXX We hijack the build-in browser action because we currently don't have - // `_blank` attributes in viewer links, and the transformer function is - // handled by a third party package that we can't configure easily. Fix that - // by using directly `_blank` attribute in the rendered HTML. - event.preventDefault(); - + let prevent = true; const userId = event.currentTarget.dataset.userid; if (userId) { Popup.open('member').call({ userId }, event, templateInstance); } else { const href = event.currentTarget.href; - if (href) { + const child = event.currentTarget.firstElementChild; + if (child && child.tagName === 'IMG') { + prevent = false; + } else if (href) { window.open(href, '_blank'); } } + if (prevent) { + event.stopPropagation(); + + // XXX We hijack the build-in browser action because we currently don't have + // `_blank` attributes in viewer links, and the transformer function is + // handled by a third party package that we can't configure easily. Fix that + // by using directly `_blank` attribute in the rendered HTML. + event.preventDefault(); + } }, }); diff --git a/client/lib/utils.js b/client/lib/utils.js index 5681273e..f81e691c 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -24,6 +24,58 @@ Utils = { ); }, + MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL, + COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO, + shrinkImage(options) { + // shrink image to certain size + const dataurl = options.dataurl, + callback = options.callback, + toBlob = options.toBlob; + let canvas = document.createElement('canvas'), + image = document.createElement('img'); + const maxSize = options.maxSize || 1024; + const ratio = options.ratio || 1.0; + const next = function(result) { + image = null; + canvas = null; + if (typeof callback === 'function') { + callback(result); + } + }; + image.onload = function() { + let width = this.width, + height = this.height; + let changed = false; + if (width > height) { + if (width > maxSize) { + height *= maxSize / width; + width = maxSize; + changed = true; + } + } else if (height > maxSize) { + width *= maxSize / height; + height = maxSize; + changed = true; + } + canvas.width = width; + canvas.height = height; + canvas.getContext('2d').drawImage(this, 0, 0, width, height); + if (changed === true) { + const type = 'image/jpeg'; + if (toBlob) { + canvas.toBlob(next, type, ratio); + } else { + next(canvas.toDataURL(type, ratio)); + } + } else { + next(changed); + } + }; + image.onerror = function() { + next(false); + }; + image.src = dataurl; + }, capitalize(string) { return string.charAt(0).toUpperCase() + string.slice(1); }, -- cgit v1.2.3-1-g7c22