1 | 'use strict';
|
2 |
|
3 | const MarkdownIt = require('markdown-it');
|
4 | const fs = require('fs');
|
5 | const path = require('path');
|
6 | const url = require('url');
|
7 |
|
8 | const probe = require('probe-image-size');
|
9 |
|
10 | const nconf = require.main.require('nconf');
|
11 | const winston = require.main.require('winston');
|
12 | const meta = require.main.require('./src/meta');
|
13 | const posts = require.main.require('./src/posts');
|
14 | const translator = require.main.require('./src/translator');
|
15 | const plugins = require.main.require('./src/plugins');
|
16 | const cacheCreate = require.main.require('./src/cacheCreate');
|
17 |
|
18 | const SocketPlugins = require.main.require('./src/socket.io/plugins');
|
19 | SocketPlugins.markdown = require('./websockets');
|
20 |
|
21 | let parser;
|
22 |
|
23 | const 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 |
|
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 |
|
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 |
|
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 |
|
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,
|
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 |
|
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 |
|
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 |
|
202 | if (Markdown.config.probe && data && data.postData && data.postData.content) {
|
203 | const matcher = /!\[[^\]]*?\]\((https?[^)]+?)\)/g;
|
204 | let current;
|
205 |
|
206 |
|
207 | while ((current = matcher.exec(data.postData.content)) !== null) {
|
208 | const match = current[1];
|
209 | if (match && Markdown.isExternalLink(match)) {
|
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 |
|
218 | const size = await probe(match);
|
219 |
|
220 | let { width, height } = size;
|
221 |
|
222 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
349 | if (!Markdown.isUrlValid(attributes.get('src'))) { return ''; }
|
350 |
|
351 | token.attrSet('class', (token.attrGet('class') || '') + ' img-responsive img-markdown');
|
352 |
|
353 |
|
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 |
|
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 |
|
426 |
|
427 |
|
428 |
|
429 |
|
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
|
454 | || (urlObj.host === baseUrlObj.host && urlObj.protocol === baseUrlObj.protocol
|
455 | && (nconf.get('relative_path').length > 0 ? urlObj.pathname.indexOf(nconf.get('relative_path')) === 0 : true))
|
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 |
|
478 | module.exports = Markdown;
|