import { ReactiveCache } from '/imports/reactiveCache'; const specialHandles = [ {userId: 'board_members', username: 'board_members'}, {userId: 'card_members', username: 'card_members'} ]; const specialHandleNames = specialHandles.map(m => m.username); BlazeComponent.extendComponent({ onRendered() { const textareaSelector = 'textarea'; const mentions = [ // User mentions { match: /\B@([\w.-]*)$/, search(term, callback) { const currentBoard = Utils.getCurrentBoard(); callback( _.union( currentBoard .activeMembers() .map(member => { const user = ReactiveCache.getUser(member.userId); const username = user.username; const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname : ""; return username.includes(term) || fullName.includes(term) ? user : null; }) .filter(Boolean), [...specialHandles]) ); }, template(user) { if (user.profile && user.profile.fullname) { return (user.profile.fullname + " (" + user.username + ")"); } return user.username; }, replace(user) { if (user.profile && user.profile.fullname) { return `@${user.username} (${user.profile.fullname}) `; } return `@${user.username} `; }, index: 1, }, ]; const enableTextarea = function() { const $textarea = this.$(textareaSelector); autosize($textarea); $textarea.escapeableTextComplete(mentions); }; if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) { const isSmall = Utils.isMiniScreen(); const toolbar = isSmall ? [ ['view', ['fullscreen']], ['table', ['table']], ['font', ['bold', 'underline']], //['fontsize', ['fontsize']], ['color', ['color']], ] : [ ['style', ['style']], ['font', ['bold', 'underline', 'clear']], ['fontsize', ['fontsize']], ['fontname', ['fontname']], ['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']], // modal popup has issue somehow :( ['view', ['fullscreen', 'codeview', '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(/( -1) { return mSummernotes[idx]; } return undefined; }; inputs.each(function(idx, input) { mSummernotes[idx] = $(input).summernote({ placeholder, callbacks: { onInit(object) { const originalInput = this; $(originalInput).on('submitted', function() { // when comment is submitted, the original textarea will be set to '', so shall we if (!this.value) { const sn = getSummernote(this); sn && sn.summernote('code', ''); } }); const jEditor = object && object.editable; const toolbar = object && object.toolbar; if (jEditor !== undefined) { jEditor.escapeableTextComplete(mentions); } if (toolbar !== undefined) { const fBtn = toolbar.find('.btn-fullscreen'); fBtn.on('click', function() { const $this = $(this), isActive = $this.hasClass('active'); $('.minicards,#header-quick-access').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually }); } }, onImageUpload(files) { const $summernote = getSummernote(this); if (files && files.length > 0) { const image = files[0]; const currentCard = Utils.getCurrentCard(); const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL; const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO; const processUpload = function(file) { const uploader = Attachments.insert( { file, meta: Utils.getCommonAttachmentMetaFrom(card), chunkSize: 'dynamic', }, false, ); uploader.on('uploaded', (error, fileRef) => { if (!error) { if (fileRef.isImage) { const img = document.createElement('img'); img.src = fileRef.link(); img.setAttribute('width', '100%'); $summernote.summernote('insertNode', img); } } }); uploader.start(); }; if (MAX_IMAGE_PIXEL) { const reader = new FileReader(); reader.onload = function(e) { const dataurl = e && e.target && e.target.result; if (dataurl !== undefined) { // need to shrink image Utils.shrinkImage({ dataurl, maxSize: MAX_IMAGE_PIXEL, ratio: COMPRESS_RATIO, toBlob: true, callback(blob) { if (blob !== false) { blob.name = image.name; processUpload(blob); } }, }); } }; reader.readAsDataURL(image); } else { processUpload(image); } } }, onPaste(e) { var clipboardData = e.clipboardData; var pastedData = clipboardData.getData('Text'); //if pasted data is an image, exit if (!pastedData.length) { e.preventDefault(); return; } // clear up unwanted tag info when user pasted in text const thisNote = this; const updatePastedText = function(object) { const someNote = getSummernote(object); // Fix Pasting text into a card is adding a line before and after // (and multiplies by pasting more) by changing paste "p" to "br". // Fixes https://github.com/wekan/wekan/2890 . // == Fix Start == someNote.execCommand('defaultParagraphSeparator', false, 'br'); // == Fix End == const original = someNote.summernote('code'); const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML. someNote.summernote('code', ''); //clear original someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code. }; setTimeout(function() { //this kinda sucks, but if you don't do a setTimeout, //the function is called before the text is really pasted. updatePastedText(thisNote); }, 10); }, }, dialogsInBody: true, spellCheck: true, disableGrammar: false, disableDragAndDrop: false, toolbar, popover: { image: [ ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']], ['float', ['floatLeft', 'floatRight', 'floatNone']], ['remove', ['removeMedia']], ], link: [['link', ['linkDialogShow', 'unlink']]], table: [ ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']], ['delete', ['deleteRow', 'deleteCol', 'deleteTable']], ], air: [ ['color', ['color']], ['font', ['bold', 'underline', 'clear']], ], }, height: 200, }); }); } } else { enableTextarea(); } }, events() { return [ { 'click a.fa.fa-copy'(event) { const $editor = this.$('textarea.editor'); const promise = Utils.copyTextToClipboard($editor[0].value); const $tooltip = this.$('.copied-tooltip'); Utils.showCopied(promise, $tooltip); }, } ] } }).register('editor'); import DOMPurify from 'dompurify'; // Additional safeAttrValue function to allow for other specific protocols // See https://github.com/leizongmin/js-xss/issues/52#issuecomment-241354114 /* function mySafeAttrValue(tag, name, value, cssFilter) { // only when the tag is 'a' and attribute is 'href' // then use your custom function if (tag === 'a' && name === 'href') { // only filter the value if starts with 'cbthunderlink:' or 'aodroplink' if ( /^thunderlink:/gi.test(value) || /^cbthunderlink:/gi.test(value) || /^aodroplink:/gi.test(value) || /^onenote:/gi.test(value) || /^file:/gi.test(value) || /^abasurl:/gi.test(value) || /^conisio:/gi.test(value) || /^mailspring:/gi.test(value) ) { return value; } else { // use the default safeAttrValue function to process all non cbthunderlinks return sanitizeXss.safeAttrValue(tag, name, value, cssFilter); } } else { // use the default safeAttrValue function to process it return sanitizeXss.safeAttrValue(tag, name, value, cssFilter); } } */ // 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 // compiled version to most users -- who don't need to edit. // In the meantime, all the transformation are done on the client using the // Blaze API. const at = HTML.CharRef({ html: '@', str: '@' }); Blaze.Template.registerHelper( 'mentions', new Template('mentions', function() { const view = this; let content = Blaze.toHTML(view.templateContentBlock); const currentBoard = Utils.getCurrentBoard(); if (!currentBoard) return HTML.Raw( DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }), ); const knowedUsers = _.union(currentBoard.members.map(member => { const u = ReactiveCache.getUser(member.userId); if (u) { member.username = u.username; } return member; }), [...specialHandles]); const mentionRegex = /\B@([\w.-]*)/gi; let currentMention; while ((currentMention = mentionRegex.exec(content)) !== null) { const [fullMention, quoteduser, simple] = currentMention; const username = quoteduser || simple; const knowedUser = _.findWhere(knowedUsers, { username }); if (!knowedUser) { continue; } const linkValue = [' ', at, knowedUser.username]; let linkClass = 'atMention js-open-member'; if (knowedUser.userId === Meteor.userId()) { linkClass += ' me'; } // This @user mention link generation did open same Wekan // window in new tab, so now A is changed to U so it's // underlined and there is no link popup. This way also // text can be selected more easily. //const link = HTML.A( const link = HTML.U( { class: linkClass, // XXX Hack. Since we stringify this render function result below with // `Blaze.toHTML` we can't rely on blaze data contexts to pass the // `userId` to the popup as usual, and we need to store it in the DOM // using a data attribute. 'data-userId': knowedUser.userId, }, linkValue, ); content = content.replace(fullMention, Blaze.toHTML(link)); } return HTML.Raw( DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }), ); }), ); 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) { const 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) { // Open links in current browser tab, changed from _blank to _self, and back to _blank: // https://github.com/wekan/wekan/discussions/3534 //window.open(href, '_self'); 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(); } }, });