| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413 | 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 = Boards.findOne(Session.get('currentBoard'));          callback(            _.union(            currentBoard              .activeMembers()              .map(member => {                const user = Users.findOne(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(/(<a )/gi, '$1target=_ '); // always to new target        return output;      };      const editor = '.editor';      const selectors = [        `.js-new-description-form ${editor}`,        `.js-new-comment-form ${editor}`,        `.js-edit-comment ${editor}`,      ].join(','); // only new comment and edit comment      const inputs = $(selectors);      if (inputs.length === 0) {        // only enable richereditor to new comment or edit comment no others        enableTextarea();      } else {        const placeholder = inputs.attr('placeholder') || '';        const mSummernotes = [];        const getSummernote = function(input) {          const idx = inputs.index(input);          if (idx > -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 = Boards.findOne(Session.get('currentBoard'));    if (!currentBoard)      return HTML.Raw(        DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }),      );    const knowedUsers = _.union(currentBoard.members.map(member => {      const u = Users.findOne(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();    }  },});
 |