File size: 3,016 Bytes
e1ae2c6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
const MarkdownIt = require('markdown-it');

/**
 * 构建期 Markdown 渲染器(用于内容页 template: content)
 *
 * 约束:
 * - 禁止 raw HTML(避免 XSS)
 * - 禁止图片(本期不支持图片/附件)
 * - 链接 href 必须通过安全策略校验(沿用 safeUrl 的 scheme 白名单思想)
 */

function normalizeAllowedSchemes(allowedSchemes) {
  if (!Array.isArray(allowedSchemes) || allowedSchemes.length === 0) {
    return ['http', 'https', 'mailto', 'tel'];
  }
  return allowedSchemes
    .map((s) =>
      String(s || '')
        .trim()
        .toLowerCase()
        .replace(/:$/, '')
    )
    .filter(Boolean);
}

function isRelativeUrl(url) {
  const s = String(url || '').trim();
  return (
    s.startsWith('#') ||
    s.startsWith('/') ||
    s.startsWith('./') ||
    s.startsWith('../') ||
    s.startsWith('?')
  );
}

/**
 * 将 href 按 MeNav 安全策略清洗为可点击链接
 * @param {string} href 原始 href
 * @param {string[]} allowedSchemes 允许的 scheme 列表(不含冒号)
 * @returns {string} 安全 href(不安全时返回 '#'
 */
function sanitizeLinkHref(href, allowedSchemes) {
  const raw = String(href || '').trim();
  if (!raw) return '#';
  if (isRelativeUrl(raw)) return raw;

  // 明确拒绝协议相对 URL(//example.com),避免绕过策略
  if (raw.startsWith('//')) return '#';

  try {
    const parsed = new URL(raw);
    const scheme = String(parsed.protocol || '')
      .toLowerCase()
      .replace(/:$/, '');
    return allowedSchemes.includes(scheme) ? raw : '#';
  } catch {
    return '#';
  }
}

function createMarkdownIt({ allowedSchemes }) {
  const md = new MarkdownIt({
    html: false,
    linkify: true,
    typographer: true,
  });

  // markdown-it 默认会拒绝 javascript: 等链接,并导致其不被渲染为 <a>
  // 我们这里统一“允许渲染,但在 renderer 层做 href 安全降级”。
  md.validateLink = () => true;

  // 本期明确不支持图片
  md.disable('image');

  const normalizedSchemes = normalizeAllowedSchemes(allowedSchemes);
  const defaultRender = md.renderer.rules.link_open;

  md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
    const token = tokens[idx];
    const hrefIndex = token.attrIndex('href');
    if (hrefIndex >= 0) {
      const originalHref = token.attrs[hrefIndex][1];
      token.attrs[hrefIndex][1] = sanitizeLinkHref(originalHref, normalizedSchemes);
    }

    return defaultRender
      ? defaultRender(tokens, idx, options, env, self)
      : self.renderToken(tokens, idx, options);
  };

  return md;
}

/**
 * @param {string} markdownText markdown 原文
 * @param {{allowedSchemes?: string[]}} opts
 * @returns {string} HTML(不包含外层 layout)
 */
function renderMarkdownToHtml(markdownText, opts = {}) {
  const md = createMarkdownIt({ allowedSchemes: opts.allowedSchemes });
  return md.render(String(markdownText || ''));
}

module.exports = {
  sanitizeLinkHref,
  renderMarkdownToHtml,
};