UNPKG

14.8 kBJavaScriptView Raw
1'use strict';
2
3const MarkdownIt = require('markdown-it');
4const fs = require('fs');
5const path = require('path');
6const url = require('url');
7
8const probe = require('probe-image-size');
9
10const nconf = require.main.require('nconf');
11const winston = require.main.require('winston');
12const meta = require.main.require('./src/meta');
13const posts = require.main.require('./src/posts');
14const translator = require.main.require('./src/translator');
15const plugins = require.main.require('./src/plugins');
16const cacheCreate = require.main.require('./src/cacheCreate');
17
18const SocketPlugins = require.main.require('./src/socket.io/plugins');
19SocketPlugins.markdown = require('./websockets');
20
21let parser;
22
23const Markdown = {
24 config: {},
25 _externalImageCache: undefined,
26 onLoad: async function (params) {
27 const controllers = require('./lib/controllers');
28 const hostMiddleware = require.main.require('./src/middleware');
29 const middlewares = [hostMiddleware.maintenanceMode, hostMiddleware.registrationComplete, hostMiddleware.pluginHooks];
30
31 params.router.get('/admin/plugins/markdown', params.middleware.admin.buildHeader, controllers.renderAdmin);
32 params.router.get('/api/admin/plugins/markdown', controllers.renderAdmin);
33
34 // Return raw markdown via GET
35 params.router.get('/api/post/:pid/raw', middlewares, controllers.retrieveRaw);
36
37 Markdown.init();
38 Markdown.loadThemes();
39
40 return params;
41 },
42
43 getConfig: function (config) {
44 config.markdown = {
45 highlight: Markdown.highlight ? 1 : 0,
46 highlightLinesLanguageList: Markdown.config.highlightLinesLanguageList,
47 theme: Markdown.config.highlightTheme || 'railscasts.css',
48 };
49 return config;
50 },
51
52 getLinkTags: function (hookData) {
53 hookData.links.push({
54 rel: 'prefetch stylesheet',
55 type: '',
56 href: `${nconf.get('relative_path')}/plugins/nodebb-plugin-markdown/styles/${Markdown.config.highlightTheme || 'railscasts.css'}`,
57 });
58
59 const prefetch = ['/assets/src/modules/highlight.js', `/assets/language/${meta.config.defaultLang || 'en-GB'}/markdown.json`];
60 hookData.links = hookData.links.concat(
61 prefetch.map((path) => ({
62 rel: 'prefetch',
63 href: nconf.get('relative_path') + path + '?' + meta.config['cache-buster'],
64 }))
65 );
66
67 return hookData;
68 },
69
70 init: function () {
71 // Load saved config
72 const _self = this;
73 const defaults = {
74 html: false,
75
76 langPrefix: 'language-',
77 highlight: true,
78 highlightLinesLanguageList: [],
79 highlightTheme: 'railscasts.css',
80
81 probe: true,
82 probeCacheSize: 256,
83
84 xhtmlOut: true,
85 breaks: true,
86 linkify: true,
87 typographer: false,
88 externalBlank: false,
89 nofollow: true,
90 allowRTLO: false,
91 checkboxes: true,
92 multimdTables: true,
93 };
94 const notCheckboxes = ['langPrefix', 'highlightTheme', 'highlightLinesLanguageList', 'probeCacheSize'];
95
96 meta.settings.get('markdown', function (err, options) {
97 if (err) {
98 winston.warn(`[plugin/markdown] Unable to retrieve settings, assuming defaults: ${err.message}`);
99 }
100
101 for (const field in defaults) {
102 // If not set in config (nil)
103 if (!options.hasOwnProperty(field)) {
104 _self.config[field] = defaults[field];
105 } else if (!notCheckboxes.includes(field)) {
106 _self.config[field] = options[field] === 'on';
107 } else {
108 _self.config[field] = options[field];
109 }
110 }
111
112 _self.highlight = _self.config.highlight;
113 delete _self.config.highlight;
114
115 if (typeof _self.config.highlightLinesLanguageList === 'string') {
116 try {
117 _self.config.highlightLinesLanguageList = JSON.parse(_self.config.highlightLinesLanguageList);
118 } catch (e) {
119 winston.warn('[plugins/markdown] Invalid config for highlightLinesLanguageList, blanking.');
120 _self.config.highlightLinesLanguageList = [];
121 }
122
123 _self.config.highlightLinesLanguageList = _self.config.highlightLinesLanguageList.join(',').split(',');
124 }
125
126 parser = new MarkdownIt(_self.config);
127
128 Markdown.updateParserRules(parser);
129
130 // External image size cache
131 if (_self.config.probe) {
132 Markdown._externalImageCache = cacheCreate({
133 name: 'markdown.externalImageCache',
134 max: parseInt(_self.config.probeCacheSize, 10) || 256,
135 length: function () { return 1; },
136 maxAge: 1000 * 60 * 60 * 24, // 1 day
137 });
138 }
139 });
140 },
141
142 loadThemes: function () {
143 fs.readdir(path.join(require.resolve('highlight.js'), '../../styles'), function (err, files) {
144 if (err) {
145 winston.error('[plugin/markdown] Could not load Markdown themes: ' + err.message);
146 Markdown.themes = [];
147 return;
148 }
149 const isStylesheet = /\.css$/;
150 Markdown.themes = files.filter(function (file) {
151 return isStylesheet.test(file);
152 }).map(function (file) {
153 return {
154 name: file,
155 };
156 });
157 });
158 },
159
160 parsePost: async function (data) {
161 const env = await Markdown.beforeParse(data);
162 if (data && data.postData && data.postData.content && parser) {
163 data.postData.content = parser.render(data.postData.content, env || {});
164 }
165 return Markdown.afterParse(data);
166 },
167
168 parseSignature: async function (data) {
169 if (data && data.userData && data.userData.signature && parser) {
170 data.userData.signature = parser.render(data.userData.signature);
171 }
172 return Markdown.afterParse(data);
173 },
174
175 parseAboutMe: async function (aboutme) {
176 aboutme = (aboutme && parser) ? parser.render(aboutme) : aboutme;
177 // process.nextTick(next, null, aboutme);
178 return Markdown.afterParse(aboutme);
179 },
180
181 parseRaw: async function (raw) {
182 raw = (raw && parser) ? parser.render(raw) : raw;
183 return Markdown.afterParse(raw);
184 },
185
186 beforeParse: async (data) => {
187 const env = {
188 images: new Map(),
189 };
190
191 if (data && data.postData && data.postData.pid) {
192 // Check that pid for images, and return their sizes
193 const images = await posts.uploads.listWithSizes(data.postData.pid);
194 env.images = images.reduce((memo, cur) => {
195 memo.set(cur.name, cur);
196 delete cur.name;
197 return memo;
198 }, env.images);
199 }
200
201 // Probe post data for external images as well
202 if (Markdown.config.probe && data && data.postData && data.postData.content) {
203 const matcher = /!\[[^\]]*?\]\((https?[^)]+?)\)/g;
204 let current;
205
206 // eslint-disable-next-line no-cond-assign
207 while ((current = matcher.exec(data.postData.content)) !== null) {
208 const match = current[1];
209 if (match && Markdown.isExternalLink(match)) { // for security only parse external images
210 const parsedUrl = url.parse(match);
211 const filename = path.basename(parsedUrl.pathname);
212 const size = Markdown._externalImageCache.get(match);
213 if (size) {
214 env.images.set(filename, size);
215 } else {
216 try {
217 // eslint-disable-next-line no-await-in-loop
218 const size = await probe(match);
219
220 let { width, height } = size;
221
222 // Swap width and height if orientation bit is set
223 if (size.orientation >= 5 && size.orientation <= 8) {
224 [width, height] = [height, width];
225 }
226
227 env.images.set(filename, { width, height });
228 Markdown._externalImageCache.set(match, { width, height });
229 } catch (e) {
230 // No handling required
231 }
232 }
233 }
234 }
235 }
236
237 return env;
238 },
239
240 afterParse: function (payload) {
241 if (!payload) {
242 return payload;
243 }
244 const italicMention = /@<em>([^<]+)<\/em>/g;
245 const boldMention = /@<strong>([^<]+)<\/strong>/g;
246 const execute = function (html) {
247 // Replace all italicised mentions back to regular mentions
248 if (italicMention.test(html)) {
249 html = html.replace(italicMention, function (match, slug) {
250 return '@_' + slug + '_';
251 });
252 } else if (boldMention.test(html)) {
253 html = html.replace(boldMention, function (match, slug) {
254 return '@__' + slug + '__';
255 });
256 }
257
258 return html;
259 };
260
261 if (payload.hasOwnProperty('postData')) {
262 payload.postData.content = execute(payload.postData.content);
263 } else if (payload.hasOwnProperty('userData')) {
264 payload.userData.signature = execute(payload.userData.signature);
265 } else {
266 payload = execute(payload);
267 }
268
269 return payload;
270 },
271
272 renderHelp: async function (helpContent) {
273 const translated = await translator.translate('[[markdown:help_text]]');
274 const parsed = await plugins.hooks.fire('filter:parse.raw', `## Markdown\n${translated}`);
275 helpContent += parsed;
276 return helpContent;
277 },
278
279 registerFormatting: async function (payload) {
280 const formatting = [
281 { name: 'bold', className: 'fa fa-bold', title: '[[modules:composer.formatting.bold]]' },
282 { name: 'italic', className: 'fa fa-italic', title: '[[modules:composer.formatting.italic]]' },
283 { name: 'list', className: 'fa fa-list-ul', title: '[[modules:composer.formatting.list]]' },
284 { name: 'strikethrough', className: 'fa fa-strikethrough', title: '[[modules:composer.formatting.strikethrough]]' },
285 { name: 'code', className: 'fa fa-code', title: '[[modules:composer.formatting.code]]' },
286 { name: 'link', className: 'fa fa-link', title: '[[modules:composer.formatting.link]]' },
287 { name: 'picture-o', className: 'fa fa-picture-o', title: '[[modules:composer.formatting.picture]]' },
288 ];
289
290 payload.options = formatting.concat(payload.options);
291
292 return payload;
293 },
294
295 updateSanitizeConfig: async (config) => {
296 config.allowedTags.push('input');
297 config.allowedAttributes.input = ['type', 'checked'];
298 config.allowedAttributes.ol.push('start');
299 config.allowedAttributes.th.push('colspan', 'rowspan');
300 config.allowedAttributes.td.push('colspan', 'rowspan');
301
302 return config;
303 },
304
305 updateParserRules: function (parser) {
306 if (Markdown.config.checkboxes) {
307 // Add support for checkboxes
308 parser.use(require('markdown-it-checkbox'), {
309 divWrap: true,
310 divClass: 'plugin-markdown',
311 });
312 }
313
314 if (Markdown.config.multimdTables) {
315 parser.use(require('markdown-it-multimd-table'), {
316 multiline: true,
317 rowspan: true,
318 headerless: true,
319 });
320 }
321
322 parser.use((md) => {
323 md.core.ruler.before('linkify', 'autodir', (state) => {
324 state.tokens.forEach((token) => {
325 if (token.type === 'paragraph_open') {
326 token.attrJoin('dir', 'auto');
327 }
328 });
329 });
330 });
331
332 // Update renderer to add some classes to all images
333 const renderImage = parser.renderer.rules.image || function (tokens, idx, options, env, self) {
334 return self.renderToken.apply(self, arguments);
335 };
336 const renderLink = parser.renderer.rules.link_open || function (tokens, idx, options, env, self) {
337 return self.renderToken.apply(self, arguments);
338 };
339 const renderTable = parser.renderer.rules.table_open || function (tokens, idx, options, env, self) {
340 return self.renderToken.apply(self, arguments);
341 };
342
343 parser.renderer.rules.image = function (tokens, idx, options, env, self) {
344 const token = tokens[idx];
345 const attributes = new Map(token.attrs);
346 const parsedSrc = url.parse(attributes.get('src'));
347
348 // Validate the url
349 if (!Markdown.isUrlValid(attributes.get('src'))) { return ''; }
350
351 token.attrSet('class', (token.attrGet('class') || '') + ' img-responsive img-markdown');
352
353 // Append sizes to images
354 if (parsedSrc.pathname) {
355 const filename = path.basename(parsedSrc.pathname);
356 if (env.images && env.images.has(filename)) {
357 const size = env.images.get(filename);
358 token.attrSet('width', size.width);
359 token.attrSet('height', size.height);
360 }
361 }
362
363 return renderImage(tokens, idx, options, env, self);
364 };
365
366 parser.renderer.rules.link_open = function (tokens, idx, options, env, self) {
367 // Add target="_blank" to all links
368 const targetIdx = tokens[idx].attrIndex('target');
369 let relIdx = tokens[idx].attrIndex('rel');
370 const hrefIdx = tokens[idx].attrIndex('href');
371
372 if (Markdown.isExternalLink(tokens[idx].attrs[hrefIdx][1])) {
373 if (Markdown.config.externalBlank) {
374 if (targetIdx < 0) {
375 tokens[idx].attrPush(['target', '_blank']);
376 } else {
377 tokens[idx].attrs[targetIdx][1] = '_blank';
378 }
379
380 if (relIdx < 0) {
381 tokens[idx].attrPush(['rel', 'noopener noreferrer']);
382 relIdx = tokens[idx].attrIndex('rel');
383 } else {
384 tokens[idx].attrs[relIdx][1] = 'noopener noreferrer';
385 }
386 }
387
388 if (Markdown.config.nofollow) {
389 if (relIdx < 0) {
390 tokens[idx].attrPush(['rel', 'nofollow ugc']);
391 } else {
392 tokens[idx].attrs[relIdx][1] += ' nofollow ugc';
393 }
394 }
395 }
396
397 if (!Markdown.config.allowRTLO) {
398 if (tokens[idx + 1] && tokens[idx + 1].type === 'text') {
399 if (tokens[idx + 1].content.match(Markdown.regexes.rtl_override)) {
400 tokens[idx + 1].content = tokens[idx + 1].content.replace(Markdown.regexes.rtl_override, '');
401 }
402 }
403 }
404
405 return renderLink(tokens, idx, options, env, self);
406 };
407
408 parser.renderer.rules.table_open = function (tokens, idx, options, env, self) {
409 const classIdx = tokens[idx].attrIndex('class');
410
411 if (classIdx < 0) {
412 tokens[idx].attrPush(['class', 'table table-bordered table-striped']);
413 } else {
414 tokens[idx].attrs[classIdx][1] += ' table table-bordered table-striped';
415 }
416
417 return renderTable(tokens, idx, options, env, self);
418 };
419
420 plugins.hooks.fire('action:markdown.updateParserRules', parser);
421 },
422
423 isUrlValid: function (src) {
424 /**
425 * Images linking to a relative path are only allowed from the root prefixes
426 * defined in allowedRoots. We allow both with and without relative_path
427 * even though upload_url should handle it, because sometimes installs
428 * migrate to (non-)subfolder and switch mid-way, but the uploads urls don't
429 * get updated.
430 */
431 const allowedRoots = [nconf.get('upload_url'), '/uploads'];
432 const allowed = (pathname) => allowedRoots.some((root) => pathname.toString().startsWith(root) || pathname.toString().startsWith(nconf.get('relative_path') + root));
433
434 try {
435 const urlObj = url.parse(src, false, true);
436 return !(urlObj.host === null && !allowed(urlObj.pathname));
437 } catch (e) {
438 return false;
439 }
440 },
441
442 isExternalLink: function (urlString) {
443 let urlObj;
444 let baseUrlObj;
445 try {
446 urlObj = url.parse(urlString);
447 baseUrlObj = url.parse(nconf.get('url'));
448 } catch (err) {
449 return false;
450 }
451
452 if (
453 urlObj.host === null // Relative paths are always internal links...
454 || (urlObj.host === baseUrlObj.host && urlObj.protocol === baseUrlObj.protocol // Otherwise need to check that protocol and host match
455 && (nconf.get('relative_path').length > 0 ? urlObj.pathname.indexOf(nconf.get('relative_path')) === 0 : true)) // Subfolder installs need this additional check
456 ) {
457 return false;
458 }
459 return true;
460 },
461
462 admin: {
463 menu: async function (custom_header) {
464 custom_header.plugins.push({
465 route: '/plugins/markdown',
466 icon: 'fa-edit',
467 name: 'Markdown',
468 });
469 return custom_header;
470 },
471 },
472
473 regexes: {
474 rtl_override: /\u202E/gi,
475 },
476};
477
478module.exports = Markdown;