editor.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import _sanitizeXss from 'xss';
  2. const enableRicherEditor =
  3. Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR || true;
  4. const sanitizeXss = (input, options) => {
  5. const defaultAllowedIframeSrc = /^(https:){0,1}\/\/.*?(youtube|vimeo|dailymotion|youku)/i;
  6. const allowedIframeSrcRegex = (function() {
  7. let reg = defaultAllowedIframeSrc;
  8. const SAFE_IFRAME_SRC_PATTERN =
  9. Meteor.settings.public.SAFE_IFRAME_SRC_PATTERN;
  10. try {
  11. if (SAFE_IFRAME_SRC_PATTERN !== undefined) {
  12. reg = new RegExp(SAFE_IFRAME_SRC_PATTERN, 'i');
  13. }
  14. } catch (e) {
  15. /*eslint no-console: ["error", { allow: ["warn", "error"] }] */
  16. console.error('Wrong pattern specified', SAFE_IFRAM_SRC_PATTERN, e);
  17. }
  18. return reg;
  19. })();
  20. const targetWindow = '_blank';
  21. options = {
  22. onTag(tag, html, options) {
  23. if (tag === 'iframe') {
  24. const clipCls = 'note-vide-clip';
  25. if (!options.isClosing) {
  26. const srcp = /src=(['"]{0,1})(\S*)(\1)/;
  27. let safe = html.indexOf(`class="${clipCls}"`) > -1;
  28. if (srcp.exec(html)) {
  29. const src = RegExp.$2;
  30. if (allowedIframeSrcRegex.exec(src)) {
  31. safe = true;
  32. }
  33. if (safe)
  34. return `<iframe src='${src}' class="${clipCls}" width=100% height=auto allowfullscreen></iframe>`;
  35. }
  36. } else {
  37. return '';
  38. }
  39. } else if (tag === 'a') {
  40. if (!options.isClosing) {
  41. if (/href=(['"]{0,1})(\S*)(\1)/.exec(html)) {
  42. const href = RegExp.$2;
  43. if (href.match(/^((http(s){0,1}:){0,1}\/\/|\/)/)) {
  44. // a valid url
  45. return `<a href=${href} target=${targetWindow}>`;
  46. }
  47. }
  48. }
  49. } else if (tag === 'img') {
  50. if (!options.isClosing) {
  51. if (new RegExp('src=([\'"]{0,1})(\\S*)(\\1)').exec(html)) {
  52. const src = RegExp.$2;
  53. return `<a href='${src}' class='swipebox'><img src='${src}' class="attachment-image-preview mCS_img_loaded"></a>`;
  54. }
  55. }
  56. }
  57. return undefined;
  58. },
  59. onTagAttr(tag, name, value) {
  60. if (tag === 'img' && name === 'src') {
  61. if (value && value.substr(0, 5) === 'data:') {
  62. // allow image with dataURI src
  63. return `${name}='${value}'`;
  64. }
  65. } else if (tag === 'a' && name === 'target') {
  66. return `${name}='${targetWindow}'`; // always change a href target to a new window
  67. }
  68. return undefined;
  69. },
  70. ...options,
  71. };
  72. return _sanitizeXss(input, options);
  73. };
  74. Template.editor.onRendered(() => {
  75. const textareaSelector = 'textarea';
  76. const mentions = [
  77. // User mentions
  78. {
  79. match: /\B@([\w.]*)$/,
  80. search(term, callback) {
  81. const currentBoard = Boards.findOne(Session.get('currentBoard'));
  82. callback(
  83. currentBoard
  84. .activeMembers()
  85. .map(member => {
  86. const username = Users.findOne(member.userId).username;
  87. return username.includes(term) ? username : null;
  88. })
  89. .filter(Boolean),
  90. );
  91. },
  92. template(value) {
  93. return value;
  94. },
  95. replace(username) {
  96. return `@${username} `;
  97. },
  98. index: 1,
  99. },
  100. ];
  101. const enableTextarea = function() {
  102. const $textarea = this.$(textareaSelector);
  103. autosize($textarea);
  104. $textarea.escapeableTextComplete(mentions);
  105. };
  106. if (enableRicherEditor) {
  107. const isSmall = Utils.isMiniScreen();
  108. const toolbar = isSmall
  109. ? [
  110. ['view', ['fullscreen']],
  111. ['table', ['table']],
  112. ['font', ['bold', 'underline']],
  113. //['fontsize', ['fontsize']],
  114. ['color', ['color']],
  115. ]
  116. : [
  117. ['style', ['style']],
  118. ['font', ['bold', 'underline', 'clear']],
  119. ['fontsize', ['fontsize']],
  120. ['fontname', ['fontname']],
  121. ['color', ['color']],
  122. ['para', ['ul', 'ol', 'paragraph']],
  123. ['table', ['table']],
  124. ['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
  125. //['insert', ['link', 'picture']], // modal popup has issue somehow :(
  126. ['view', ['fullscreen', 'help']],
  127. ];
  128. const cleanPastedHTML = sanitizeXss;
  129. const editor = '.editor';
  130. const selectors = [
  131. `.js-new-comment-form ${editor}`,
  132. `.js-edit-comment ${editor}`,
  133. ].join(','); // only new comment and edit comment
  134. const inputs = $(selectors);
  135. if (inputs.length === 0) {
  136. // only enable richereditor to new comment or edit comment no others
  137. enableTextarea();
  138. } else {
  139. const placeholder = inputs.attr('placeholder') || '';
  140. const mSummernotes = [];
  141. const getSummernote = function(input) {
  142. const idx = inputs.index(input);
  143. if (idx > -1) {
  144. return mSummernotes[idx];
  145. }
  146. return undefined;
  147. };
  148. inputs.each(function(idx, input) {
  149. mSummernotes[idx] = $(input).summernote({
  150. placeholder,
  151. callbacks: {
  152. onInit(object) {
  153. const originalInput = this;
  154. $(originalInput).on('submitted', function() {
  155. // resetCommentInput has been called
  156. if (!this.value) {
  157. const sn = getSummernote(this);
  158. sn && sn.summernote('reset');
  159. object && object.editingArea.find('.note-placeholder').show();
  160. }
  161. });
  162. const jEditor = object && object.editable;
  163. const toolbar = object && object.toolbar;
  164. if (jEditor !== undefined) {
  165. jEditor.escapeableTextComplete(mentions);
  166. }
  167. if (toolbar !== undefined) {
  168. const fBtn = toolbar.find('.btn-fullscreen');
  169. fBtn.on('click', function() {
  170. const $this = $(this),
  171. isActive = $this.hasClass('active');
  172. $('.minicards').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually
  173. });
  174. }
  175. },
  176. onImageUpload(files) {
  177. const $summernote = getSummernote(this);
  178. if (files && files.length > 0) {
  179. const image = files[0];
  180. const reader = new FileReader();
  181. const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
  182. const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
  183. const processData = function(dataURL) {
  184. const img = document.createElement('img');
  185. img.src = dataURL;
  186. img.setAttribute('width', '100%');
  187. $summernote.summernote('insertNode', img);
  188. };
  189. reader.onload = function(e) {
  190. const dataurl = e && e.target && e.target.result;
  191. if (dataurl !== undefined) {
  192. if (MAX_IMAGE_PIXEL) {
  193. // need to shrink image
  194. Utils.shrinkImage({
  195. dataurl,
  196. maxSize: MAX_IMAGE_PIXEL,
  197. ratio: COMPRESS_RATIO,
  198. callback(changed) {
  199. if (changed !== false && !!changed) {
  200. processData(changed);
  201. }
  202. },
  203. });
  204. } else {
  205. processData(dataurl);
  206. }
  207. }
  208. };
  209. reader.readAsDataURL(image);
  210. }
  211. },
  212. onPaste() {
  213. // clear up unwanted tag info when user pasted in text
  214. const thisNote = this;
  215. const updatePastedText = function(object) {
  216. const someNote = getSummernote(object);
  217. const original = someNote.summernote('code');
  218. const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML.
  219. someNote.summernote('reset'); //clear original
  220. someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code.
  221. };
  222. setTimeout(function() {
  223. //this kinda sucks, but if you don't do a setTimeout,
  224. //the function is called before the text is really pasted.
  225. updatePastedText(thisNote);
  226. }, 10);
  227. },
  228. },
  229. dialogsInBody: true,
  230. disableDragAndDrop: true,
  231. toolbar,
  232. popover: {
  233. image: [
  234. [
  235. 'image',
  236. ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone'],
  237. ],
  238. ['float', ['floatLeft', 'floatRight', 'floatNone']],
  239. ['remove', ['removeMedia']],
  240. ],
  241. table: [
  242. ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
  243. ['delete', ['deleteRow', 'deleteCol', 'deleteTable']],
  244. ],
  245. air: [
  246. ['color', ['color']],
  247. ['font', ['bold', 'underline', 'clear']],
  248. ],
  249. },
  250. height: 200,
  251. });
  252. });
  253. }
  254. } else {
  255. enableTextarea();
  256. }
  257. });
  258. // XXX I believe we should compute a HTML rendered field on the server that
  259. // would handle markdown and user mentions. We can simply have two
  260. // fields, one source, and one compiled version (in HTML) and send only the
  261. // compiled version to most users -- who don't need to edit.
  262. // In the meantime, all the transformation are done on the client using the
  263. // Blaze API.
  264. const at = HTML.CharRef({ html: '&commat;', str: '@' });
  265. Blaze.Template.registerHelper(
  266. 'mentions',
  267. new Template('mentions', function() {
  268. const view = this;
  269. let content = Blaze.toHTML(view.templateContentBlock);
  270. const currentBoard = Boards.findOne(Session.get('currentBoard'));
  271. if (!currentBoard) return HTML.Raw(sanitizeXss(content));
  272. const knowedUsers = currentBoard.members.map(member => {
  273. const u = Users.findOne(member.userId);
  274. if (u) {
  275. member.username = u.username;
  276. }
  277. return member;
  278. });
  279. const mentionRegex = /\B@([\w.]*)/gi;
  280. let currentMention;
  281. while ((currentMention = mentionRegex.exec(content)) !== null) {
  282. const [fullMention, username] = currentMention;
  283. const knowedUser = _.findWhere(knowedUsers, { username });
  284. if (!knowedUser) {
  285. continue;
  286. }
  287. const linkValue = [' ', at, knowedUser.username];
  288. let linkClass = 'atMention js-open-member';
  289. if (knowedUser.userId === Meteor.userId()) {
  290. linkClass += ' me';
  291. }
  292. const link = HTML.A(
  293. {
  294. class: linkClass,
  295. // XXX Hack. Since we stringify this render function result below with
  296. // `Blaze.toHTML` we can't rely on blaze data contexts to pass the
  297. // `userId` to the popup as usual, and we need to store it in the DOM
  298. // using a data attribute.
  299. 'data-userId': knowedUser.userId,
  300. },
  301. linkValue,
  302. );
  303. content = content.replace(fullMention, Blaze.toHTML(link));
  304. }
  305. return HTML.Raw(sanitizeXss(content));
  306. }),
  307. );
  308. Template.viewer.events({
  309. // Viewer sometimes have click-able wrapper around them (for instance to edit
  310. // the corresponding text). Clicking a link shouldn't fire these actions, stop
  311. // we stop these event at the viewer component level.
  312. 'click a'(event, templateInstance) {
  313. let prevent = true;
  314. const userId = event.currentTarget.dataset.userid;
  315. if (userId) {
  316. Popup.open('member').call({ userId }, event, templateInstance);
  317. } else {
  318. const href = event.currentTarget.href;
  319. const child = event.currentTarget.firstElementChild;
  320. if (child && child.tagName === 'IMG') {
  321. prevent = false;
  322. } else if (href) {
  323. window.open(href, '_blank');
  324. }
  325. }
  326. if (prevent) {
  327. event.stopPropagation();
  328. // XXX We hijack the build-in browser action because we currently don't have
  329. // `_blank` attributes in viewer links, and the transformer function is
  330. // handled by a third party package that we can't configure easily. Fix that
  331. // by using directly `_blank` attribute in the rendered HTML.
  332. event.preventDefault();
  333. }
  334. },
  335. });