| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 | Template.editor.onRendered(() => {  const textareaSelector = 'textarea';  const mentions = [    // User mentions    {      match: /\B@([\w.]*)$/,      search(term, callback) {        const currentBoard = Boards.findOne(Session.get('currentBoard'));        callback(          currentBoard            .activeMembers()            .map(member => {              const username = Users.findOne(member.userId).username;              return username.includes(term) ? username : null;            })            .filter(Boolean),        );      },      template(value) {        return value;      },      replace(username) {        return `@${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', '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 = Cards.findOne(Session.get('currentCard'));                const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;                const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;                const insertImage = src => {                  const img = document.createElement('img');                  img.src = src;                  img.setAttribute('width', '100%');                  $summernote.summernote('insertNode', img);                };                const processData = function(fileObj) {                  Utils.processUploadedAttachment(                    currentCard,                    fileObj,                    attachment => {                      if (                        attachment &&                        attachment._id &&                        attachment.isImage()                      ) {                        attachment.one('uploaded', function() {                          const maxTry = 3;                          const checkItvl = 500;                          let retry = 0;                          const checkUrl = function() {                            // even though uploaded event fired, attachment.url() is still null somehow //TODO                            const url = attachment.url();                            if (url) {                              insertImage(                                `${location.protocol}//${location.host}${url}`,                              );                            } else {                              retry++;                              if (retry < maxTry) {                                setTimeout(checkUrl, checkItvl);                              }                            }                          };                          checkUrl();                        });                      }                    },                  );                };                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;                            processData(blob);                          }                        },                      });                    }                  };                  reader.readAsDataURL(image);                } else {                  processData(image);                }              }            },            onPaste() {              // 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,          disableDragAndDrop: true,          toolbar,          popover: {            image: [              [                'image',                ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone'],              ],              ['float', ['floatLeft', 'floatRight', 'floatNone']],              ['remove', ['removeMedia']],            ],            table: [              ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],              ['delete', ['deleteRow', 'deleteCol', 'deleteTable']],            ],            air: [              ['color', ['color']],              ['font', ['bold', 'underline', 'clear']],            ],          },          height: 200,        });      });    }  } else {    enableTextarea();  }});import sanitizeXss from 'xss';// Additional  safeAttrValue function to allow for other specific protocols// See https://github.com/leizongmin/js-xss/issues/52#issuecomment-241354114function 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:/ig.test(value) || /^cbthunderlink:/ig.test(value) || /^aodroplink:/ig.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(sanitizeXss(content, { safeAttrValue: mySafeAttrValue }));    const knowedUsers = currentBoard.members.map(member => {      const u = Users.findOne(member.userId);      if (u) {        member.username = u.username;      }      return member;    });    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(sanitizeXss(content, { safeAttrValue: mySafeAttrValue }));  }),);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) {        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();    }  },});
 |