| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 | import _sanitizeXss from 'xss';const ASIS = 'asis';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';  const getHtmlDOM = html => {    const i = document.createElement('i');    i.innerHTML = html;    return i.firstChild;  };  options = {    onTag(tag, html, options) {      const htmlDOM = getHtmlDOM(html);      const getAttr = attr => {        return htmlDOM && attr && htmlDOM.getAttribute(attr);      };      if (tag === 'iframe') {        const clipCls = 'note-vide-clip';        if (!options.isClosing) {          const iframeCls = getAttr('class');          let safe = iframeCls.indexOf(clipCls) > -1;          const src = getAttr('src');          if (allowedIframeSrcRegex.exec(src)) {            safe = true;          }          if (safe)            return `<iframe src='${src}' class="${clipCls}" width=100% height=auto allowfullscreen></iframe>`;        } else {          // remove </iframe> tag          return '';        }      } else if (tag === 'a') {        if (!options.isClosing) {          if (getAttr(ASIS) === 'true') {            // if has a ASIS attribute, don't do anything, it's a member id            return html;          } else {            const href = getAttr('href');            if (href.match(/^((http(s){0,1}:){0,1}\/\/|\/)/)) {              // a valid url              return `<a href=${href} target=${targetWindow}>`;            }          }        }      } else if (tag === 'img') {        if (!options.isClosing) {          const src = getAttr('src');          if (src) {            return `<a href='${src}' class='swipebox'><img src='${src}' class="attachment-image-preview mCS_img_loaded"></a>`;          }        }      }      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 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 = sanitizeXss;    const editor = '.editor';    const selectors = [      `.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() {                // resetCommentInput has been called                if (!this.value) {                  const sn = getSummernote(this);                  sn && sn.summernote('reset');                  object && object.editingArea.find('.note-placeholder').show();                }              });              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);                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('reset'); //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();  }});// 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));    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, username] = currentMention;      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';      }      const link = HTML.A(        {          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,          [ASIS]: 'true',        },        linkValue,      );      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) {    let prevent = true;    const userId = event.currentTarget.dataset.userid;    if (userId) {      Popup.open('member').call({ userId }, event, templateInstance);    } else {      const href = event.currentTarget.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();    }  },});
 |