File size: 4,206 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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
function menavExtractDomain(url) {
  if (!url) return '';

  try {
    // 移除协议部分 (http://, https://, etc.)
    let domain = String(url).replace(/^[a-zA-Z]+:\/\//, '');

    // 移除路径、查询参数和锚点
    domain = domain.split('/')[0].split('?')[0].split('#')[0];

    // 移除端口号(如果有)
    domain = domain.split(':')[0];

    return domain;
  } catch (e) {
    return String(url);
  }
}

// URL 安全策略:默认仅允许 http/https(可加 mailto/tel)与相对链接;其他 scheme 降级为 '#'
function menavGetAllowedUrlSchemes() {
  try {
    const cfg =
      window.MeNav && typeof window.MeNav.getConfig === 'function'
        ? window.MeNav.getConfig()
        : null;
    const fromConfig =
      cfg &&
      cfg.data &&
      cfg.data.site &&
      cfg.data.site.security &&
      cfg.data.site.security.allowedSchemes;
    if (Array.isArray(fromConfig) && fromConfig.length > 0) {
      return fromConfig
        .map((s) =>
          String(s || '')
            .trim()
            .toLowerCase()
            .replace(/:$/, '')
        )
        .filter(Boolean);
    }
  } catch (e) {
    // 忽略,回退默认
  }
  return ['http', 'https', 'mailto', 'tel'];
}

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

function menavSanitizeUrl(rawUrl, contextLabel) {
  if (rawUrl === undefined || rawUrl === null) return '#';
  const url = String(rawUrl).trim();
  if (!url) return '#';

  if (menavIsRelativeUrl(url)) return url;

  // 明确拒绝协议相对 URL(//example.com),避免意外绕过策略
  if (url.startsWith('//')) {
    console.warn(`[MeNav][安全] 已拦截不安全 URL(协议相对形式):${contextLabel || ''}`, url);
    return '#';
  }

  try {
    const parsed = new URL(url);
    const scheme = String(parsed.protocol || '')
      .toLowerCase()
      .replace(/:$/, '');
    const allowed = menavGetAllowedUrlSchemes();
    if (allowed.includes(scheme)) return url;
    console.warn(`[MeNav][安全] 已拦截不安全 URL scheme:${contextLabel || ''}`, url);
    return '#';
  } catch (e) {
    console.warn(`[MeNav][安全] 已拦截无法解析的 URL:${contextLabel || ''}`, url);
    return '#';
  }
}

// class token 清洗:仅允许字母/数字/下划线/中划线与空格分隔,避免属性/事件注入
function menavSanitizeClassList(rawClassList, contextLabel) {
  const input = String(rawClassList || '').trim();
  if (!input) return '';

  const tokens = input
    .split(/\s+/g)
    .map((t) => t.trim())
    .filter(Boolean)
    .map((t) => t.replace(/[^\w-]/g, ''))
    .filter(Boolean);

  const sanitized = tokens.join(' ');
  if (sanitized !== input) {
    console.warn(`[MeNav][安全] 已清洗不安全的 icon class:${contextLabel || ''}`, rawClassList);
  }
  return sanitized;
}

// 版本号统一来源:优先读取 meta[menav-version],回退到 menav-config-data.version
function menavDetectVersion() {
  try {
    const meta = document.querySelector('meta[name="menav-version"]');
    const v = meta ? String(meta.getAttribute('content') || '').trim() : '';
    if (v) return v;
  } catch (e) {
    // 忽略
  }

  try {
    const configData = document.getElementById('menav-config-data');
    const raw = configData ? String(configData.textContent || '').trim() : '';
    if (!raw) return '1.0.0';
    const parsed = JSON.parse(raw);
    const v = parsed && parsed.version ? String(parsed.version).trim() : '';
    return v || '1.0.0';
  } catch (e) {
    return '1.0.0';
  }
}

// 修复移动端 `100vh` 视口高度问题:用实际可视高度驱动布局,避免侧边栏/内容区底部被浏览器 UI 遮挡
function menavUpdateAppHeight() {
  const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
  document.documentElement.style.setProperty('--app-height', `${Math.round(viewportHeight)}px`);
}

module.exports = {
  menavExtractDomain,
  menavSanitizeUrl,
  menavSanitizeClassList,
  menavDetectVersion,
  menavUpdateAppHeight,
};