Files
mce/templates/MetaLab-2026/static/js/common.js
Victor_Jay 53f18ca6ee fix: CSP 策略移除 unsafe-inline,改用 nonce 机制
- SecurityHeaders 中间件生成随机 nonce,注入 context
- BuildPageData/BuildAdminPageData 将 CSPNonce 传递给模板
- 所有 19 个模板文件 <script> 标签添加 nonce 属性
- 移除所有内联事件处理(onclick/onerror),改用事件监听
- 移除所有 javascript:void(0) 链接,改用 # 号 + preventDefault
- utils.js 添加全局 avatar 图片加载失败回退处理
- common.js 登出按钮添加 preventDefault
- audit.js 关闭弹窗按钮改用事件监听
2026-06-02 16:10:23 +08:00

349 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
MetaLab Common JS — 全站共享脚本
============================================================ */
(function () {
'use strict';
// ---- Shared utils (exposed to other scripts via window.MetaLab) ----
window.MetaLab = window.MetaLab || {};
var toastTimer = null;
window.MetaLab.toast = function (el, msg, isError) {
if (!el) return;
clearTimeout(toastTimer);
el.textContent = msg;
el.className = 'toast' + (isError ? ' error' : '');
void el.offsetWidth;
el.classList.add('show');
toastTimer = setTimeout(function () {
el.classList.remove('show');
}, 3000);
};
// ---- CSRF token helper (从共享 utils.js 提供的 getCSRFToken 别名) ----
window.MetaLab.csrfToken = typeof getCSRFToken === 'function' ? getCSRFToken : function () { return ''; };
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
// Also intercept 401 responses to auto-refresh token
(function () {
var SAME_ORIGIN = /^\/(?!\/)/;
var isRefreshing = false;
var refreshPromise = null;
var pendingRetries = [];
// 优先从 cookie 读取 CSRF token与服务端验证源一致meta 标签作为后备
function resolveCSRFToken() {
var cookieMatch = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/);
if (cookieMatch && cookieMatch[1]) return cookieMatch[1];
var meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function getCSRFTokenHeader() {
var t = resolveCSRFToken();
return t || window.MetaLab.csrfToken();
}
// ---- Token refresh logic ----
function redirectToLogin() {
// 避免在认证页面上造成无限跳转循环
if (/^\/auth\//.test(window.location.pathname)) return;
var returnURL = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = '/auth/login?redirect=' + returnURL;
}
function doRefresh() {
if (isRefreshing) return refreshPromise;
isRefreshing = true;
refreshPromise = fetch('/api/auth/refresh', { method: 'POST' })
.then(function (res) {
isRefreshing = false;
refreshPromise = null;
if (!res.ok) {
// Refresh failed — clear all pending retries and redirect to login
while (pendingRetries.length) {
var r = pendingRetries.shift();
r.reject(new Error('refresh_failed'));
}
redirectToLogin();
return Promise.reject(new Error('refresh_failed'));
}
// Retry all pending requests
while (pendingRetries.length) {
var retry = pendingRetries.shift();
retry.retry();
}
return true;
})
.catch(function (err) {
isRefreshing = false;
refreshPromise = null;
while (pendingRetries.length) {
var r2 = pendingRetries.shift();
r2.reject(err);
}
redirectToLogin();
return Promise.reject(err);
});
return refreshPromise;
}
// ---- Patch XMLHttpRequest ----
var origXHROpen = XMLHttpRequest.prototype.open;
var origXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._method = method;
this._url = url;
return origXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var xhr = this;
if (SAME_ORIGIN.test(xhr._url) &&
xhr._method && !/^(GET|HEAD|OPTIONS)$/i.test(xhr._method)) {
xhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader());
}
// 401 interception for XHR
// 保存原始 onreadystatechange 后立即清除,防止原生属性重复触发
// 否则浏览器原生 onreadystatechange 和 addEventListener 监听器会各触发一次
// 导致 Vditor 图片上传等场景下图片被插入两次
var origOnReady = xhr.onreadystatechange;
xhr.onreadystatechange = null;
xhr.addEventListener('readystatechange', function () {
if (xhr.readyState === 4 && xhr.status === 401 &&
xhr._url !== '/api/auth/refresh' &&
!xhr._retried) {
xhr._retried = true;
// Abort the original response handling
xhr._blocked = true;
doRefresh().then(function () {
// Re-create and re-send the request
var newXhr = new XMLHttpRequest();
newXhr.open(xhr._method, xhr._url, true);
// Copy relevant properties
newXhr.onreadystatechange = origOnReady;
newXhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader());
newXhr.send(xhr._body);
}).catch(function () {
// Refresh failed, trigger original error handler
xhr._blocked = false;
if (origOnReady) origOnReady.call(xhr);
});
}
if (!xhr._blocked && origOnReady) {
origOnReady.call(xhr);
}
});
// Store body for potential retry
if (xhr._method && !/^(GET|HEAD)$/i.test(xhr._method)) {
xhr._body = arguments[0];
}
return origXHRSend.apply(xhr, arguments);
};
// ---- Patch fetch with 401 interceptor ----
var origFetch = window.fetch;
window.fetch = function (url, options) {
options = options || {};
var headers = options.headers || {};
var method = (options.method || 'GET').toUpperCase();
var urlStr = (typeof url === 'string') ? url : '';
if (SAME_ORIGIN.test(urlStr) && !/^(GET|HEAD|OPTIONS)$/i.test(method)) {
if (headers instanceof Headers) {
headers.set('X-CSRF-Token', getCSRFTokenHeader());
} else {
headers['X-CSRF-Token'] = getCSRFTokenHeader();
}
options.headers = headers;
}
// Don't intercept the refresh request itself
if (urlStr === '/api/auth/refresh') {
return origFetch(url, options);
}
return origFetch(url, options).then(function (res) {
if (res.status === 401) {
// Clone the response so we can read it once for logging
var cloned = res.clone();
// Queue this request for retry after refresh
return new Promise(function (resolve, reject) {
pendingRetries.push({
retry: function () {
// Re-fetch with updated CSRF token
var retryOpts = Object.assign({}, options);
var retryHeaders = retryOpts.headers || {};
if (retryHeaders instanceof Headers) {
retryHeaders.set('X-CSRF-Token', getCSRFTokenHeader());
} else {
retryHeaders['X-CSRF-Token'] = getCSRFTokenHeader();
}
retryOpts.headers = retryHeaders;
resolve(origFetch(url, retryOpts));
},
reject: function (err) {
reject(err);
}
});
doRefresh();
});
}
return res;
});
};
})();
var menuBtn = document.getElementById('mobileMenuBtn');
var navLinks = document.getElementById('navLinks');
var scrollBtn = document.getElementById('scrollTopBtn');
var menuIcon = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
var closeIcon = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
// ---- Mobile menu toggle ----
if (menuBtn && navLinks) {
menuBtn.addEventListener('click', function () {
navLinks.classList.toggle('active');
menuBtn.innerHTML = navLinks.classList.contains('active') ? closeIcon : menuIcon;
});
}
// ---- Close mobile menu on nav click ----
var links = document.querySelectorAll('.nav-links a');
for (var i = 0; i < links.length; i++) {
links[i].addEventListener('click', function () {
if (navLinks) {
navLinks.classList.remove('active');
}
if (menuBtn) {
menuBtn.innerHTML = menuIcon;
}
});
}
// ---- Smooth scroll for anchor links ----
var anchors = document.querySelectorAll('a[href^="#"]');
for (var j = 0; j < anchors.length; j++) {
anchors[j].addEventListener('click', function (e) {
var href = this.getAttribute('href');
if (href === '#') return;
var target = document.querySelector(href);
if (target) {
e.preventDefault();
window.scrollTo({ top: target.offsetTop - 55, behavior: 'smooth' });
}
});
}
// ---- Scroll-to-top button ----
if (scrollBtn) {
window.addEventListener('scroll', function () {
scrollBtn.classList.toggle('visible', window.scrollY > window.innerHeight * 0.5);
});
scrollBtn.addEventListener('click', function () {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// ---- Dev greeting ----
console.log('%c🔨 嘿,开发者朋友!', 'color:#2ecc71;font-size:18px;font-weight:bold');
console.log('%c欢迎来到 MetaLab 的蓝图页。', 'color:#34495e');
console.log('%c我们正在搭建一个纯净、友好的下一代开发者社区。', 'color:#34495e');
console.log('%c有任何想法或愿意参与早期测试欢迎联系我们metazone@foxmail.com', 'color:#e74c3c;font-style:italic');
// ---- Logout ----
var logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', function (e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout', true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
window.location.href = '/';
}
};
xhr.send();
});
}
// ---- Unread message badge polling ----
var msgBadge = document.getElementById('msgBadge');
if (msgBadge) {
var POLL_INTERVAL = 60 * 1000; // 1 minute
function pollUnread() {
fetch('/api/messages/unread')
.then(function (res) {
if (!res.ok) return;
return res.json();
})
.then(function (data) {
if (!data || !data.success) return;
var count = data.data && data.data.unread ? data.data.unread : 0;
msgBadge.textContent = count > 99 ? '99+' : count;
msgBadge.style.display = count > 0 ? 'flex' : 'none';
})
.catch(function () {});
}
pollUnread();
setInterval(pollUnread, POLL_INTERVAL);
}
// ---- Auto-refresh token (silent) ----
// 场景 A页面开着时每 10 分钟静默续期(保持 access token 不过期)
// 场景 B关浏览器 15+ min 后回来access token 过期但 refresh 仍有效
// → 页面渲染为未登录 → 刷新成功后 reload 一次获得登录态
// → 仅当页面是未登录状态(无 logoutBtn时才触发 reload
// 场景 C编辑器等页面发 API 请求时遇到 401 → 由 fetch/XHR patch 自动刷新后重试
// 不在认证页面执行刷新(登录/注册页无需续期)
var onAuthPage = /^\/auth\//.test(window.location.pathname);
if (!onAuthPage) {
var REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes
var refreshTimer = null;
function tryRefresh(callback) {
fetch('/api/auth/refresh', { method: 'POST' })
.then(function (res) {
if (res.ok) {
if (callback) callback();
} else {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
})
.catch(function () {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
});
}
// 启动定时刷新:只要用户已登录(有 logoutBtn 元素),就定期刷新
if (document.getElementById('logoutBtn')) {
// 页面已经是登录态,启动定时刷新即可,无需 reload
tryRefresh(function () {
refreshTimer = setInterval(function () { tryRefresh(null); }, REFRESH_INTERVAL);
});
} else {
// 场景 B页面渲染为未登录无 logoutBtn但 refresh token 可能仍有效
// 尝试刷新一次,成功后 reload 页面获取登录态
// 使用 sessionStorage 防止死循环reload 后再次进入此分支不会再 reload
if (!sessionStorage.getItem('mlb_refreshed')) {
sessionStorage.setItem('mlb_refreshed', '1');
tryRefresh(function () {
window.location.reload();
});
}
}
} // end if (!onAuthPage)
})();