refactor: ByteMD 迁移至 Tiptap WYSIWYG 编辑器 + Token 刷新优化
- 移除 ByteMD CDN 资源和 goldmark 依赖,替换为 Tiptap ESM 模块 - 编辑器从源码编辑模式改为 WYSIWYG 所见即所得 - 移除 /api/posts/preview 预览接口(Tiptap 直接输出 HTML 无需后端渲染) - 保留 UploadImage 图片上传接口和 bluemonday HTML 消毒 - 工具栏支持加粗、斜体、删除线、代码、标题、列表、引用、代码块、分割线、撤销/重做 - 支持图片粘贴/拖拽上传,Ctrl+S 快捷提交,F11 全屏,字数统计 - 优化 common.js:自动 401 拦截 Token 刷新,修复刷新死循环 - 草稿 localStorage 键名更新为 draft_post_body_html
This commit is contained in:
@ -24,11 +24,59 @@
|
||||
|
||||
// ---- 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 token = window.MetaLab.csrfToken();
|
||||
if (!token) return;
|
||||
var SAME_ORIGIN = /^\/(?!\/)/;
|
||||
var isRefreshing = false;
|
||||
var refreshPromise = null;
|
||||
var pendingRetries = [];
|
||||
|
||||
// Patch XMLHttpRequest (used by register.js / login.js / common.js)
|
||||
function resolveCSRFToken() {
|
||||
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 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
|
||||
while (pendingRetries.length) {
|
||||
var r = pendingRetries.shift();
|
||||
r.reject(new Error('refresh_failed'));
|
||||
}
|
||||
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);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
});
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// ---- Patch XMLHttpRequest ----
|
||||
var origXHROpen = XMLHttpRequest.prototype.open;
|
||||
var origXHRSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
@ -36,31 +84,100 @@
|
||||
this._url = url;
|
||||
return origXHROpen.apply(this, arguments);
|
||||
};
|
||||
var SAME_ORIGIN = /^\/(?!\/)/;
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
if (SAME_ORIGIN.test(this._url) &&
|
||||
this._method && !/^(GET|HEAD|OPTIONS)$/i.test(this._method)) {
|
||||
this.setRequestHeader('X-CSRF-Token', token);
|
||||
var xhr = this;
|
||||
if (SAME_ORIGIN.test(xhr._url) &&
|
||||
xhr._method && !/^(GET|HEAD|OPTIONS)$/i.test(xhr._method)) {
|
||||
xhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader());
|
||||
}
|
||||
return origXHRSend.apply(this, arguments);
|
||||
|
||||
// 401 interception for XHR
|
||||
var origOnReady = xhr.onreadystatechange;
|
||||
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 (used by auto-refresh)
|
||||
// ---- 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', token);
|
||||
headers.set('X-CSRF-Token', getCSRFTokenHeader());
|
||||
} else {
|
||||
headers['X-CSRF-Token'] = token;
|
||||
headers['X-CSRF-Token'] = getCSRFTokenHeader();
|
||||
}
|
||||
options.headers = headers;
|
||||
}
|
||||
return origFetch(url, options);
|
||||
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
@ -162,7 +279,9 @@
|
||||
// ---- Auto-refresh token (silent) ----
|
||||
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
||||
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
||||
// → 页面先渲染为未登录 → 刷新成功后 reload 一次获得登录态
|
||||
// → 页面渲染为未登录 → 刷新成功后 reload 一次获得登录态
|
||||
// → 仅当页面是未登录状态(无 logoutBtn)时才触发 reload
|
||||
// 场景 C:编辑器等页面发 API 请求时遇到 401 → 由 fetch/XHR patch 自动刷新后重试
|
||||
// 不在认证页面执行刷新(登录/注册页无需续期)
|
||||
var onAuthPage = /^\/auth\//.test(window.location.pathname);
|
||||
if (!onAuthPage) {
|
||||
@ -190,23 +309,22 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 仅当存在"记住我"标记 Cookie 时才尝试刷新(避免无需刷新时产生 401 控制台报错)
|
||||
function hasCookie(name) {
|
||||
return document.cookie.split(';').some(function (c) {
|
||||
return c.trim().startsWith(name + '=');
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCookie('mlb_rm')) {
|
||||
// 启动定时刷新:只要用户已登录(有 logoutBtn 元素),就定期刷新
|
||||
if (document.getElementById('logoutBtn')) {
|
||||
// 页面已经是登录态,启动定时刷新即可,无需 reload
|
||||
tryRefresh(function () {
|
||||
refreshTimer = setInterval(function () { tryRefresh(null); }, REFRESH_INTERVAL);
|
||||
|
||||
if (!document.getElementById('logoutBtn') && !sessionStorage.getItem('mlb_refreshed')) {
|
||||
sessionStorage.setItem('mlb_refreshed', '1');
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
sessionStorage.removeItem('mlb_refreshed');
|
||||
} 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)
|
||||
|
||||
Reference in New Issue
Block a user